From 999e1c81f96508633ede1380a82cd8b6e4741b02 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 26 Sep 2026 20:55:49 +0200 Subject: [PATCH] Allow integration of the Shelly BLU TRV via a device other than the BLU Gateway (#182422) --- homeassistant/components/shelly/button.py | 3 +- homeassistant/components/shelly/utils.py | 10 ++-- .../shelly/snapshots/test_button.ambr | 54 ++++++++++++++++++- tests/components/shelly/test_button.py | 11 +++- tests/components/shelly/test_init.py | 53 ++++++++++++++++-- 5 files changed, 117 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index d16abefaff69..25aba6ba2b2f 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, override -from aioshelly.const import MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( @@ -368,7 +368,6 @@ RPC_BUTTONS = { translation_key="calibrate", entity_category=EntityCategory.CONFIG, entity_class=ShellyBluTrvButton, - models={MODEL_BLU_GATEWAY_G3}, ), "smoke_mute": RpcButtonDescription( key="smoke", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6a44a165e206..82494b237e63 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -13,7 +13,6 @@ from aioshelly.const import ( DEFAULT_COAP_PORT, DEFAULT_HTTP_PORT, MODEL_1L, - MODEL_BLU_GATEWAY_G3, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, @@ -922,9 +921,6 @@ def remove_stale_blu_trv_devices( hass: HomeAssistant, rpc_device: RpcDevice, entry: ConfigEntry ) -> None: """Remove stale BLU TRV devices.""" - if rpc_device.model != MODEL_BLU_GATEWAY_G3: - return - dev_reg = dr.async_get(hass) devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) config = rpc_device.config @@ -936,6 +932,12 @@ def remove_stale_blu_trv_devices( # Device is not a sub-device, skip continue + if not any( + connection[0] == CONNECTION_BLUETOOTH for connection in device.connections + ): + # Channel sub-devices have no Bluetooth connection + continue + if any( identifier[0] == DOMAIN and identifier[1] in trv_addrs for identifier in device.identifiers diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr index 0b151aaf3441..0186d1071102 100644 --- a/tests/components/shelly/snapshots/test_button.ambr +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_rpc_blu_trv_button[button.trv_name_calibrate-entry] +# name: test_rpc_blu_trv_button[S3GW-1DBT001][button.trv_name_calibrate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -36,7 +36,57 @@ 'unit_of_measurement': None, }) # --- -# name: test_rpc_blu_trv_button[button.trv_name_calibrate-state] +# name: test_rpc_blu_trv_button[S3GW-1DBT001][button.trv_name_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'TRV-Name Calibrate', + }), + 'context': , + 'entity_id': 'button.trv_name_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_blu_trv_button[SNSW-002P16EU][button.trv_name_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.trv_name_calibrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Calibrate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calibrate', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': 'F8447725F0DD-blutrv:200-calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_blu_trv_button[SNSW-002P16EU][button.trv_name_calibrate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'TRV-Name Calibrate', diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 152820ad21c1..4f807be4f8f4 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, patch -from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_SMOKE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + MODEL_BLU_GATEWAY_G3, + MODEL_PLUS_2PM, + MODEL_PLUS_SMOKE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest from syrupy.assertion import SnapshotAssertion @@ -153,19 +158,21 @@ async def test_rpc_button_reauth_error( assert flow["context"].get("entry_id") == entry.entry_id +@pytest.mark.parametrize("model", [MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM]) async def test_rpc_blu_trv_button( hass: HomeAssistant, mock_blu_trv: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, snapshot: SnapshotAssertion, + model: str, ) -> None: """Test RPC BLU TRV button.""" monkeypatch.delitem(mock_blu_trv.status, "script:1") monkeypatch.delitem(mock_blu_trv.status, "script:2") monkeypatch.delitem(mock_blu_trv.status, "script:3") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + await init_integration(hass, 3, model=model) entity_id = "button.trv_name_calibrate" diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 97e9544b417e..c42a9d2bfe01 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -7,7 +7,12 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP from aioshelly.common import ConnectionOptions -from aioshelly.const import DEFAULT_HTTPS_PORT, MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM +from aioshelly.const import ( + DEFAULT_HTTPS_PORT, + MODEL_2PM_G3, + MODEL_BLU_GATEWAY_G3, + MODEL_PLUS_2PM, +) from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -42,6 +47,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + EVENT_DEVICE_REGISTRY_UPDATED, DeviceRegistry, format_mac, ) @@ -50,7 +56,11 @@ from homeassistant.setup import async_setup_component from . import MOCK_MAC, init_integration, mutate_rpc_device_status, register_sub_device -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + async_capture_events, + async_load_json_object_fixture, +) async def test_custom_coap_port( @@ -760,19 +770,21 @@ async def test_migrate_ble_scanner_mode_future_minor_version( assert entry.options[CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE +@pytest.mark.parametrize("model", [MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM]) async def test_blu_trv_stale_device_removal( hass: HomeAssistant, mock_blu_trv: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + model: str, ) -> None: """Test BLU TRV removal of stale a device after un-pairing.""" trv_200_entity_id = "climate.trv_name" trv_201_entity_id = "climate.trv_201" - monkeypatch.setattr(mock_blu_trv, "model", MODEL_BLU_GATEWAY_G3) - gw_entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + monkeypatch.setattr(mock_blu_trv, "model", model) + gw_entry = await init_integration(hass, 3, model=model) # verify that both trv devices are present assert hass.states.get(trv_200_entity_id) is not None @@ -833,6 +845,39 @@ async def test_empty_device_removal( assert device_registry.async_get(sub_device_entry.id) is None +async def test_sub_device_kept_on_reload( + hass: HomeAssistant, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test channel sub-devices are not removed as stale BLU TRV devices.""" + device_fixture = await async_load_json_object_fixture(hass, "2pm_gen3.json", DOMAIN) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + config_entry = await init_integration(hass, gen=3, model=MODEL_2PM_G3) + + sub_device_ids = set() + for entity_id in ("switch.test_name_output_0", "switch.test_name_output_1"): + entry = entity_registry.async_get(entity_id) + assert entry + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.via_device_id + sub_device_ids.add(device_entry.id) + + events = async_capture_events(hass, EVENT_DEVICE_REGISTRY_UPDATED) + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not [event for event in events if event.data["action"] == "remove"] + for device_id in sub_device_ids: + assert device_registry.async_get(device_id) + + async def test_rpc_waits_for_ble_scanner_at_startup( hass: HomeAssistant, mock_rpc_device: Mock,