Merge branch 'dev' into handle-timeoutu-in-subinfo-call

This commit is contained in:
Joakim Sørensen 2025-07-08 13:47:34 +01:00 committed by GitHub
commit a306114855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 751 additions and 69 deletions

View File

@ -28,33 +28,31 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
comment: all tests missing
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Network information not relevant
discovery:
status: exempt
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
docs-data-update: todo
docs-examples: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done

View File

@ -1140,6 +1140,18 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="EnergyEvseStateOfCharge",
translation_key="evse_soc",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.EnergyEvse.Attributes.StateOfCharge,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(

View File

@ -402,6 +402,9 @@
"other": "Other fault"
}
},
"evse_soc": {
"name": "State of charge"
},
"pump_control_mode": {
"name": "Control mode",
"state": {

View File

@ -10,8 +10,11 @@ from homeassistant.components.homeassistant import exposed_entities
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
)
from .const import CONF_INVERT, CONF_TARGET_DOMAIN
@ -19,24 +22,14 @@ _LOGGER = logging.getLogger(__name__)
@callback
def async_add_to_device(
hass: HomeAssistant, entry: ConfigEntry, entity_id: str
) -> str | None:
"""Add our config entry to the tracked entity's device."""
def async_get_parent_device_id(hass: HomeAssistant, entity_id: str) -> str | None:
"""Get the parent device id."""
registry = er.async_get(hass)
device_registry = dr.async_get(hass)
device_id = None
if (
not (wrapped_switch := registry.async_get(entity_id))
or not (device_id := wrapped_switch.device_id)
or not (device_registry.async_get(device_id))
):
return device_id
if not (wrapped_switch := registry.async_get(entity_id)):
return None
device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id)
return device_id
return wrapped_switch.device_id
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -68,9 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
add_helper_config_entry_to_device=False,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_add_to_device(hass, entry, entity_id),
source_device_id=async_get_parent_device_id(hass, entity_id),
source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID],
source_entity_removed=source_entity_removed,
)
@ -96,8 +90,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
options = {**config_entry.options}
if config_entry.minor_version < 2:
options.setdefault(CONF_INVERT, False)
if config_entry.version < 3:
# Remove the switch_as_x config entry from the source device
if source_device_id := async_get_parent_device_id(
hass, options[CONF_ENTITY_ID]
):
async_remove_helper_config_entry_from_source_device(
hass,
helper_config_entry_id=config_entry.entry_id,
source_device_id=source_device_id,
)
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
config_entry, options=options, minor_version=3
)
_LOGGER.debug(

View File

@ -58,7 +58,7 @@ class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
options_flow = OPTIONS_FLOW
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title and hide the wrapped entity if registered."""

View File

@ -15,7 +15,6 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, ToggleEntity
from homeassistant.helpers.event import async_track_state_change_event
@ -48,12 +47,8 @@ class BaseEntity(Entity):
if wrapped_switch:
name = wrapped_switch.original_name
self._device_id = device_id
if device_id and (device := device_registry.async_get(device_id)):
self._attr_device_info = DeviceInfo(
connections=device.connections,
identifiers=device.identifiers,
)
self.device_entry = device
self._attr_entity_category = entity_category
self._attr_has_entity_name = has_entity_name
self._attr_name = name

View File

@ -113,11 +113,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
),
"Plug Mini (US)": (
VOLTAGE_DESCRIPTION,
CURRENT_DESCRIPTION_IN_A,
CURRENT_DESCRIPTION_IN_MA,
),
"Plug Mini (JP)": (
VOLTAGE_DESCRIPTION,
CURRENT_DESCRIPTION_IN_A,
CURRENT_DESCRIPTION_IN_MA,
),
"Hub 2": (
TEMPERATURE_DESCRIPTION,

View File

@ -825,21 +825,25 @@ class EntityPlatform:
entity.add_to_platform_abort()
return
if self.config_entry and (device_info := entity.device_info):
try:
device = dev_reg.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**device_info,
)
except dev_reg.DeviceInfoError as exc:
self.logger.error(
"%s: Not adding entity with invalid device info: %s",
self.platform_name,
str(exc),
)
entity.add_to_platform_abort()
return
device: dev_reg.DeviceEntry | None
if self.config_entry:
if device_info := entity.device_info:
try:
device = dev_reg.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_subentry_id=config_subentry_id,
**device_info,
)
except dev_reg.DeviceInfoError as exc:
self.logger.error(
"%s: Not adding entity with invalid device info: %s",
self.platform_name,
str(exc),
)
entity.add_to_platform_abort()
return
else:
device = entity.device_entry
else:
device = None

View File

@ -14,6 +14,7 @@ from .event import async_track_entity_registry_updated_event
def async_handle_source_entity_changes(
hass: HomeAssistant,
*,
add_helper_config_entry_to_device: bool = True,
helper_config_entry_id: str,
set_source_entity_id_or_uuid: Callable[[str], None],
source_device_id: str | None,
@ -88,15 +89,17 @@ def async_handle_source_entity_changes(
helper_entity.entity_id, device_id=source_entity_entry.device_id
)
if source_entity_entry.device_id is not None:
if add_helper_config_entry_to_device:
if source_entity_entry.device_id is not None:
device_registry.async_update_device(
source_entity_entry.device_id,
add_config_entry_id=helper_config_entry_id,
)
device_registry.async_update_device(
source_entity_entry.device_id,
add_config_entry_id=helper_config_entry_id,
source_device_id, remove_config_entry_id=helper_config_entry_id
)
device_registry.async_update_device(
source_device_id, remove_config_entry_id=helper_config_entry_id
)
source_device_id = source_entity_entry.device_id
# Reload the config entry so the helper entity is recreated with
@ -111,3 +114,46 @@ def async_handle_source_entity_changes(
return async_track_entity_registry_updated_event(
hass, source_entity_id, async_registry_updated
)
def async_remove_helper_config_entry_from_source_device(
hass: HomeAssistant,
*,
helper_config_entry_id: str,
source_device_id: str,
) -> None:
"""Remove helper config entry from source device.
This is a convenience function to migrate from helpers which added their config
entry to the source device.
"""
device_registry = dr.async_get(hass)
if (
not (source_device := device_registry.async_get(source_device_id))
or helper_config_entry_id not in source_device.config_entries
):
return
entity_registry = er.async_get(hass)
helper_entity_entries = er.async_entries_for_config_entry(
entity_registry, helper_config_entry_id
)
# Disconnect helper entities from the device to prevent them from
# being removed when the config entry link to the device is removed.
modified_helpers: list[er.RegistryEntry] = []
for helper in helper_entity_entries:
if helper.device_id != source_device_id:
continue
modified_helpers.append(helper)
entity_registry.async_update_entity(helper.entity_id, device_id=None)
# Remove the helper config entry from the device
device_registry.async_update_device(
source_device_id, remove_config_entry_id=helper_config_entry_id
)
# Connect the helper entity to the device
for helper in modified_helpers:
entity_registry.async_update_entity(
helper.entity_id, device_id=source_device_id
)

View File

@ -447,6 +447,7 @@
"1/153/37": null,
"1/153/38": null,
"1/153/39": null,
"1/153/48": 75,
"1/153/64": 2,
"1/153/65": 0,
"1/153/66": 0,

View File

@ -5022,6 +5022,59 @@
'state': '2.0',
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.evse_state_of_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'State of charge',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'evse_soc',
'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseStateOfCharge-153-48',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'evse State of charge',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.evse_state_of_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '75',
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -1,6 +1,11 @@
"""The tests for Switch as X platforms."""
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
from homeassistant.components.lock import LockState
from homeassistant.components.siren import SirenEntityFeature
from homeassistant.components.valve import ValveEntityFeature
from homeassistant.const import STATE_CLOSED, STATE_OFF, STATE_ON, STATE_OPEN, Platform
PLATFORMS_TO_TEST = (
@ -12,6 +17,15 @@ PLATFORMS_TO_TEST = (
Platform.VALVE,
)
CAPABILITY_MAP = {
Platform.COVER: None,
Platform.FAN: {},
Platform.LIGHT: {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.ONOFF]},
Platform.LOCK: None,
Platform.SIREN: None,
Platform.VALVE: None,
}
STATE_MAP = {
False: {
Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED},
@ -30,3 +44,12 @@ STATE_MAP = {
Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN},
},
}
SUPPORTED_FEATURE_MAP = {
Platform.COVER: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE,
Platform.FAN: FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF,
Platform.LIGHT: 0,
Platform.LOCK: 0,
Platform.SIREN: SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF,
Platform.VALVE: ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE,
}

View File

@ -25,12 +25,12 @@ from homeassistant.const import (
EntityCategory,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.setup import async_setup_component
from . import PLATFORMS_TO_TEST
from . import CAPABILITY_MAP, PLATFORMS_TO_TEST, SUPPORTED_FEATURE_MAP
from tests.common import MockConfigEntry
@ -79,6 +79,22 @@ def switch_as_x_config_entry(
return config_entry
def track_entity_registry_actions(
hass: HomeAssistant, entity_id: str
) -> list[er.EventEntityRegistryUpdatedData]:
"""Track entity registry actions for an entity."""
events = []
@callback
def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None:
"""Add entity registry updated event to the list."""
events.append(event.data)
async_track_entity_registry_updated_event(hass, entity_id, add_event)
return events
@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST)
async def test_config_entry_unregistered_uuid(
hass: HomeAssistant, target_domain: str
@ -222,7 +238,7 @@ async def test_device_registry_config_entry_1(
assert entity_entry.device_id == switch_entity_entry.device_id
device_entry = device_registry.async_get(device_entry.id)
assert switch_as_x_config_entry.entry_id in device_entry.config_entries
assert switch_as_x_config_entry.entry_id not in device_entry.config_entries
events = []
@ -304,7 +320,7 @@ async def test_device_registry_config_entry_2(
assert entity_entry.device_id == switch_entity_entry.device_id
device_entry = device_registry.async_get(device_entry.id)
assert switch_as_x_config_entry.entry_id in device_entry.config_entries
assert switch_as_x_config_entry.entry_id not in device_entry.config_entries
events = []
@ -386,7 +402,7 @@ async def test_device_registry_config_entry_3(
assert entity_entry.device_id == switch_entity_entry.device_id
device_entry = device_registry.async_get(device_entry.id)
assert switch_as_x_config_entry.entry_id in device_entry.config_entries
assert switch_as_x_config_entry.entry_id not in device_entry.config_entries
device_entry_2 = device_registry.async_get(device_entry_2.id)
assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries
@ -413,7 +429,7 @@ async def test_device_registry_config_entry_3(
device_entry = device_registry.async_get(device_entry.id)
assert switch_as_x_config_entry.entry_id not in device_entry.config_entries
device_entry_2 = device_registry.async_get(device_entry_2.id)
assert switch_as_x_config_entry.entry_id in device_entry_2.config_entries
assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries
# Check that the switch_as_x config entry is not removed
assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids()
@ -1083,11 +1099,31 @@ async def test_restore_expose_settings(
@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST)
async def test_migrate(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
target_domain: Platform,
) -> None:
"""Test migration."""
# Setup the config entry
# Switch config entry, device and entity
switch_config_entry = MockConfigEntry()
switch_config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=switch_config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
switch_entity_entry = entity_registry.async_get_or_create(
"switch",
"test",
"unique",
config_entry=switch_config_entry,
device_id=device_entry.id,
original_name="ABC",
suggested_object_id="test",
)
assert switch_entity_entry.entity_id == "switch.test"
# Switch_as_x config entry, device and entity
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
@ -1100,9 +1136,37 @@ async def test_migrate(
minor_version=1,
)
config_entry.add_to_hass(hass)
device_registry.async_update_device(
device_entry.id, add_config_entry_id=config_entry.entry_id
)
switch_as_x_entity_entry = entity_registry.async_get_or_create(
target_domain,
"switch_as_x",
config_entry.entry_id,
capabilities=CAPABILITY_MAP[target_domain],
config_entry=config_entry,
device_id=device_entry.id,
original_name="ABC",
suggested_object_id="abc",
supported_features=SUPPORTED_FEATURE_MAP[target_domain],
)
entity_registry.async_update_entity_options(
switch_as_x_entity_entry.entity_id,
DOMAIN,
{"entity_id": "switch.test", "invert": False},
)
events = track_entity_registry_actions(hass, switch_as_x_entity_entry.entity_id)
# Setup the switch_as_x config entry
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert set(entity_registry.entities) == {
switch_entity_entry.entity_id,
switch_as_x_entity_entry.entity_id,
}
# Check migration was successful and added invert option
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.options == {
@ -1117,6 +1181,20 @@ async def test_migrate(
assert hass.states.get(f"{target_domain}.abc") is not None
assert entity_registry.async_get(f"{target_domain}.abc") is not None
# Entity removed from device to prevent deletion, then added back to device
assert events == [
{
"action": "update",
"changes": {"device_id": device_entry.id},
"entity_id": switch_as_x_entity_entry.entity_id,
},
{
"action": "update",
"changes": {"device_id": None},
"entity_id": switch_as_x_entity_entry.entity_id,
},
]
@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST)
async def test_migrate_from_future(

View File

@ -49,6 +49,11 @@ DEVICE_MOCKS = {
Platform.SELECT,
Platform.SWITCH,
],
"wk_wifi_smart_gas_boiler_thermostat": [
# https://github.com/orgs/home-assistant/discussions/243
Platform.CLIMATE,
Platform.SWITCH,
],
}

View File

@ -0,0 +1,188 @@
{
"endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "xxxxxxxxxxxxxxxxxxx",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,
"id": "bfb45cb8a9452fba66lexg",
"name": "WiFi Smart Gas Boiler Thermostat ",
"category": "wk",
"product_id": "fi6dne5tu4t1nm6j",
"product_name": "WiFi Smart Gas Boiler Thermostat ",
"online": true,
"sub": false,
"time_zone": "+02:00",
"active_time": "2025-07-05T17:50:52+00:00",
"create_time": "2025-07-05T17:50:52+00:00",
"update_time": "2025-07-05T17:50:52+00:00",
"function": {
"switch": {
"type": "Boolean",
"value": {}
},
"mode": {
"type": "Enum",
"value": {
"range": ["auto"]
}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 50,
"max": 350,
"scale": 1,
"step": 5
}
},
"temp_correction": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": -99,
"max": 99,
"scale": 1,
"step": 1
}
},
"upper_temp": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 150,
"max": 350,
"scale": 1,
"step": 5
}
},
"lower_temp": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 50,
"max": 140,
"scale": 1,
"step": 5
}
},
"child_lock": {
"type": "Boolean",
"value": {}
},
"frost": {
"type": "Boolean",
"value": {}
},
"factory_reset": {
"type": "Boolean",
"value": {}
}
},
"status_range": {
"switch": {
"type": "Boolean",
"value": {}
},
"mode": {
"type": "Enum",
"value": {
"range": ["auto"]
}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 50,
"max": 350,
"scale": 1,
"step": 5
}
},
"temp_current": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 0,
"max": 800,
"scale": 1,
"step": 1
}
},
"temp_correction": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": -99,
"max": 99,
"scale": 1,
"step": 1
}
},
"fault": {
"type": "Bitmap",
"value": {
"label": ["battery_temp_fault"]
}
},
"upper_temp": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 150,
"max": 350,
"scale": 1,
"step": 5
}
},
"lower_temp": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 50,
"max": 140,
"scale": 1,
"step": 5
}
},
"battery_percentage": {
"type": "Integer",
"value": {
"unit": "%",
"min": 0,
"max": 100,
"scale": 0,
"step": 1
}
},
"child_lock": {
"type": "Boolean",
"value": {}
},
"frost": {
"type": "Boolean",
"value": {}
},
"factory_reset": {
"type": "Boolean",
"value": {}
}
},
"status": {
"switch": true,
"mode": "auto",
"temp_set": 220,
"temp_current": 249,
"temp_correction": -15,
"fault": 0,
"upper_temp": 350,
"lower_temp": 50,
"battery_percentage": 100,
"child_lock": false,
"frost": false,
"factory_reset": false
},
"set_up": true,
"support_local": true
}

View File

@ -0,0 +1,67 @@
# serializer version: 1
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'target_temp_step': 0.5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.wifi_smart_gas_boiler_thermostat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 385>,
'translation_key': None,
'unique_id': 'tuya.bfb45cb8a9452fba66lexg',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 24.9,
'friendly_name': 'WiFi Smart Gas Boiler Thermostat ',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'supported_features': <ClimateEntityFeature: 385>,
'target_temp_step': 0.5,
'temperature': 22.0,
}),
'context': <ANY>,
'entity_id': 'climate.wifi_smart_gas_boiler_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_cool',
})
# ---

View File

@ -630,3 +630,51 @@
'state': 'unavailable',
})
# ---
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Child lock',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'child_lock',
'unique_id': 'tuya.bfb45cb8a9452fba66lexgchild_lock',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock',
}),
'context': <ANY>,
'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,57 @@
"""Test Tuya climate platform."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice
from homeassistant.components.tuya import ManagerCompat
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import DEVICE_MOCKS, initialize_entry
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.parametrize(
"mock_device_code",
[k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE in v],
)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE])
async def test_platform_setup_and_discovery(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test platform setup and discovery."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
"mock_device_code",
[k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE not in v],
)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE])
async def test_platform_setup_no_discovery(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
entity_registry: er.EntityRegistry,
) -> None:
"""Test platform setup and discovery."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)

View File

@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, Mock
import pytest
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
)
from tests.common import (
MockConfigEntry,
@ -184,6 +187,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s
"""Track entity registry actions for an entity."""
events = []
@callback
def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None:
"""Add entity registry updated event to the list."""
events.append(event.data["action"])
@ -193,6 +197,20 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s
return events
def listen_entity_registry_events(hass: HomeAssistant) -> list[str]:
"""Track entity registry actions for an entity."""
events: list[er.EventEntityRegistryUpdatedData] = []
@callback
def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None:
"""Add entity registry updated event to the list."""
events.append(event.data)
hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, add_event)
return events
@pytest.mark.parametrize("use_entity_registry_id", [True, False])
@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration")
async def test_async_handle_source_entity_changes_source_entity_removed(
@ -425,3 +443,85 @@ async def test_async_handle_source_entity_new_entity_id(
# Check we got the expected events
assert events == []
@pytest.mark.parametrize("use_entity_registry_id", [True, False])
@pytest.mark.usefixtures("source_entity_entry")
async def test_async_remove_helper_config_entry_from_source_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
helper_config_entry: MockConfigEntry,
helper_entity_entry: er.RegistryEntry,
source_device: dr.DeviceEntry,
) -> None:
"""Test removing the helper config entry from the source device."""
# Add the helper config entry to the source device
device_registry.async_update_device(
source_device.id, add_config_entry_id=helper_config_entry.entry_id
)
# Create a helper entity entry, not connected to the source device
extra_helper_entity_entry = entity_registry.async_get_or_create(
"sensor",
HELPER_DOMAIN,
f"{helper_config_entry.entry_id}_2",
config_entry=helper_config_entry,
original_name="ABC",
)
assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id
events = listen_entity_registry_events(hass)
async_remove_helper_config_entry_from_source_device(
hass,
helper_config_entry_id=helper_config_entry.entry_id,
source_device_id=source_device.id,
)
# Check we got the expected events
assert events == [
{
"action": "update",
"changes": {"device_id": source_device.id},
"entity_id": helper_entity_entry.entity_id,
},
{
"action": "update",
"changes": {"device_id": None},
"entity_id": helper_entity_entry.entity_id,
},
]
@pytest.mark.parametrize("use_entity_registry_id", [True, False])
@pytest.mark.usefixtures("source_entity_entry")
async def test_async_remove_helper_config_entry_from_source_device_helper_not_in_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
helper_config_entry: MockConfigEntry,
helper_entity_entry: er.RegistryEntry,
source_device: dr.DeviceEntry,
) -> None:
"""Test removing the helper config entry from the source device."""
# Create a helper entity entry, not connected to the source device
extra_helper_entity_entry = entity_registry.async_get_or_create(
"sensor",
HELPER_DOMAIN,
f"{helper_config_entry.entry_id}_2",
config_entry=helper_config_entry,
original_name="ABC",
)
assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id
events = listen_entity_registry_events(hass)
async_remove_helper_config_entry_from_source_device(
hass,
helper_config_entry_id=helper_config_entry.entry_id,
source_device_id=source_device.id,
)
# Check we got the expected events
assert events == []