mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 16:17:20 +00:00
Merge branch 'dev' into handle-timeoutu-in-subinfo-call
This commit is contained in:
commit
a306114855
@ -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
|
||||
|
@ -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(
|
||||
|
@ -402,6 +402,9 @@
|
||||
"other": "Other fault"
|
||||
}
|
||||
},
|
||||
"evse_soc": {
|
||||
"name": "State of charge"
|
||||
},
|
||||
"pump_control_mode": {
|
||||
"name": "Control mode",
|
||||
"state": {
|
||||
|
@ -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(
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
}
|
67
tests/components/tuya/snapshots/test_climate.ambr
Normal file
67
tests/components/tuya/snapshots/test_climate.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
@ -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',
|
||||
})
|
||||
# ---
|
||||
|
57
tests/components/tuya/test_climate.py
Normal file
57
tests/components/tuya/test_climate.py
Normal 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
|
||||
)
|
@ -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 == []
|
||||
|
Loading…
x
Reference in New Issue
Block a user