Allow integration of the Shelly BLU TRV via a device other than the BLU Gateway (#182422)

This commit is contained in:
Maciej Bieniek
2026-09-26 20:55:49 +02:00
committed by GitHub
parent ab241d37ef
commit 999e1c81f9
5 changed files with 117 additions and 14 deletions
+1 -2
View File
@@ -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",
+6 -4
View File
@@ -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
@@ -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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'TRV-Name Calibrate',
}),
'context': <ANY>,
'entity_id': 'button.trv_name_calibrate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.trv_name_calibrate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'TRV-Name Calibrate',
+9 -2
View File
@@ -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"
+49 -4
View File
@@ -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,