From a6e86ab9104c0cd298a8c820ee96b1052aada9ee Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 2 Sep 2026 12:40:46 -0400 Subject: [PATCH 01/10] Update python-roborock to 7.2.3 (#181096) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d13c1525004d50..ff995ec0341d38 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==7.1.1", + "python-roborock==7.2.3", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 847cbcfcae32aa..bc70dde8907560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2799,7 +2799,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==7.1.1 +python-roborock==7.2.3 # homeassistant.components.smarttub python-smarttub==0.0.47 From 0c5bac08963911d2d00d61c67963667f2dd31774 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:05:54 +0200 Subject: [PATCH 02/10] Use @pytest.mark.usefixtures() in Plugwise test code (#181095) --- .../components/plugwise/test_binary_sensor.py | 11 +++++----- tests/components/plugwise/test_button.py | 2 +- tests/components/plugwise/test_climate.py | 10 +++++----- tests/components/plugwise/test_init.py | 4 ++-- tests/components/plugwise/test_number.py | 7 ++++--- tests/components/plugwise/test_select.py | 11 +++++----- tests/components/plugwise/test_sensor.py | 20 +++++++++---------- tests/components/plugwise/test_switch.py | 6 +++--- .../components/plugwise/test_water_heater.py | 6 +++--- 9 files changed, 38 insertions(+), 39 deletions(-) diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index c01da5c5205e59..0063416387d99b 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -1,7 +1,5 @@ """Tests for the Plugwise binary_sensor integration.""" -from unittest.mock import MagicMock - import pytest from syrupy.assertion import SnapshotAssertion @@ -14,11 +12,11 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_binary_sensor_snapshot( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -27,13 +25,13 @@ async def test_adam_binary_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_binary_sensor_snapshot( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -42,10 +40,11 @@ async def test_anna_binary_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_binary_sensor_change( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test change of climate related binary_sensor entities.""" hass.states.async_set("binary_sensor.opentherm_dhw_state", STATE_ON, {}) @@ -61,6 +60,7 @@ async def test_anna_climate_binary_sensor_change( assert state.state == STATE_OFF +@pytest.mark.usefixtures("mock_smile_p1") @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True @@ -69,7 +69,6 @@ async def test_anna_climate_binary_sensor_change( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_v4_binary_sensor_snapshot( hass: HomeAssistant, - mock_smile_p1: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, diff --git a/tests/components/plugwise/test_button.py b/tests/components/plugwise/test_button.py index 8667e2ef893013..364b53bdd6daf8 100644 --- a/tests/components/plugwise/test_button.py +++ b/tests/components/plugwise/test_button.py @@ -13,11 +13,11 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_button_snapshot( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 479479c8249977..4762b3324f424c 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -48,11 +48,11 @@ ) +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_climate_snapshot( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -267,13 +267,13 @@ async def test_adam_restore_state_climate( assert mock_smile_adam_heat_cool.set_schedule_state.call_count == 2 +@pytest.mark.usefixtures("mock_smile_adam_heat_cool") @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_2_climate_snapshot( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -591,13 +591,13 @@ async def test_adam_climate_off_mode_change( assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_climate_snapshot( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -699,13 +699,13 @@ async def test_anna_climate_entity_climate_changes( assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_2_climate_snapshot( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -746,9 +746,9 @@ async def test_tom_without_temperature_measurement( assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None +@pytest.mark.usefixtures("mock_smile_legacy_anna") async def test_legacy_anna_no_schedule( hass: HomeAssistant, - mock_smile_legacy_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test failing to set a schedule with no schedule defined.""" diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index c41bbf87d5ff5b..0f68e8b2dc3cfb 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -167,6 +167,7 @@ async def test_device_via_device_links( assert child_device.via_device_id == gateway_device.id +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( @@ -189,7 +190,6 @@ async def test_migrate_unique_id_temperature( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_smile_anna: MagicMock, entitydata: dict, old_unique_id: str, new_unique_id: str, @@ -210,6 +210,7 @@ async def test_migrate_unique_id_temperature( assert entity_migrated.unique_id == new_unique_id +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -241,7 +242,6 @@ async def test_migrate_unique_id_relay( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_smile_adam: MagicMock, entitydata: dict, old_unique_id: str, new_unique_id: str, diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index e877e38f4bef2a..19fb444cc7a8b0 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -18,11 +18,11 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_number_entities( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -51,8 +51,9 @@ async def test_adam_temperature_offset_change( ) +@pytest.mark.usefixtures("mock_smile_adam") async def test_adam_temperature_offset_out_of_bounds_change( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test changing an Adam temperature_offset number beyond limits.""" with pytest.raises(ServiceValidationError, match="valid range"): @@ -95,13 +96,13 @@ async def test_adam_dhw_setpoint_change( ) +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(NUMBER_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_number_entities( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index a719e3c10dd75d..d064ea4b6996d8 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -25,11 +25,11 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_select_entities( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -61,13 +61,13 @@ async def test_adam_change_select_entity( ) +@pytest.mark.usefixtures("mock_smile_adam_heat_cool") @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_2_select_entities( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -148,9 +148,9 @@ async def test_adam_select_zone_profile( ) +@pytest.mark.usefixtures("mock_smile_legacy_anna") async def test_legacy_anna_select_entities( hass: HomeAssistant, - mock_smile_legacy_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test that "off" is the selected option for legacy Anna without schedule.""" @@ -158,10 +158,11 @@ async def test_legacy_anna_select_entities( assert state.state == "off" +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_select_unavailable_schedule_mode( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Fail-test an Anna thermostat_schedule select option.""" @@ -177,13 +178,13 @@ async def test_anna_select_unavailable_schedule_mode( ) +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_loria_cooling_active"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_entities_with_dhw_mode_select( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 24bad95edc8e25..f7525d62cc638a 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -1,7 +1,5 @@ """Tests for the Plugwise Sensor integration.""" -from unittest.mock import MagicMock - import pytest from syrupy.assertion import SnapshotAssertion @@ -13,13 +11,13 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam_heat_cool") @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_sensor_snapshot( hass: HomeAssistant, - mock_smile_adam_heat_cool: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -28,9 +26,9 @@ async def test_adam_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_adam_jip") async def test_adam_climate_sensor_humidity( hass: HomeAssistant, - mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of climate related humidity sensor entity.""" @@ -39,10 +37,10 @@ async def test_adam_climate_sensor_humidity( assert float(state.state) == 56.2 +@pytest.mark.usefixtures("mock_smile_adam_jip") async def test_unique_id_migration_humidity( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam_jip: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" @@ -82,13 +80,13 @@ async def test_unique_id_migration_humidity( assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_sensor_snapshot( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -97,11 +95,11 @@ async def test_anna_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_anna_p1") @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_p1_sensor_snapshot( hass: HomeAssistant, - mock_smile_anna_p1: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -110,6 +108,7 @@ async def test_anna_p1_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_p1") @pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True @@ -118,7 +117,6 @@ async def test_anna_p1_sensor_snapshot( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_dsmr_sensor_snapshot( hass: HomeAssistant, - mock_smile_p1: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -127,6 +125,7 @@ async def test_p1_dsmr_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_p1") @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True @@ -135,7 +134,6 @@ async def test_p1_dsmr_sensor_snapshot( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_snapshot( hass: HomeAssistant, - mock_smile_p1: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -144,6 +142,7 @@ async def test_p1_3ph_dsmr_sensor_snapshot( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.usefixtures("mock_smile_p1") @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True @@ -151,7 +150,6 @@ async def test_p1_3ph_dsmr_sensor_snapshot( async def test_p1_3ph_dsmr_sensor_disabled_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" @@ -170,11 +168,11 @@ async def test_p1_3ph_dsmr_sensor_disabled_entities( assert float(state.state) == 233.2 +@pytest.mark.usefixtures("mock_stretch") @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_stretch_sensor_snapshot( hass: HomeAssistant, - mock_stretch: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index f04cf92c0da1f2..4d8334883b4d25 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -23,11 +23,11 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_smile_adam") @pytest.mark.parametrize("platforms", [(SWITCH_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_switch_snapshot( hass: HomeAssistant, - mock_smile_adam: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -110,11 +110,11 @@ async def test_adam_climate_switch_negative_testing( ) +@pytest.mark.usefixtures("mock_stretch") @pytest.mark.parametrize("platforms", [(SWITCH_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_stretch_switch_snapshot( hass: HomeAssistant, - mock_stretch: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -161,10 +161,10 @@ async def test_stretch_switch_changes( ) +@pytest.mark.usefixtures("mock_smile_adam") async def test_unique_id_migration_plug_relay( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -plugs to -relay.""" diff --git a/tests/components/plugwise/test_water_heater.py b/tests/components/plugwise/test_water_heater.py index 1a8ffbbffc943c..62667ff034897c 100644 --- a/tests/components/plugwise/test_water_heater.py +++ b/tests/components/plugwise/test_water_heater.py @@ -27,11 +27,11 @@ ) +@pytest.mark.usefixtures("mock_smile_adam_jip") @pytest.mark.parametrize("platforms", [(WATER_HEATER_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_water_heater_snapshot( hass: HomeAssistant, - mock_smile_adam_jip: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -87,13 +87,13 @@ async def test_adam_water_heater_setpoint_change( assert mock_smile_adam_jip.set_number.call_count == 1 +@pytest.mark.usefixtures("mock_smile_anna") @pytest.mark.parametrize("chosen_env", ["anna_loria_cooling_active"], indirect=True) @pytest.mark.parametrize("cooling_present", [False], indirect=True) @pytest.mark.parametrize("platforms", [(WATER_HEATER_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_anna_water_heater_snapshot( hass: HomeAssistant, - mock_smile_anna: MagicMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, setup_platform: MockConfigEntry, @@ -164,9 +164,9 @@ async def test_adam_water_heater_active_state( assert state.state == STATE_GAS +@pytest.mark.usefixtures("mock_smile_adam_jip") async def test_adam_water_heater_setpoint_error_uses_configured_unit( hass: HomeAssistant, - mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test out-of-range setpoint errors use the configured temperature unit.""" From 487f863245f53b116ae83ddc6a7ee735b6c33493 Mon Sep 17 00:00:00 2001 From: rqi14 <26152437+rqi14@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:11:59 +0100 Subject: [PATCH 03/10] Fire a Lutron single press when the button reports only a release (#181094) --- homeassistant/components/lutron/event.py | 3 ++- tests/components/lutron/test_event.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index b9b086eb0bd261..c7c889b420bed6 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -94,7 +94,8 @@ def handle_event( action = LutronEventType.PRESS else: action = LutronEventType.RELEASE - elif event == Button.Event.PRESSED: + elif event in (Button.Event.PRESSED, Button.Event.RELEASED): + # Buttons carrying a hold action report only a release, never a press. action = LutronEventType.SINGLE_PRESS if action: diff --git a/tests/components/lutron/test_event.py b/tests/components/lutron/test_event.py index 856b17a2077d07..3120a576d9650d 100644 --- a/tests/components/lutron/test_event.py +++ b/tests/components/lutron/test_event.py @@ -93,3 +93,24 @@ async def test_event_press_release( assert len(events) == 2 assert events[1].data["action"] == "released" + + +async def test_event_release_only_button( + hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """A button that only ever reports a release still fires a single press.""" + mock_config_entry.add_to_hass(hass) + + button = mock_lutron.areas[0].keypads[0].buttons[0] + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + events = async_capture_events(hass, "lutron_event") + + for call in button.subscribe.call_args_list: + callback = call[0][0] + callback(button, None, Button.Event.RELEASED, None) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["action"] == "single" From 573f65a04984c86aedeaa8151d7d5b443a94e4c5 Mon Sep 17 00:00:00 2001 From: rjones-gentex Date: Wed, 2 Sep 2026 13:13:28 -0400 Subject: [PATCH 04/10] Upgrade homelink package version to remove pyOpenSSL dep (#181092) --- homeassistant/components/gentex_homelink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gentex_homelink/manifest.json b/homeassistant/components/gentex_homelink/manifest.json index 34110d6db8235c..fd97d52e4b80f2 100644 --- a/homeassistant/components/gentex_homelink/manifest.json +++ b/homeassistant/components/gentex_homelink/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["homelink-integration-api==0.0.5"] + "requirements": ["homelink-integration-api==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc70dde8907560..e25e173162ef5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1315,7 +1315,7 @@ home-assistant-intents==2026.8.28 homekit-audio-proxy==1.2.1 # homeassistant.components.gentex_homelink -homelink-integration-api==0.0.5 +homelink-integration-api==0.1.0 # homeassistant.components.homematicip_cloud homematicip==2.16.0 From 34f617da9e7f3ec5bddfbc039f0189e86954a83a Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 2 Sep 2026 19:13:56 +0200 Subject: [PATCH 05/10] Expose Zooz ZSE43 vibration sensor as vibration, not tampering (#181003) --- .../components/zwave_js/binary_sensor.py | 42 +- tests/components/zwave_js/conftest.py | 14 + .../zwave_js/fixtures/zooz_zse43_state.json | 1382 +++++++++++++++++ .../components/zwave_js/test_binary_sensor.py | 21 + 4 files changed, 1455 insertions(+), 4 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/zooz_zse43_state.json diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 2d761e97cf8ce3..f3c5b29de59fdf 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -39,6 +39,7 @@ async_delete_issue, ) from homeassistant.helpers.start import async_at_started +from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -684,10 +685,16 @@ def __init__( if description: self.entity_description = description - # Entity class attributes - self._attr_name = self.generate_name( - alternate_value_name=self.info.primary_value.metadata.states[self.state_key] - ) + # Notification sensors are named after their notification state. A + # description may set its own name to override that. + if not hasattr(self, "entity_description") or ( + self.entity_description.name is UNDEFINED + ): + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.metadata.states[ + self.state_key + ] + ) self._attr_unique_id = f"{self._attr_unique_id}.{self.state_key}" @property @@ -870,6 +877,33 @@ def __init__( DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ + # Zooz ZSE43 Tilt/Shock Sensor. Its vibration sensor is reported + # through the Home Security "Cover status" notification, so expose + # that notification as a vibration sensor. + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + manufacturer_id={0x027A}, + product_id={0xE003}, + product_type={0x7000}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Home Security"}, + property_key={"Cover status"}, + type={ValueType.NUMBER}, + any_available_states_keys={3}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HOME_SECURITY) + }, + ), + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 7: Home Security - State Id 3 (product cover removed) + key=NOTIFICATION_HOME_SECURITY, + name="Vibration", + states={3}, + device_class=BinarySensorDeviceClass.VIBRATION, + ), + entity_class=ZWaveNotificationBinarySensor, + ), NewZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, primary_value=ZWaveValueDiscoverySchema( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 051951fd17be65..256678ecd49d97 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -517,6 +517,12 @@ def switch_zooz_zen72_state_fixture() -> dict[str, Any]: return load_json_object_fixture("switch_zooz_zen72_state.json", DOMAIN) +@pytest.fixture(name="zooz_zse43_state", scope="package") +def zooz_zse43_state_fixture() -> dict[str, Any]: + """Load the Zooz ZSE43 tilt/shock sensor node state fixture data.""" + return load_json_object_fixture("zooz_zse43_state.json", DOMAIN) + + @pytest.fixture(name="indicator_test_state", scope="package") def indicator_test_state_fixture() -> dict[str, Any]: """Load the indicator CC test node state fixture data.""" @@ -1435,6 +1441,14 @@ def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state) -> Node: return node +@pytest.fixture(name="zooz_zse43") +def zooz_zse43_fixture(client: MagicMock, zooz_zse43_state: NodeDataType) -> Node: + """Mock a Zooz ZSE43 tilt/shock sensor node.""" + node = Node(client, copy.deepcopy(zooz_zse43_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="indicator_test") def indicator_test_fixture(client, indicator_test_state) -> Node: """Mock a indicator CC test node.""" diff --git a/tests/components/zwave_js/fixtures/zooz_zse43_state.json b/tests/components/zwave_js/fixtures/zooz_zse43_state.json new file mode 100644 index 00000000000000..d1064cc1ebb7c6 --- /dev/null +++ b/tests/components/zwave_js/fixtures/zooz_zse43_state.json @@ -0,0 +1,1382 @@ +{ + "nodeId": 13, + "index": 0, + "installerIcon": 3078, + "userIcon": 3072, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57347, + "productType": 28672, + "firmwareVersion": "1.20.1", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse43.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE43", + "description": "Tilt Shock XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57347 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": { + "1": { + "groupId": 1, + "label": "Lifeline", + "maxNodes": 3, + "isLifeline": true, + "multiChannel": "auto" + }, + "2": { + "groupId": 2, + "label": "Tilt On/Off", + "maxNodes": 5, + "isLifeline": false, + "multiChannel": "auto" + }, + "3": { + "groupId": 3, + "label": "Shock On/Off", + "maxNodes": 5, + "isLifeline": false, + "multiChannel": "auto" + } + }, + "paramInformation": { + "_map": { + "{\"parameter\":1}": { + "parameterNumber": 1, + "label": "LED Indicator", + "valueSize": 1, + "allowed": [ + { + "from": 0, + "to": 3 + } + ], + "minValue": 0, + "maxValue": 3, + "unsigned": false, + "defaultValue": 3, + "allowManualEntry": false, + "options": [ + { + "value": 0, + "label": "Disable" + }, + { + "value": 1, + "label": "LED on shock only" + }, + { + "value": 2, + "label": "LED on tilt only" + }, + { + "value": 3, + "label": "LED on shock and tilt" + } + ] + }, + "{\"parameter\":2}": { + "parameterNumber": 2, + "label": "Battery Report Threshold", + "valueSize": 1, + "allowed": [ + { + "from": 1, + "to": 50 + } + ], + "minValue": 1, + "maxValue": 50, + "unsigned": true, + "defaultValue": 5, + "unit": "%", + "allowManualEntry": true, + "purpose": "reporting_threshold.battery", + "options": [] + }, + "{\"parameter\":3}": { + "parameterNumber": 3, + "label": "Low Battery Alarm Threshold", + "valueSize": 1, + "allowed": [ + { + "from": 10, + "to": 50 + } + ], + "minValue": 10, + "maxValue": 50, + "unsigned": true, + "defaultValue": 20, + "unit": "%", + "allowManualEntry": true, + "purpose": "reporting_threshold.low_battery", + "options": [] + }, + "{\"parameter\":4}": { + "parameterNumber": 4, + "label": "Shock Sensitivity", + "valueSize": 1, + "allowed": [ + { + "from": 0, + "to": 2 + } + ], + "minValue": 0, + "maxValue": 2, + "unsigned": false, + "defaultValue": 0, + "unit": "%", + "allowManualEntry": false, + "options": [ + { + "value": 0, + "label": "High" + }, + { + "value": 1, + "label": "Medium" + }, + { + "value": 2, + "label": "Low" + } + ] + }, + "{\"parameter\":5}": { + "parameterNumber": 5, + "label": "Group 2: On Delay", + "valueSize": 4, + "allowed": [ + { + "from": 0, + "to": 3600 + } + ], + "minValue": 0, + "maxValue": 3600, + "unsigned": true, + "defaultValue": 0, + "unit": "seconds", + "allowManualEntry": true, + "options": [] + }, + "{\"parameter\":6}": { + "parameterNumber": 6, + "label": "Group 2: Off Delay", + "valueSize": 4, + "allowed": [ + { + "from": 0, + "to": 3600 + } + ], + "minValue": 0, + "maxValue": 3600, + "unsigned": true, + "defaultValue": 0, + "unit": "seconds", + "allowManualEntry": true, + "options": [] + }, + "{\"parameter\":7}": { + "parameterNumber": 7, + "label": "Enable Sensors", + "valueSize": 1, + "allowed": [ + { + "from": 0, + "to": 2 + } + ], + "minValue": 0, + "maxValue": 2, + "unsigned": false, + "defaultValue": 2, + "allowManualEntry": false, + "options": [ + { + "value": 0, + "label": "Only tilt sensor enabled" + }, + { + "value": 1, + "label": "Only shock sensor enabled" + }, + { + "value": 2, + "label": "Both sensors enabled" + } + ] + }, + "{\"parameter\":8}": { + "parameterNumber": 8, + "label": "Group 2: Tilt Sensor Basic Reports", + "valueSize": 1, + "allowed": [ + { + "from": 0, + "to": 3 + } + ], + "minValue": 0, + "maxValue": 3, + "unsigned": false, + "defaultValue": 3, + "allowManualEntry": false, + "options": [ + { + "value": 0, + "label": "Disable" + }, + { + "value": 1, + "label": "0xff (On) when tilt detected" + }, + { + "value": 2, + "label": "0x00 (Off) when tilt cleared" + }, + { + "value": 3, + "label": "0xff (On) when tilt detected; 0x00 (Off) when tilt cleared" + } + ] + }, + "{\"parameter\":9}": { + "parameterNumber": 9, + "label": "Group 3: Shock Sensor Basic Reports", + "valueSize": 1, + "allowed": [ + { + "from": 0, + "to": 3 + } + ], + "minValue": 0, + "maxValue": 3, + "unsigned": false, + "defaultValue": 3, + "allowManualEntry": false, + "options": [ + { + "value": 0, + "label": "Disable" + }, + { + "value": 1, + "label": "0xff (On) when shock detected" + }, + { + "value": 2, + "label": "0x00 (Off) when shock cleared" + }, + { + "value": 3, + "label": "0xff (On) when shock detected; 0x00 (Off) when shock cleared" + } + ] + } + } + }, + "metadata": { + "wakeup": "Click the Z-Wave button 4 times quickly. The LED indicator will flash twice to confirm the device is awake.", + "inclusion": "Click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing and turn off once inclusion is completed.", + "exclusion": "Click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing and turn off when exclusion is complete.", + "reset": "1. Click the Z-Wave button twice and hold it the third time for 10 seconds\n2. The LED indicator will blink continuously\n3. Immediately click the Z-Wave button twice more to finalize the reset\n4. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE43", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe003:1.20.1", + "statistics": { + "commandsTX": 122, + "commandsRX": 124, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 3, + "rtt": 21.1, + "lastSeen": "2026-09-01T08:31:34.188Z", + "rssi": -62, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -62, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2026-09-01T08:25:06.085Z", + "protocol": 0, + "sdkVersion": "7.13.10", + "canSleep": true, + "hardwareVersion": 1, + "hasSUCReturnRoute": true, + "manufacturer": "Zooz", + "dsk": "**REDACTED**", + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 3, + "min": 0, + "max": 3, + "states": { + "0": "Disable", + "1": "LED on shock only", + "2": "LED on tilt only", + "3": "LED on shock and tilt" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 3 + } + ] + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "allowed": [ + { + "from": 10, + "to": 50 + } + ], + "purpose": "reporting_threshold.low_battery" + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Shock Sensitivity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Shock Sensitivity", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "High", + "1": "Medium", + "2": "Low" + }, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 2 + } + ] + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Group 2: On Delay", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 2: On Delay", + "default": 0, + "min": 0, + "max": 3600, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 3600 + } + ] + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Group 2: Off Delay", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 2: Off Delay", + "default": 0, + "min": 0, + "max": 3600, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 3600 + } + ] + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Enable Sensors", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Sensors", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Only tilt sensor enabled", + "1": "Only shock sensor enabled", + "2": "Both sensors enabled" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 2 + } + ] + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Group 2: Tilt Sensor Basic Reports", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 2: Tilt Sensor Basic Reports", + "default": 3, + "min": 0, + "max": 3, + "states": { + "0": "Disable", + "1": "0xff (On) when tilt detected", + "2": "0x00 (Off) when tilt cleared", + "3": "0xff (On) when tilt detected; 0x00 (Off) when tilt cleared" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 3 + } + ] + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Group 3: Shock Sensor Basic Reports", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 3: Shock Sensor Basic Reports", + "default": 3, + "min": 0, + "max": 3, + "states": { + "0": "Disable", + "1": "0xff (On) when shock detected", + "2": "0x00 (Off) when shock cleared", + "3": "0xff (On) when shock detected; 0x00 (Off) when shock cleared" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "allowed": [ + { + "from": 0, + "to": 3 + } + ] + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Report for contact", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Select report type(Notification or binary sensor) when contact sensor triggered", + "label": "Report for contact", + "default": 0, + "min": 0, + "max": 2, + "valueSize": 1, + "format": 1, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Report delay after tamper disarming", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Report will be issued after some seconds determined by this parameter", + "label": "Report delay after tamper disarming", + "default": 3, + "min": 0, + "max": 3600, + "valueSize": 2, + "format": 1, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Battery Report Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "allowed": [ + { + "from": 1, + "to": 50 + } + ], + "purpose": "reporting_threshold.battery" + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Door state", + "propertyName": "Access Control", + "propertyKeyName": "Door state", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Door state", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "22": "Window/door is open", + "23": "Window/door is closed", + "5632": "Window/door is open in regular position", + "5633": "Window/door is open in tilt position" + }, + "stateful": true, + "secret": false + }, + "value": 22 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Cover status", + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Cover status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Tampering, product cover removed" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "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": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Opening state", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open" + }, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Door state (simple)", + "propertyName": "Access Control", + "propertyKeyName": "Door state (simple)", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Door state (simple)", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "22": "Window/door is open", + "23": "Window/door is closed" + }, + "stateful": true, + "secret": false + }, + "value": 22 + }, + { + "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": 57347 + }, + { + "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": 28672 + }, + { + "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": 634 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 0, + "max": 86400, + "states": { + "0": "Disabled" + }, + "stateful": true, + "secret": false, + "allowed": [ + { + "value": 0 + }, + { + "from": 3600, + "to": 86400, + "step": 60 + } + ] + }, + "value": 21600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "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.20"] + }, + { + "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.13" + }, + { + "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": 43707 + }, + { + "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.20.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": 423 + }, + { + "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.13.10" + }, + { + "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": 423 + }, + { + "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.13.10" + }, + { + "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.13.10" + }, + { + "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": 13, + "index": 0, + "installerIcon": 3078, + "userIcon": 3072, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "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": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 48, + "name": "Binary Sensor", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 86c979e8bc5cc2..c6d942577cb3c8 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, @@ -1706,3 +1707,23 @@ async def test_legacy_door_open_state_stale_repair_issue_cleaned_up( ) is None ) + + +ZSE43_VIBRATION_SENSOR = "binary_sensor.tilt_shock_xs_sensor_vibration" + + +@pytest.mark.usefixtures("zooz_zse43", "integration") +async def test_zooz_zse43_vibration_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test the ZSE43 cover-removed notification is exposed as a vibration sensor.""" + state = hass.states.get(ZSE43_VIBRATION_SENSOR) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.VIBRATION + assert state.attributes[ATTR_FRIENDLY_NAME] == "Tilt Shock XS Sensor Vibration" + + entity_entry = entity_registry.async_get(ZSE43_VIBRATION_SENSOR) + assert entity_entry + assert entity_entry.original_name == "Vibration" + assert entity_entry.entity_category is None From 33cc6839f98c19e9e87f8f375380e09e632d0ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 2 Sep 2026 19:27:17 +0200 Subject: [PATCH 06/10] Add missing state_class for miele filling level sensors (#181093) --- homeassistant/components/miele/sensor.py | 5 + .../miele/snapshots/test_sensor.ambr | 280 ++++++++++++++---- 2 files changed, 229 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 1f4c9f3b2f1253..bf507a218426c5 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -723,6 +723,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: value_fn=lambda value: value.twin_dos_container_1_filling_level, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ), MieleSensorDefinition( @@ -736,6 +737,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: value_fn=lambda value: value.twin_dos_container_2_filling_level, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ), MieleSensorDefinition( @@ -745,6 +747,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: translation_key="power_disk_level", value_fn=lambda value: value.power_disc_filling_level, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -755,6 +758,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: translation_key="salt_level", value_fn=lambda value: value.salt_filling_level, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), ), @@ -765,6 +769,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: translation_key="rinse_aid_level", value_fn=lambda value: value.rinse_aid_filling_level, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), ), diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index bf685098aa28da..a1b0ee9b6f6618 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -526,7 +526,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -561,6 +563,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -577,7 +580,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -612,6 +617,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -628,7 +634,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -663,6 +671,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -679,7 +688,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -714,6 +725,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -730,7 +742,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -765,6 +779,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -781,7 +796,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -816,6 +833,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -832,7 +850,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -867,6 +887,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -2227,7 +2248,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2262,6 +2285,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -2278,7 +2302,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2313,6 +2339,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -2329,7 +2356,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2364,6 +2393,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -2380,7 +2410,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2415,6 +2447,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -2431,7 +2464,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2466,6 +2501,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -2482,7 +2518,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2517,6 +2555,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -2533,7 +2572,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2568,6 +2609,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -3107,7 +3149,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3142,6 +3186,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -3158,7 +3203,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3193,6 +3240,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -3209,7 +3257,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3244,6 +3294,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -3260,7 +3311,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3295,6 +3348,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -3311,7 +3365,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3346,6 +3402,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -3362,7 +3419,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3397,6 +3456,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -3413,7 +3473,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3448,6 +3510,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -4218,7 +4281,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4253,6 +4318,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -4269,7 +4335,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4304,6 +4372,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -4320,7 +4389,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4355,6 +4426,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -4371,7 +4443,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4406,6 +4480,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -4422,7 +4497,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4457,6 +4534,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -4473,7 +4551,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4508,6 +4588,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -4524,7 +4605,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4559,6 +4642,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -4982,7 +5066,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5017,6 +5103,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -5033,7 +5120,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5068,6 +5157,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -5084,7 +5174,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5119,6 +5211,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -5135,7 +5228,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5170,6 +5265,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -5186,7 +5282,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -5221,6 +5319,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -8180,7 +8279,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8215,6 +8316,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 1 level', + : , : '%', }), 'context': , @@ -8231,7 +8333,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8266,6 +8370,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 2 level', + : , : '%', }), 'context': , @@ -8798,7 +8903,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8833,6 +8940,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -8849,7 +8957,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8884,6 +8994,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -8900,7 +9011,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8935,6 +9048,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -8951,7 +9065,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -8986,6 +9102,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -9002,7 +9119,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -9037,6 +9156,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -11996,7 +12116,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12031,6 +12153,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 1 level', + : , : '%', }), 'context': , @@ -12047,7 +12170,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12082,6 +12207,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 2 level', + : , : '%', }), 'context': , @@ -12614,7 +12740,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12649,6 +12777,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -12665,7 +12794,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12700,6 +12831,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -12716,7 +12848,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12751,6 +12885,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -12767,7 +12902,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12802,6 +12939,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -12818,7 +12956,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -12853,6 +12993,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -15812,7 +15953,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -15847,6 +15990,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 1 level', + : , : '%', }), 'context': , @@ -15863,7 +16007,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -15898,6 +16044,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Washing machine TwinDos 2 level', + : , : '%', }), 'context': , @@ -16182,7 +16329,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16217,6 +16366,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test PowerDisk level', + : , : '%', }), 'context': , @@ -16233,7 +16383,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16268,6 +16420,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Rinse aid level', + : , : '%', }), 'context': , @@ -16284,7 +16437,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16319,6 +16474,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test Salt level', + : , : '%', }), 'context': , @@ -16335,7 +16491,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16370,6 +16528,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -16386,7 +16545,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16421,6 +16582,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 1 level', + : , : '%', }), 'context': , @@ -16437,7 +16599,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16472,6 +16636,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , @@ -16488,7 +16653,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -16523,6 +16690,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Miele test TwinDos 2 level', + : , : '%', }), 'context': , From d0ebb31684ee4c49407480da71a3b537d3b12751 Mon Sep 17 00:00:00 2001 From: rqi14 <26152437+rqi14@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:37:44 +0100 Subject: [PATCH 07/10] Update Lutron light state without waiting for the controller (#181086) --- homeassistant/components/lutron/light.py | 14 +++++ tests/components/lutron/conftest.py | 7 +++ tests/components/lutron/test_light.py | 76 ++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 49681392331cfc..30d7c5efcb0240 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -96,6 +96,8 @@ def turn_on(self, **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: args["fade_time_seconds"] = kwargs[ATTR_TRANSITION] self._lutron_device.set_level(**args) + # Publish now rather than waiting for the controller to report back. + self._publish_level() @override def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +106,18 @@ def turn_off(self, **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: args["fade_time_seconds"] = kwargs[ATTR_TRANSITION] self._lutron_device.set_level(**args) + self._publish_level() + + def _publish_level(self) -> None: + """Publish the device's current level without waiting for a report. + + Reads `last_level()` like `_update_callback` does, so a report that + lands around the same time cannot be overwritten by a separately held + assumed value -- whichever reached the library last is what gets + published. + """ + self._update_attrs() + self.schedule_update_ha_state() @property @override diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py index 1aa71ba59b4db7..bdac43e7e8b0fc 100644 --- a/tests/components/lutron/conftest.py +++ b/tests/components/lutron/conftest.py @@ -48,6 +48,13 @@ def mock_lutron() -> Generator[MagicMock]: light.is_dimmable = True light.type = "LIGHT" light.last_level.return_value = 0 + + # pylutron's Output.set_level() stores the new level, which last_level() + # then returns. Mirror that so the mock behaves like the library. + def _set_level(new_level, fade_time_seconds=None): + light.last_level.return_value = new_level + + light.set_level.side_effect = _set_level area.outputs.append(light) # Mock a switch diff --git a/tests/components/lutron/test_light.py b/tests/components/lutron/test_light.py index 8999004090a98d..6830a913400512 100644 --- a/tests/components/lutron/test_light.py +++ b/tests/components/lutron/test_light.py @@ -226,3 +226,79 @@ async def test_light_brightness_restore( ) # HA level 127 -> Lutron level ~49.8 light.set_level.assert_called_with(new_level=pytest.approx(50.0, abs=0.5)) + + +async def test_light_state_published_without_waiting_for_a_report( + hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """State follows the command without waiting for the controller to report. + + The subscription still delivers the authoritative value later, but it can + be slow, and on a HomeWorks QS system driven by keypad scenes it never + arrives for zone levels at all. + """ + mock_config_entry.add_to_hass(hass) + + light = mock_lutron.areas[0].outputs[0] + light.last_level.return_value = 0 + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.test_area_test_light" + assert hass.states.get(entity_id).state == STATE_OFF + + # No callback is fired here: nothing reports the change back. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_a_later_report_wins_over_the_commanded_value( + hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """The controller remains authoritative when it does report.""" + mock_config_entry.add_to_hass(hass) + + light = mock_lutron.areas[0].outputs[0] + light.last_level.return_value = 0 + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.test_area_test_light" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).attributes[ATTR_BRIGHTNESS] == 128 + + # The controller says the zone actually landed on 100%. + light.last_level.return_value = 100 + callback = light.subscribe.call_args[0][0] + callback(light, None, None, None) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).attributes[ATTR_BRIGHTNESS] == 255 From f04652b3242039e40b076743f887df6107e0ac5f Mon Sep 17 00:00:00 2001 From: Balloob Bot Date: Wed, 2 Sep 2026 19:57:03 +0200 Subject: [PATCH 08/10] Deprecate modbus get_hub in favour of async_get_unit (#179933) Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/modbus/__init__.py | 21 ++++- tests/components/modbus/test_deprecation.py | 91 +++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/components/modbus/test_deprecation.py diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index f6cb4fac8a36b0..74b873eb9e7c44 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -5,6 +5,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -26,7 +27,25 @@ def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: - """Return modbus hub with name.""" + """Return modbus hub with name. + + Deprecated. Use `async_get_unit` instead, which builds a connection from + credentials the integration holds rather than attaching to a hub the user + configured in YAML under a name the integration has to be told. + """ + report_usage( + "calls `modbus.get_hub`, which is deprecated in favour of " + "`modbus.async_get_unit`. Collect the connection details in your own " + "config flow and ask for a unit on them", + breaks_in_ha_version="2027.10", + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, + # get_hub is defined here, so its own frame is the first one the stack + # walk meets. Without this it reports modbus every time, and the core + # behavior above then silences the caller it was meant to name. + exclude_integrations={DOMAIN}, + ) return hass.data[DATA_MODBUS_HUBS][name] diff --git a/tests/components/modbus/test_deprecation.py b/tests/components/modbus/test_deprecation.py new file mode 100644 index 00000000000000..abaecb9a1d7c4a --- /dev/null +++ b/tests/components/modbus/test_deprecation.py @@ -0,0 +1,91 @@ +"""Test the deprecation of get_hub.""" + +import importlib.util +from pathlib import Path +import sys +from types import ModuleType + +import pytest + +from homeassistant.components.modbus import get_hub +from homeassistant.components.modbus.modbus import DATA_MODBUS_HUBS +from homeassistant.core import HomeAssistant + + +def _caller_in(root: Path, path: str) -> ModuleType: + """Load a module that calls get_hub, from a file at *path*. + + The report names an integration by walking the stack for a file under + `custom_components/` or `homeassistant/components/`, so a test of who gets + named has to call from a file that actually sits there. + """ + source = root / path / "caller.py" + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text( + "from homeassistant.components.modbus import get_hub\n" + "\n" + "def call(hass):\n" + ' return get_hub(hass, "hub")\n' + ) + + spec = importlib.util.spec_from_file_location( + f"caller_{source.parent.name}", source + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(name="hub") +def hub_fixture(hass: HomeAssistant) -> object: + """Put one hub in place for get_hub to return.""" + hub = object() + hass.data[DATA_MODBUS_HUBS] = {"hub": hub} + return hub + + +async def test_a_custom_integration_is_warned( + hass: HomeAssistant, + hub: object, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A custom integration has its own config flow to collect details in. + + Called through the real stack, so this also covers the report reaching + past modbus's own frame to the caller that asked. + """ + caller = _caller_in(tmp_path, "custom_components/my_integration") + + assert caller.call(hass) is hub + + assert "deprecated" in caplog.text + assert "async_get_unit" in caplog.text + assert "my_integration" in caplog.text + assert "2027.10" in caplog.text + assert "modbus" not in caplog.text.split("my_integration")[0] + + +async def test_a_core_integration_is_not_warned( + hass: HomeAssistant, + hub: object, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Flexit is the one caller in core, and the user cannot act on it.""" + caller = _caller_in(tmp_path, "homeassistant/components/flexit") + + assert caller.call(hass) is hub + + assert "deprecated" not in caplog.text + + +async def test_a_caller_outside_any_integration_is_not_warned( + hass: HomeAssistant, hub: object, caplog: pytest.LogCaptureFixture +) -> None: + """Nothing to name and nobody to tell, so it stays quiet.""" + assert get_hub(hass, "hub") is hub + + assert "deprecated" not in caplog.text From 5c5073dd3ae42e844a5711ec93de8e9c43338209 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:00:42 -0700 Subject: [PATCH 09/10] Type the lookin UDP manager with a HassKey (#181098) --- homeassistant/components/lookin/__init__.py | 18 +++++------ tests/components/lookin/test_init.py | 35 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index a7296fee15c805..461ed4ee58265d 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,5 +1,4 @@ """The lookin integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine @@ -23,6 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -37,8 +37,6 @@ LOGGER = logging.getLogger(__name__) -UDP_MANAGER = "udp_manager" - def _async_climate_updater( lookin_protocol: LookInHttpProtocol, @@ -90,9 +88,13 @@ async def async_stop(self) -> None: self._subscriptions = None +# One UDP listener serves every lookin device, so the manager is shared between +# config entries rather than owned by any one of them. +UDP_MANAGER: HassKey[LookinUDPManager] = HassKey(DOMAIN) + + 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] lookin_protocol = LookInHttpProtocol( api_uri=f"http://{host}", session=async_get_clientsession(hass) @@ -159,10 +161,8 @@ def _async_meteo_push_update(event: UDPEvent) -> None: meteo.update_from_value(event.value) meteo_coordinator.async_set_updated_data(meteo) - if UDP_MANAGER not in domain_data: - manager = domain_data[UDP_MANAGER] = LookinUDPManager() - else: - manager = domain_data[UDP_MANAGER] + if (manager := hass.data.get(UDP_MANAGER)) is None: + manager = hass.data[UDP_MANAGER] = LookinUDPManager() lookin_udp_subs = await manager.async_get_subscriptions() @@ -200,7 +200,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> b 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] + manager = hass.data[UDP_MANAGER] await manager.async_stop() return unload_ok diff --git a/tests/components/lookin/test_init.py b/tests/components/lookin/test_init.py index 7d3dd7bd6abb6b..3c613a15f4665d 100644 --- a/tests/components/lookin/test_init.py +++ b/tests/components/lookin/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.lookin import UDP_MANAGER from homeassistant.components.lookin.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST @@ -81,3 +82,37 @@ async def test_controlled_device_links_to_lookin_device( ) assert controlled_device is not None assert controlled_device.via_device_id == lookin_device.id + + +async def test_udp_manager_outlives_the_config_entry(hass: HomeAssistant) -> None: + """Test the shared UDP manager is reused rather than rebuilt per entry.""" + device = _mocked_device() + remote = _mocked_remote() + protocol = _mocked_protocol(device, remote) + + subscriptions = MagicMock() + subscriptions.subscribe_event = MagicMock(return_value=MagicMock()) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID + ) + entry.add_to_hass(hass) + + with ( + patch(f"{MODULE}.LookInHttpProtocol", return_value=protocol), + patch(f"{MODULE}.LookinUDPSubscriptions", return_value=subscriptions), + patch(f"{MODULE}.start_lookin_udp", AsyncMock(return_value=MagicMock())), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + manager = hass.data[UDP_MANAGER] + assert manager is not None + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + # The manager is keyed globally, not on the entry, so a reload reuses it + # instead of leaving a second listener behind. + assert hass.data[UDP_MANAGER] is manager From f01e29709bc209e54c011affd1f73fdf7a158756 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:01:10 +0200 Subject: [PATCH 10/10] Bump github/codeql-action from 4.37.8 to 4.37.9 (#181053) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 112703d7280a57..4978fd6b2a4b90 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: python