mirror of
https://github.com/home-assistant/core.git
synced 2026-08-21 05:41:29 +01:00
3413 lines
113 KiB
Python
3413 lines
113 KiB
Python
"""Test ESPHome binary sensors."""
|
|
|
|
import asyncio
|
|
from dataclasses import asdict
|
|
import logging
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
from aioesphomeapi import (
|
|
APIClient,
|
|
BinarySensorInfo,
|
|
BinarySensorState,
|
|
DeviceInfo,
|
|
SensorInfo,
|
|
SensorState,
|
|
SubDeviceInfo,
|
|
build_device_unique_id,
|
|
build_unique_id,
|
|
)
|
|
import pytest
|
|
|
|
from homeassistant.components.esphome import DOMAIN
|
|
from homeassistant.const import (
|
|
ATTR_FRIENDLY_NAME,
|
|
ATTR_ICON,
|
|
ATTR_RESTORED,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import Event, EventStateChangedData, 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,
|
|
async_track_state_change_event,
|
|
)
|
|
|
|
from .conftest import (
|
|
MockESPHomeDevice,
|
|
MockESPHomeDeviceType,
|
|
MockGenericDeviceEntryType,
|
|
reconnect_with_updated_entity_info,
|
|
)
|
|
|
|
|
|
def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]:
|
|
"""Track entity registry actions for an entity."""
|
|
events: list[str] = []
|
|
|
|
@callback
|
|
def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None:
|
|
"""Add entity registry updated event to the list."""
|
|
events.append(event.data["action"])
|
|
|
|
async_track_entity_registry_updated_event(hass, entity_id, add_event)
|
|
|
|
return events
|
|
|
|
|
|
def _two_binary_sensor_setup() -> tuple[
|
|
list[BinarySensorInfo], list[BinarySensorState]
|
|
]:
|
|
"""Return two binary sensors and their states for re-key tests."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=2,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
return entity_info, states
|
|
|
|
|
|
async def test_entities_removed(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities are removed when static info changes."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor_to_be_removed",
|
|
key=2,
|
|
name="my binary_sensor to be removed",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry = mock_device.entry
|
|
entry_id = entry.entry_id
|
|
storage_key = f"esphome.{entry_id}"
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2
|
|
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.attributes[ATTR_RESTORED] is True
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is not None
|
|
assert state.attributes[ATTR_RESTORED] is True
|
|
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
entry=entry,
|
|
)
|
|
assert mock_device.entry.entry_id == entry_id
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is None
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is None
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1
|
|
|
|
|
|
async def test_entities_removed_after_reload(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities are removed when static info changes after reload."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor_to_be_removed",
|
|
key=2,
|
|
name="my binary_sensor to be removed",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
mock_device: MockESPHomeDevice = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry = mock_device.entry
|
|
entry_id = entry.entry_id
|
|
storage_key = f"esphome.{entry_id}"
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is not None
|
|
|
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2
|
|
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.attributes[ATTR_RESTORED] is True
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
assert state.attributes[ATTR_RESTORED] is True
|
|
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is not None
|
|
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2
|
|
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert ATTR_RESTORED not in state.attributes
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
assert ATTR_RESTORED not in state.attributes
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is not None
|
|
|
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
]
|
|
mock_device.client.list_entities_services = AsyncMock(
|
|
return_value=(entity_info, [])
|
|
)
|
|
mock_device.client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(mock_device.device_info, entity_info, [])
|
|
)
|
|
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
on_future = hass.loop.create_future()
|
|
|
|
@callback
|
|
def _async_wait_for_on(event: Event[EventStateChangedData]) -> None:
|
|
if event.data["new_state"].state == STATE_ON:
|
|
on_future.set_result(None)
|
|
|
|
async_track_state_change_event(
|
|
hass, ["binary_sensor.test_my_binary_sensor"], _async_wait_for_on
|
|
)
|
|
await hass.async_block_till_done()
|
|
async with asyncio.timeout(2):
|
|
await on_future
|
|
|
|
assert mock_device.entry.entry_id == entry_id
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is None
|
|
|
|
await hass.async_block_till_done()
|
|
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is None
|
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1
|
|
|
|
|
|
async def test_entities_for_entire_platform_removed(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test removing all entities for a specific platform when static info changes."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor_to_be_removed",
|
|
key=1,
|
|
name="my binary_sensor to be removed",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry = mock_device.entry
|
|
entry_id = entry.entry_id
|
|
storage_key = f"esphome.{entry_id}"
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1
|
|
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is not None
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is not None
|
|
assert state.attributes[ATTR_RESTORED] is True
|
|
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entry=entry,
|
|
)
|
|
assert mock_device.entry.entry_id == entry_id
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed")
|
|
assert state is None
|
|
reg_entry = entity_registry.async_get(
|
|
"binary_sensor.test_my_binary_sensor_to_be_removed"
|
|
)
|
|
assert reg_entry is None
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 0
|
|
|
|
|
|
async def test_entity_info_object_ids(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test how object ids affect entity id."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="object_id_is_used",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
)
|
|
]
|
|
states = []
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
|
|
|
|
async def test_deep_sleep_device(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a deep sleep device."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
SensorInfo(
|
|
object_id="my_sensor",
|
|
key=3,
|
|
name="my sensor",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
SensorState(key=3, state=123.0, missing_state=False),
|
|
]
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"has_deep_sleep": True},
|
|
)
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == "123.0"
|
|
|
|
await mock_device.mock_disconnect(False)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
await mock_device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == "123.0"
|
|
|
|
await mock_device.mock_disconnect(True)
|
|
await hass.async_block_till_done()
|
|
await mock_device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
mock_device.set_state(BinarySensorState(key=1, state=False, missing_state=False))
|
|
mock_device.set_state(SensorState(key=3, state=56, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_OFF
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == "56"
|
|
|
|
await mock_device.mock_disconnect(True)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_OFF
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == "56"
|
|
|
|
await mock_device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
await mock_device.mock_disconnect(False)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
state = hass.states.get("sensor.test_my_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
await mock_device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
# Verify we do not dispatch any more state updates or
|
|
# availability updates after the stop event is fired
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
|
|
async def test_esphome_device_without_friendly_name(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a device without friendly_name set."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="my binary_sensor",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"friendly_name": None},
|
|
)
|
|
state = hass.states.get("binary_sensor.test_my_binary_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
|
|
async def test_entity_without_name_device_with_friendly_name(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test name and entity_id for device with friendly name."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="mybinary_sensor",
|
|
key=1,
|
|
name="",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
|
|
)
|
|
state = hass.states.get("binary_sensor.the_best_mixer")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
# Make sure we have set the name to `None` as otherwise
|
|
# the friendly_name will be "The Best Mixer "
|
|
assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer"
|
|
|
|
|
|
@pytest.mark.usefixtures("hass_storage")
|
|
async def test_entity_id_preserved_on_upgrade(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test entity_id is preserved on upgrade."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="my",
|
|
key=1,
|
|
name="my",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
assert (
|
|
build_unique_id("11:22:33:44:55:AA", entity_info[0])
|
|
== "11:22:33:44:55:AA-binary_sensor-my"
|
|
)
|
|
|
|
entry = entity_registry.async_get_or_create(
|
|
Platform.BINARY_SENSOR,
|
|
DOMAIN,
|
|
"11:22:33:44:55:AA-binary_sensor-my",
|
|
suggested_object_id="should_not_change",
|
|
)
|
|
assert entry.entity_id == "binary_sensor.should_not_change"
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
|
|
)
|
|
state = hass.states.get("binary_sensor.should_not_change")
|
|
assert state is not None
|
|
|
|
|
|
@pytest.mark.usefixtures("hass_storage")
|
|
async def test_entity_id_preserved_on_upgrade_old_format_entity_id(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test entity_id is preserved on upgrade from old format."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="my",
|
|
key=1,
|
|
name="my",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
assert (
|
|
build_unique_id("11:22:33:44:55:AA", entity_info[0])
|
|
== "11:22:33:44:55:AA-binary_sensor-my"
|
|
)
|
|
|
|
entry = entity_registry.async_get_or_create(
|
|
Platform.BINARY_SENSOR,
|
|
DOMAIN,
|
|
"11:22:33:44:55:AA-binary_sensor-my",
|
|
suggested_object_id="my",
|
|
)
|
|
assert entry.entity_id == "binary_sensor.my"
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"name": "mixer"},
|
|
)
|
|
state = hass.states.get("binary_sensor.my")
|
|
assert state is not None
|
|
|
|
|
|
async def test_entity_id_preserved_on_upgrade_when_in_storage(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
hass_storage: dict[str, Any],
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
entity_registry: er.EntityRegistry,
|
|
) -> None:
|
|
"""Test entity_id is preserved on upgrade with user defined entity_id."""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="my",
|
|
key=1,
|
|
name="my",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
|
|
)
|
|
state = hass.states.get("binary_sensor.the_best_mixer_my")
|
|
assert state is not None
|
|
# now rename the entity
|
|
ent_reg_entry = entity_registry.async_get_or_create(
|
|
Platform.BINARY_SENSOR,
|
|
DOMAIN,
|
|
"11:22:33:44:55:AA/0/binary_sensor/my",
|
|
)
|
|
entity_registry.async_update_entity(
|
|
ent_reg_entry.entity_id,
|
|
new_entity_id="binary_sensor.user_named",
|
|
)
|
|
await hass.config_entries.async_unload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
entry = device.entry
|
|
entry_id = entry.entry_id
|
|
storage_key = f"esphome.{entry_id}"
|
|
assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1
|
|
binary_sensor_data: dict[str, Any] = hass_storage[storage_key]["data"][
|
|
"binary_sensor"
|
|
][0]
|
|
assert binary_sensor_data["name"] == "my"
|
|
assert binary_sensor_data["object_id"] == "my"
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
entry=entry,
|
|
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
|
|
)
|
|
state = hass.states.get("binary_sensor.user_named")
|
|
assert state is not None
|
|
|
|
|
|
async def test_deep_sleep_added_after_setup(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test deep sleep added after setup."""
|
|
mock_device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=[
|
|
BinarySensorInfo(
|
|
object_id="test",
|
|
key=1,
|
|
name="test",
|
|
),
|
|
],
|
|
states=[
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
],
|
|
device_info={"has_deep_sleep": False},
|
|
)
|
|
|
|
entity_id = "binary_sensor.test_test"
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
await mock_device.mock_disconnect(expected_disconnect=True)
|
|
|
|
# No deep sleep, should be unavailable
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
|
|
await mock_device.mock_connect()
|
|
|
|
# reconnect, should be available
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
await mock_device.mock_disconnect(expected_disconnect=True)
|
|
new_device_info = DeviceInfo(
|
|
**{**asdict(mock_device.device_info), "has_deep_sleep": True}
|
|
)
|
|
mock_device.client.device_info = AsyncMock(return_value=new_device_info)
|
|
mock_device.client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(
|
|
new_device_info,
|
|
mock_device.client.list_entities_services.return_value[0],
|
|
mock_device.client.list_entities_services.return_value[1],
|
|
)
|
|
)
|
|
mock_device.device_info = new_device_info
|
|
|
|
await mock_device.mock_connect()
|
|
|
|
# Now disconnect that deep sleep is set in device info
|
|
await mock_device.mock_disconnect(expected_disconnect=True)
|
|
|
|
# Deep sleep, should be available
|
|
state = hass.states.get(entity_id)
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
|
|
async def test_entity_assignment_to_sub_device(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities are assigned to correct sub devices."""
|
|
device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures
|
|
|
|
# Define sub devices
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="Door Sensor", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
|
|
# Create entities that belong to different devices
|
|
entity_info = [
|
|
# Entity for main device (device_id=0)
|
|
BinarySensorInfo(
|
|
object_id="main_sensor",
|
|
key=1,
|
|
name="Main Sensor",
|
|
device_id=0,
|
|
),
|
|
# Entity for sub device 1
|
|
BinarySensorInfo(
|
|
object_id="motion",
|
|
key=2,
|
|
name="Motion",
|
|
device_id=11111111,
|
|
),
|
|
# Entity for sub device 2
|
|
BinarySensorInfo(
|
|
object_id="door",
|
|
key=3,
|
|
name="Door",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=0),
|
|
BinarySensorState(key=2, state=False, missing_state=False, device_id=11111111),
|
|
BinarySensorState(key=3, state=True, missing_state=False, device_id=22222222),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check main device
|
|
main_device = device_registry.async_get_device_by_connection(
|
|
(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address),
|
|
device.entry.entry_id,
|
|
)
|
|
assert main_device is not None
|
|
|
|
# Check entities are assigned to correct devices
|
|
main_sensor = entity_registry.async_get("binary_sensor.test_main_sensor")
|
|
assert main_sensor is not None
|
|
assert main_sensor.device_id == main_device.id
|
|
|
|
# Check sub device 1 entity
|
|
sub_device_1 = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id
|
|
)
|
|
assert sub_device_1 is not None
|
|
|
|
motion_sensor = entity_registry.async_get("binary_sensor.motion_sensor_motion")
|
|
assert motion_sensor is not None
|
|
assert motion_sensor.device_id == sub_device_1.id
|
|
|
|
# Check sub device 2 entity
|
|
sub_device_2 = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id
|
|
)
|
|
assert sub_device_2 is not None
|
|
|
|
door_sensor = entity_registry.async_get("binary_sensor.door_sensor_door")
|
|
assert door_sensor is not None
|
|
assert door_sensor.device_id == sub_device_2.id
|
|
|
|
# Check states
|
|
assert hass.states.get("binary_sensor.test_main_sensor").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.motion_sensor_motion").state == STATE_OFF
|
|
assert hass.states.get("binary_sensor.door_sensor_door").state == STATE_ON
|
|
|
|
# Check entity friendly names
|
|
# Main device entity should have: "{device_name} {entity_name}"
|
|
main_sensor_state = hass.states.get("binary_sensor.test_main_sensor")
|
|
assert main_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Test Main Sensor"
|
|
|
|
# Sub device 1 entity should have: "Motion Sensor Motion"
|
|
motion_sensor_state = hass.states.get("binary_sensor.motion_sensor_motion")
|
|
assert motion_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Motion Sensor Motion"
|
|
|
|
# Sub device 2 entity should have: "Door Sensor Door"
|
|
door_sensor_state = hass.states.get("binary_sensor.door_sensor_door")
|
|
assert door_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Door Sensor Door"
|
|
|
|
|
|
async def test_entity_friendly_names_with_empty_device_names(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entity friendly names when sub-devices have empty names."""
|
|
# Define sub devices with different name scenarios
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name
|
|
SubDeviceInfo(
|
|
device_id=22222222, name="Kitchen Light", area_id=0
|
|
), # Valid name
|
|
]
|
|
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
"friendly_name": "Main Device",
|
|
}
|
|
|
|
# Entity on sub-device with empty name
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="motion",
|
|
key=1,
|
|
name="Motion Detected",
|
|
device_id=11111111,
|
|
),
|
|
# Entity on sub-device with valid name
|
|
BinarySensorInfo(
|
|
object_id="status",
|
|
key=2,
|
|
name="Status",
|
|
device_id=22222222,
|
|
),
|
|
# Entity with empty name on sub-device with valid name
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=3,
|
|
name="", # Empty entity name
|
|
device_id=22222222,
|
|
),
|
|
# Entity on main device
|
|
BinarySensorInfo(
|
|
object_id="main_status",
|
|
key=4,
|
|
name="Main Status",
|
|
device_id=0,
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=False, missing_state=False),
|
|
BinarySensorState(key=3, state=True, missing_state=False),
|
|
BinarySensorState(key=4, state=True, missing_state=False),
|
|
]
|
|
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check entity friendly name on sub-device with empty name
|
|
# Since sub device has empty name, it falls back to main device name "Main device"
|
|
state_1 = hass.states.get("binary_sensor.main_device_motion_detected")
|
|
assert state_1 is not None
|
|
# With has_entity_name, friendly name is "{device_name} {entity_name}"
|
|
# Since sub-device falls back to main device name: "Main Device Motion Detected"
|
|
assert state_1.attributes[ATTR_FRIENDLY_NAME] == "Main Device Motion Detected"
|
|
|
|
# Check entity friendly name on sub-device with valid name
|
|
state_2 = hass.states.get("binary_sensor.kitchen_light_status")
|
|
assert state_2 is not None
|
|
# Device has name "Kitchen Light", entity has name "Status"
|
|
assert state_2.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light Status"
|
|
|
|
# Test entity with empty name on sub-device
|
|
state_3 = hass.states.get("binary_sensor.kitchen_light")
|
|
assert state_3 is not None
|
|
# Entity has empty name, so friendly name is just the device name
|
|
assert state_3.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light"
|
|
|
|
# Test entity on main device
|
|
state_4 = hass.states.get("binary_sensor.main_device_main_status")
|
|
assert state_4 is not None
|
|
assert state_4.attributes[ATTR_FRIENDLY_NAME] == "Main Device Main Status"
|
|
|
|
|
|
async def test_entity_switches_between_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test that entities can switch between devices correctly."""
|
|
# Define sub devices
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
|
|
# Create initial entity assigned to main device (no device_id)
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Test Sensor",
|
|
# device_id omitted - entity belongs to main device
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=0),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Verify entity is on main device
|
|
main_device = device_registry.async_get_device_by_connection(
|
|
(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address),
|
|
device.entry.entry_id,
|
|
)
|
|
assert main_device is not None
|
|
|
|
sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor")
|
|
assert sensor_entity is not None
|
|
assert sensor_entity.device_id == main_device.id
|
|
|
|
# Test 1: Main device → Sub device 1
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Test Sensor",
|
|
device_id=11111111, # Now on sub device 1
|
|
),
|
|
]
|
|
|
|
# Update the entity info by changing what the mock returns
|
|
mock_client.list_entities_services = AsyncMock(
|
|
return_value=(updated_entity_info, [])
|
|
)
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, updated_entity_info, [])
|
|
)
|
|
# Trigger a reconnect to simulate the entity info update
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
|
|
# Verify entity is now on sub device 1
|
|
sub_device_1 = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id
|
|
)
|
|
assert sub_device_1 is not None
|
|
|
|
sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor")
|
|
assert sensor_entity is not None
|
|
assert sensor_entity.device_id == sub_device_1.id
|
|
|
|
# Test 2: Sub device 1 → Sub device 2
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Test Sensor",
|
|
device_id=22222222, # Now on sub device 2
|
|
),
|
|
]
|
|
|
|
mock_client.list_entities_services = AsyncMock(
|
|
return_value=(updated_entity_info, [])
|
|
)
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, updated_entity_info, [])
|
|
)
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
|
|
# Verify entity is now on sub device 2
|
|
sub_device_2 = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id
|
|
)
|
|
assert sub_device_2 is not None
|
|
|
|
sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor")
|
|
assert sensor_entity is not None
|
|
assert sensor_entity.device_id == sub_device_2.id
|
|
|
|
# Test 3: Sub device 2 → Main device
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Test Sensor",
|
|
# device_id omitted - back to main device
|
|
),
|
|
]
|
|
|
|
mock_client.list_entities_services = AsyncMock(
|
|
return_value=(updated_entity_info, [])
|
|
)
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, updated_entity_info, [])
|
|
)
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
|
|
# Verify entity is back on main device
|
|
sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor")
|
|
assert sensor_entity is not None
|
|
assert sensor_entity.device_id == main_device.id
|
|
|
|
|
|
async def test_entity_id_uses_sub_device_name(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test that entity_id uses sub device name when entity belongs to sub device."""
|
|
# Define sub devices
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="motion_sensor", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="door_sensor", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
"name": "main_device",
|
|
"friendly_name": "Main Device",
|
|
}
|
|
|
|
# Create entities that belong to different devices
|
|
entity_info = [
|
|
# Entity for main device (device_id=0)
|
|
BinarySensorInfo(
|
|
object_id="main_sensor",
|
|
key=1,
|
|
name="Main Sensor",
|
|
device_id=0,
|
|
),
|
|
# Entity for sub device 1
|
|
BinarySensorInfo(
|
|
object_id="motion",
|
|
key=2,
|
|
name="Motion",
|
|
device_id=11111111,
|
|
),
|
|
# Entity for sub device 2
|
|
BinarySensorInfo(
|
|
object_id="door",
|
|
key=3,
|
|
name="Door",
|
|
device_id=22222222,
|
|
),
|
|
# Entity without name on sub device
|
|
BinarySensorInfo(
|
|
object_id="sensor_no_name",
|
|
key=4,
|
|
name="",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=False, missing_state=False),
|
|
BinarySensorState(key=3, state=True, missing_state=False),
|
|
BinarySensorState(key=4, state=True, missing_state=False),
|
|
]
|
|
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check entity_id for main device entity
|
|
# Should be: binary_sensor.{main_device_name}_{object_id}
|
|
assert hass.states.get("binary_sensor.main_device_main_sensor") is not None
|
|
|
|
# Check entity_id for sub device 1 entity
|
|
# Should be: binary_sensor.{sub_device_name}_{object_id}
|
|
assert hass.states.get("binary_sensor.motion_sensor_motion") is not None
|
|
|
|
# Check entity_id for sub device 2 entity
|
|
# Should be: binary_sensor.{sub_device_name}_{object_id}
|
|
assert hass.states.get("binary_sensor.door_sensor_door") is not None
|
|
|
|
# Check entity_id for entity without name on sub device
|
|
# Should be: binary_sensor.{sub_device_name}
|
|
assert hass.states.get("binary_sensor.motion_sensor") is not None
|
|
|
|
|
|
async def test_entity_id_with_empty_sub_device_name(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entity_id when sub device has empty name."""
|
|
# Define sub device with empty name
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name
|
|
]
|
|
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
"name": "main_device",
|
|
"friendly_name": "Main Device",
|
|
}
|
|
|
|
# Create entity on sub device with empty name
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Sensor",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# When sub device has empty name, entity_id should use main device name
|
|
# Should be: binary_sensor.{main_device_name}_{object_id}
|
|
assert hass.states.get("binary_sensor.main_device_sensor") is not None
|
|
|
|
|
|
async def test_legacy_unique_id_migrated_to_v3_sub_device(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a legacy sub-device unique id is migrated to the version 3 format."""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
|
]
|
|
device_info = {"name": "test", "devices": sub_devices}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
states = [BinarySensorState(key=1, state=True, missing_state=False)]
|
|
|
|
# Seed a registry entry in the legacy format with the @device_id suffix
|
|
legacy_entry = entity_registry.async_get_or_create(
|
|
Platform.BINARY_SENSOR,
|
|
DOMAIN,
|
|
"11:22:33:44:55:AA-binary_sensor-temperature@22222222",
|
|
suggested_object_id="kitchen_controller_temperature",
|
|
)
|
|
|
|
await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
entity_entry = entity_registry.async_get(legacy_entry.entity_id)
|
|
assert entity_entry is not None
|
|
# The legacy id is renamed to version 3, keeping the same entity
|
|
assert (
|
|
entity_entry.unique_id == "11:22:33:44:55:AA/22222222/binary_sensor/Temperature"
|
|
)
|
|
assert (
|
|
entity_registry.async_get_entity_id(
|
|
Platform.BINARY_SENSOR,
|
|
DOMAIN,
|
|
"11:22:33:44:55:AA-binary_sensor-temperature@22222222",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
async def test_unique_id_migration_when_entity_moves_between_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test unique_id is migrated when entity moves between devices."""
|
|
# Initial setup: entity on main device
|
|
device_info = {
|
|
"name": "test",
|
|
"devices": [], # No sub-devices initially
|
|
}
|
|
|
|
# Entity on main device
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature", # This field is not used by the integration
|
|
device_id=0, # Main device
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check initial entity
|
|
state = hass.states.get("binary_sensor.test_temperature")
|
|
assert state is not None
|
|
|
|
# Get the entity from registry
|
|
entity_entry = entity_registry.async_get("binary_sensor.test_temperature")
|
|
assert entity_entry is not None
|
|
initial_unique_id = entity_entry.unique_id
|
|
# Main device entities use device_id 0 in the unique id
|
|
assert "/0/" in initial_unique_id
|
|
|
|
# Add sub-device to device info
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
|
]
|
|
|
|
# Get the config entry from hass
|
|
entries = hass.config_entries.async_entries(DOMAIN)
|
|
assert len(entries) == 1
|
|
entry = entries[0]
|
|
|
|
# Build device_id_to_name mapping like manager.py does
|
|
entry_data = entry.runtime_data
|
|
entry_data.device_id_to_name = {
|
|
sub_device.device_id: sub_device.name for sub_device in sub_devices
|
|
}
|
|
|
|
# Create a new DeviceInfo with sub-devices since it's frozen
|
|
# Get the current device info and convert to dict
|
|
current_device_info = mock_client.device_info.return_value
|
|
device_info_dict = asdict(current_device_info)
|
|
|
|
# Update the devices list
|
|
device_info_dict["devices"] = sub_devices
|
|
|
|
# Create new DeviceInfo with updated devices
|
|
new_device_info = DeviceInfo(**device_info_dict)
|
|
|
|
# Update mock_client to return new device info
|
|
mock_client.device_info.return_value = new_device_info
|
|
|
|
# Update entity info - same key and object_id but now on sub-device
|
|
new_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature", # Same object_id
|
|
key=1, # Same key - this is what identifies the entity
|
|
name="Temperature", # This field is not used
|
|
device_id=22222222, # Now on sub-device
|
|
),
|
|
]
|
|
|
|
# Update the entity info by changing what the mock returns
|
|
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, new_entity_info, [])
|
|
)
|
|
|
|
# Trigger a reconnect to simulate the entity info update
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
|
|
# Wait for entity to be updated
|
|
await hass.async_block_till_done()
|
|
|
|
# The entity_id doesn't change when moving between devices,
|
|
# only the device segment of the unique_id changes
|
|
state = hass.states.get("binary_sensor.test_temperature")
|
|
assert state is not None
|
|
|
|
# Get updated entity from registry - entity_id should be the same
|
|
entity_entry = entity_registry.async_get("binary_sensor.test_temperature")
|
|
assert entity_entry is not None
|
|
|
|
# Unique ID device segment should now be the sub-device id
|
|
expected_unique_id = initial_unique_id.replace("/0/", "/22222222/")
|
|
assert entity_entry.unique_id == expected_unique_id
|
|
|
|
# Entity should now be associated with the sub-device
|
|
sub_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id
|
|
)
|
|
assert sub_device is not None
|
|
assert entity_entry.device_id == sub_device.id
|
|
|
|
|
|
async def test_unique_id_migration_sub_device_to_main_device(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test unique_id is migrated when entity moves to main device."""
|
|
# Initial setup: entity on sub-device
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"name": "test",
|
|
"devices": sub_devices,
|
|
}
|
|
|
|
# Entity on sub-device
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature",
|
|
device_id=22222222, # On sub-device
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check initial entity
|
|
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
|
assert state is not None
|
|
|
|
# Get the entity from registry
|
|
entity_entry = entity_registry.async_get(
|
|
"binary_sensor.kitchen_controller_temperature"
|
|
)
|
|
assert entity_entry is not None
|
|
initial_unique_id = entity_entry.unique_id
|
|
# Sub-device entities carry the sub-device id in the unique id
|
|
assert "/22222222/" in initial_unique_id
|
|
|
|
# Update entity info - move to main device
|
|
new_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature",
|
|
device_id=0, # Now on main device
|
|
),
|
|
]
|
|
|
|
# Update the entity info
|
|
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, new_entity_info, [])
|
|
)
|
|
|
|
# Trigger a reconnect
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
|
|
# The entity_id should remain the same
|
|
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
|
assert state is not None
|
|
|
|
# Get updated entity from registry
|
|
entity_entry = entity_registry.async_get(
|
|
"binary_sensor.kitchen_controller_temperature"
|
|
)
|
|
assert entity_entry is not None
|
|
|
|
# Unique ID device segment should now be the main device id 0
|
|
expected_unique_id = initial_unique_id.replace("/22222222/", "/0/")
|
|
assert entity_entry.unique_id == expected_unique_id
|
|
|
|
# Entity should now be associated with the main device
|
|
main_device = device_registry.async_get_device_by_connection(
|
|
(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address),
|
|
device.entry.entry_id,
|
|
)
|
|
assert main_device is not None
|
|
assert entity_entry.device_id == main_device.id
|
|
|
|
|
|
async def test_unique_id_migration_between_sub_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test that unique_id is migrated when entity moves between sub-devices."""
|
|
# Initial setup: two sub-devices
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0),
|
|
SubDeviceInfo(device_id=33333333, name="bedroom_controller", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"name": "test",
|
|
"devices": sub_devices,
|
|
}
|
|
|
|
# Entity on first sub-device
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature",
|
|
device_id=22222222, # On kitchen_controller
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Check initial entity
|
|
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
|
assert state is not None
|
|
|
|
# Get the entity from registry
|
|
entity_entry = entity_registry.async_get(
|
|
"binary_sensor.kitchen_controller_temperature"
|
|
)
|
|
assert entity_entry is not None
|
|
initial_unique_id = entity_entry.unique_id
|
|
# Sub-device entities carry the sub-device id in the unique id
|
|
assert "/22222222/" in initial_unique_id
|
|
|
|
# Update entity info - move to second sub-device
|
|
new_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="temperature",
|
|
key=1,
|
|
name="Temperature",
|
|
device_id=33333333, # Now on bedroom_controller
|
|
),
|
|
]
|
|
|
|
# Update the entity info
|
|
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(device.device_info, new_entity_info, [])
|
|
)
|
|
|
|
# Trigger a reconnect
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
|
|
# The entity_id should remain the same
|
|
state = hass.states.get("binary_sensor.kitchen_controller_temperature")
|
|
assert state is not None
|
|
|
|
# Get updated entity from registry
|
|
entity_entry = entity_registry.async_get(
|
|
"binary_sensor.kitchen_controller_temperature"
|
|
)
|
|
assert entity_entry is not None
|
|
|
|
# Unique ID device segment should have moved from 22222222 to 33333333
|
|
expected_unique_id = initial_unique_id.replace("/22222222/", "/33333333/")
|
|
assert entity_entry.unique_id == expected_unique_id
|
|
|
|
# Entity should now be associated with the second sub-device
|
|
bedroom_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_33333333"), device.entry.entry_id
|
|
)
|
|
assert bedroom_device is not None
|
|
assert entity_entry.device_id == bedroom_device.id
|
|
|
|
|
|
async def test_entity_device_id_rename_in_yaml(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities re-added when user renames device_id in YAML."""
|
|
# Initial setup: entity on sub-device with device_id 11111111
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="old_device", area_id=0),
|
|
]
|
|
|
|
device_info = {
|
|
"name": "test",
|
|
"devices": sub_devices,
|
|
}
|
|
|
|
# Entity on sub-device
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor",
|
|
key=1,
|
|
name="Sensor",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111),
|
|
]
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# Verify initial entity setup
|
|
state = hass.states.get("binary_sensor.old_device_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
# Wait for entity to be registered
|
|
await hass.async_block_till_done()
|
|
|
|
# Get the entity from registry
|
|
entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor")
|
|
assert entity_entry is not None
|
|
initial_unique_id = entity_entry.unique_id
|
|
# Sub-device entities carry the sub-device id in the unique id
|
|
assert "/11111111/" in initial_unique_id
|
|
|
|
# Simulate user renaming device_id in YAML config
|
|
# The device_id hash changes from 11111111 to 99999999
|
|
# This is treated as a completely new device
|
|
renamed_sub_devices = [
|
|
SubDeviceInfo(device_id=99999999, name="renamed_device", area_id=0),
|
|
]
|
|
|
|
# Get the config entry from hass
|
|
entries = hass.config_entries.async_entries(DOMAIN)
|
|
assert len(entries) == 1
|
|
entry = entries[0]
|
|
|
|
# Update device_id_to_name mapping
|
|
entry_data = entry.runtime_data
|
|
entry_data.device_id_to_name = {
|
|
sub_device.device_id: sub_device.name for sub_device in renamed_sub_devices
|
|
}
|
|
|
|
# Create new DeviceInfo with renamed device
|
|
current_device_info = mock_client.device_info.return_value
|
|
device_info_dict = asdict(current_device_info)
|
|
device_info_dict["devices"] = renamed_sub_devices
|
|
new_device_info = DeviceInfo(**device_info_dict)
|
|
mock_client.device_info.return_value = new_device_info
|
|
|
|
# Entity info now has the new device_id
|
|
new_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor", # Same object_id
|
|
key=1, # Same key
|
|
name="Sensor",
|
|
device_id=99999999, # New device_id after rename
|
|
),
|
|
]
|
|
|
|
# Update the entity info
|
|
mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, []))
|
|
mock_client.device_info_and_list_entities = AsyncMock(
|
|
return_value=(new_device_info, new_entity_info, [])
|
|
)
|
|
|
|
# Trigger a reconnect to simulate the YAML config change
|
|
await device.mock_disconnect(expected_disconnect=False)
|
|
await device.mock_connect()
|
|
await hass.async_block_till_done()
|
|
|
|
# The old entity should be gone (device was deleted)
|
|
state = hass.states.get("binary_sensor.old_device_sensor")
|
|
assert state is None
|
|
|
|
# A new entity should exist with a new entity_id based on the new device name
|
|
# This is a completely new entity, not a migrated one
|
|
state = hass.states.get("binary_sensor.renamed_device_sensor")
|
|
assert state is not None
|
|
assert state.state == STATE_ON
|
|
|
|
# Get the new entity from registry
|
|
entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor")
|
|
assert entity_entry is not None
|
|
|
|
# Unique ID device segment should have the new device_id
|
|
expected_unique_id = initial_unique_id.replace("/11111111/", "/99999999/")
|
|
assert entity_entry.unique_id == expected_unique_id
|
|
|
|
# Entity should be associated with the new device
|
|
renamed_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_99999999"), device.entry.entry_id
|
|
)
|
|
assert renamed_device is not None
|
|
assert entity_entry.device_id == renamed_device.id
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("unicode_name", "expected_entity_id"),
|
|
[
|
|
("Árvíztűrő tükörfúrógép", "binary_sensor.test_arvizturo_tukorfurogep"),
|
|
("Teplota venku °C", "binary_sensor.test_teplota_venku_degc"),
|
|
("Влажность %", "binary_sensor.test_vlazhnost"),
|
|
("中文传感器", "binary_sensor.test_zhong_wen_chuan_gan_qi"),
|
|
("Sensor à côté", "binary_sensor.test_sensor_a_cote"),
|
|
("τιμή αισθητήρα", "binary_sensor.test_time_aisthetera"),
|
|
],
|
|
)
|
|
async def test_entity_with_unicode_name(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_generic_device_entry: MockGenericDeviceEntryType,
|
|
unicode_name: str,
|
|
expected_entity_id: str,
|
|
) -> None:
|
|
"""Test that entities with Unicode names get proper entity IDs.
|
|
|
|
This verifies the fix for Unicode entity names where ESPHome's C++ code
|
|
sanitizes Unicode characters to underscores (not UTF-8 aware), but the
|
|
entity_id should use the original name from entity_info.name rather than
|
|
the sanitized object_id to preserve Unicode characters properly.
|
|
"""
|
|
# Simulate what ESPHome would send - a heavily sanitized object_id
|
|
# but with the original Unicode name preserved
|
|
sanitized_object_id = "_".join("_" * len(word) for word in unicode_name.split())
|
|
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id=sanitized_object_id, # ESPHome sends the sanitized version
|
|
key=1,
|
|
name=unicode_name, # But also sends the original Unicode name,
|
|
)
|
|
]
|
|
states = [BinarySensorState(key=1, state=True)]
|
|
|
|
await mock_generic_device_entry(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# The entity_id should be based on the Unicode name, properly transliterated
|
|
state = hass.states.get(expected_entity_id)
|
|
assert state is not None, f"Entity with ID {expected_entity_id} should exist"
|
|
assert state.state == STATE_ON
|
|
|
|
# The friendly name should preserve the original Unicode characters
|
|
assert state.attributes["friendly_name"] == f"Test {unicode_name}"
|
|
|
|
# Verify that using the sanitized object_id would NOT find the entity
|
|
# This confirms we're not using the object_id for entity_id generation
|
|
wrong_entity_id = f"binary_sensor.test_{sanitized_object_id}"
|
|
wrong_state = hass.states.get(wrong_entity_id)
|
|
assert wrong_state is None, f"Entity should NOT be found at {wrong_entity_id}"
|
|
|
|
|
|
async def test_entity_without_name_uses_device_name_only(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_generic_device_entry: MockGenericDeviceEntryType,
|
|
) -> None:
|
|
"""Test that entities without a name fall back to using device name only.
|
|
|
|
When entity_info.name is empty, the entity_id should just be domain.device_name
|
|
without the object_id appended, as noted in the comment in entity.py.
|
|
"""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="some_sanitized_id",
|
|
key=1,
|
|
name="", # Empty name,
|
|
)
|
|
]
|
|
states = [BinarySensorState(key=1, state=True)]
|
|
|
|
await mock_generic_device_entry(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
|
|
# With empty name, entity_id should just be domain.device_name
|
|
expected_entity_id = "binary_sensor.test"
|
|
state = hass.states.get(expected_entity_id)
|
|
assert state is not None, f"Entity {expected_entity_id} should exist"
|
|
assert state.state == STATE_ON
|
|
|
|
|
|
async def test_entities_rekeyed_after_firmware_update(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities survive the key changing when the name stays the same.
|
|
|
|
The API key is only stable for a session; a firmware update may
|
|
re-derive every key. The registry entries must be preserved so
|
|
helpers pointing at the entities are not deleted.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_ON
|
|
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
# Firmware update re-derives every key, names unchanged
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=102,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass,
|
|
device,
|
|
updated_entity_info,
|
|
states=[
|
|
BinarySensorState(key=101, state=True, missing_state=False),
|
|
BinarySensorState(key=102, state=True, missing_state=False),
|
|
],
|
|
)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert new_entry_one.unique_id == entry_one.unique_id
|
|
assert new_entry_two.unique_id == entry_two.unique_id
|
|
assert actions_one == []
|
|
assert actions_two == []
|
|
|
|
# State updates must follow the new key
|
|
device.set_state(BinarySensorState(key=101, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_OFF
|
|
|
|
# The old key must be ignored
|
|
device.set_state(BinarySensorState(key=1, state=True, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_OFF
|
|
|
|
# Reconnect again with stable keys, static info updates must
|
|
# reach the entity under the new key
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
icon="mdi:motion-sensor",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=102,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
device.set_state(BinarySensorState(key=101, state=True, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_sensor_one")
|
|
assert state.state == STATE_ON
|
|
assert state.attributes[ATTR_ICON] == "mdi:motion-sensor"
|
|
assert actions_one == []
|
|
assert actions_two == []
|
|
|
|
|
|
async def test_entities_rekeyed_after_reload(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test entities restored from storage survive a key change on connect.
|
|
|
|
Covers the path where entities are created from stored static infos
|
|
with the old keys and the first connect delivers the new keys.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry = device.entry
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
await hass.config_entries.async_unload(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=102,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=updated_entity_info,
|
|
entry=entry,
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert "remove" not in actions_one
|
|
assert "remove" not in actions_two
|
|
|
|
device.set_state(BinarySensorState(key=101, state=True, missing_state=False))
|
|
device.set_state(BinarySensorState(key=102, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_OFF
|
|
|
|
|
|
async def test_entity_rekeyed_and_another_removed(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a key change combined with a genuine entity removal."""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert entry_one is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
# Sensor One is re-keyed, Sensor Two is gone from the config
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass,
|
|
device,
|
|
updated_entity_info,
|
|
states=[BinarySensorState(key=101, state=True, missing_state=False)],
|
|
)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert new_entry_one is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert actions_one == []
|
|
|
|
assert entity_registry.async_get("binary_sensor.test_sensor_two") is None
|
|
assert actions_two == ["remove"]
|
|
|
|
|
|
async def test_entity_rekeyed_to_another_entities_old_key(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a re-key that collides with an unrelated entity's old key.
|
|
|
|
Sensor One takes over the key that used to belong to Sensor Two,
|
|
which is removed at the same time. The unique_id match must win so
|
|
the entities do not swap identities.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert entry_one is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=2,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert new_entry_one is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_one.unique_id == entry_one.unique_id
|
|
assert actions_one == []
|
|
|
|
assert entity_registry.async_get("binary_sensor.test_sensor_two") is None
|
|
assert actions_two == ["remove"]
|
|
|
|
# Sensor One must follow its new key and keep its own identity
|
|
device.set_state(BinarySensorState(key=2, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_sensor_one")
|
|
assert state.state == STATE_OFF
|
|
assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Sensor One"
|
|
|
|
|
|
async def test_entity_renamed_with_stable_key(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a rename that keeps the object_id and key stable.
|
|
|
|
A case only rename keeps the object_id, and therefore the key,
|
|
stable; the entity is updated in place and its registry entry
|
|
follows the new name based unique_id.
|
|
"""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert entry_one is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="SENSOR ONE",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert new_entry_one is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert "remove" not in actions_one
|
|
# The registry unique_id must follow the rename so the entry is
|
|
# not orphaned on the next restart
|
|
assert new_entry_one.unique_id == build_device_unique_id(
|
|
device.device_info.mac_address, updated_entity_info[0]
|
|
)
|
|
|
|
device.set_state(BinarySensorState(key=1, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.test_sensor_one")
|
|
assert state.state == STATE_OFF
|
|
assert state.attributes[ATTR_FRIENDLY_NAME] == "Test SENSOR ONE"
|
|
|
|
|
|
async def test_entity_renamed_and_rekeyed(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a rename on firmware where the key is derived from the name.
|
|
|
|
Both the name and the key change so this is treated as a new entity.
|
|
"""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert entity_registry.async_get("binary_sensor.test_sensor_one") is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one_renamed",
|
|
key=101,
|
|
name="Sensor One Renamed",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
assert entity_registry.async_get("binary_sensor.test_sensor_one") is None
|
|
assert actions_one == ["remove"]
|
|
assert (
|
|
entity_registry.async_get("binary_sensor.test_sensor_one_renamed") is not None
|
|
)
|
|
|
|
device.set_state(BinarySensorState(key=101, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_one_renamed").state == STATE_OFF
|
|
|
|
|
|
async def test_entities_rekeyed_on_sub_devices_with_same_name(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test re-keying entities with the same name on different sub devices."""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111),
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.sub_one_battery")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.sub_two_battery")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=101,
|
|
name="Battery",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=101,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert actions_one == []
|
|
assert actions_two == []
|
|
|
|
# Each entity must follow the new key on its own device
|
|
device.set_state(
|
|
BinarySensorState(key=101, state=False, missing_state=False, device_id=11111111)
|
|
)
|
|
device.set_state(
|
|
BinarySensorState(key=101, state=True, missing_state=False, device_id=22222222)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.sub_one_battery").state == STATE_OFF
|
|
assert hass.states.get("binary_sensor.sub_two_battery").state == STATE_ON
|
|
|
|
|
|
async def test_entities_swap_keys(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test two entities swapping keys in the same update.
|
|
|
|
Keys are not collision safe; a firmware update may hand one
|
|
entity's old key to another entity. Each entity must follow its
|
|
own unique_id and never adopt the other's identity.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=2,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=1,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert new_entry_one.unique_id == entry_one.unique_id
|
|
assert new_entry_two.unique_id == entry_two.unique_id
|
|
assert actions_one == []
|
|
assert actions_two == []
|
|
|
|
# Each entity must follow its swapped key with its own identity
|
|
device.set_state(BinarySensorState(key=2, state=False, missing_state=False))
|
|
device.set_state(BinarySensorState(key=1, state=True, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state_one = hass.states.get("binary_sensor.test_sensor_one")
|
|
state_two = hass.states.get("binary_sensor.test_sensor_two")
|
|
assert state_one.state == STATE_OFF
|
|
assert state_two.state == STATE_ON
|
|
assert state_one.attributes[ATTR_FRIENDLY_NAME] == "Test Sensor One"
|
|
assert state_two.attributes[ATTR_FRIENDLY_NAME] == "Test Sensor Two"
|
|
|
|
|
|
async def test_entity_rekeyed_and_moved_between_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test an entity changing key and device in the same update.
|
|
|
|
The name is the device independent identity, so the entity is
|
|
migrated to the new device instead of being removed and recreated.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert entry_one is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert new_entry is not None
|
|
assert new_entry.id == entry_one.id
|
|
assert "remove" not in actions_one
|
|
|
|
sub_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id
|
|
)
|
|
assert sub_device is not None
|
|
assert new_entry.device_id == sub_device.id
|
|
|
|
device.set_state(
|
|
BinarySensorState(key=101, state=False, missing_state=False, device_id=11111111)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_OFF
|
|
|
|
|
|
async def test_entity_new_key_reuses_removed_entity_key_on_other_device(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a new entity reusing a removed entity's key on another device.
|
|
|
|
The names differ, so the new entity must not steal the removed
|
|
entity's registry entry through the cross device key match.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=2,
|
|
name="Sensor Two",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False, device_id=11111111),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_two = entity_registry.async_get("binary_sensor.sub_one_sensor_two")
|
|
assert entry_two is not None
|
|
|
|
actions_two = track_entity_registry_actions(
|
|
hass, "binary_sensor.sub_one_sensor_two"
|
|
)
|
|
|
|
# Sensor Two is removed while a new Sensor Three on the main
|
|
# device reuses its key
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_three",
|
|
key=2,
|
|
name="Sensor Three",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
assert entity_registry.async_get("binary_sensor.sub_one_sensor_two") is None
|
|
assert actions_two == ["remove"]
|
|
entry_three = entity_registry.async_get("binary_sensor.test_sensor_three")
|
|
assert entry_three is not None
|
|
assert entry_three.id != entry_two.id
|
|
|
|
device.set_state(BinarySensorState(key=2, state=False, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_sensor_three").state == STATE_OFF
|
|
|
|
|
|
async def test_entities_with_same_name_moved_between_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test same named entities moving between devices at the same time.
|
|
|
|
The name is ambiguous so the moves are resolved by the key, which
|
|
migrates each entity to its own new device.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
SubDeviceInfo(device_id=33333333, name="sub_three", area_id=0),
|
|
SubDeviceInfo(device_id=44444444, name="sub_four", area_id=0),
|
|
SubDeviceInfo(device_id=55555555, name="sub_five", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=55555555,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111),
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222),
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=55555555),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
assert entity_registry.async_get("binary_sensor.sub_five_battery") is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.sub_one_battery")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.sub_two_battery")
|
|
actions_five = track_entity_registry_actions(hass, "binary_sensor.sub_five_battery")
|
|
|
|
# Two of the same named entities move to new sub devices with
|
|
# stable keys while the third is removed
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=33333333,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=44444444,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert "remove" not in actions_one
|
|
assert "remove" not in actions_two
|
|
assert entity_registry.async_get("binary_sensor.sub_five_battery") is None
|
|
assert actions_five == ["remove"]
|
|
|
|
mac = device.device_info.mac_address
|
|
sub_three = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{mac}_33333333"), device.entry.entry_id
|
|
)
|
|
sub_four = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{mac}_44444444"), device.entry.entry_id
|
|
)
|
|
assert sub_three is not None
|
|
assert sub_four is not None
|
|
assert new_entry_one.device_id == sub_three.id
|
|
assert new_entry_two.device_id == sub_four.id
|
|
|
|
# Each entity must follow the key on its new device
|
|
device.set_state(
|
|
BinarySensorState(key=1, state=False, missing_state=False, device_id=33333333)
|
|
)
|
|
device.set_state(
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=44444444)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.sub_one_battery").state == STATE_OFF
|
|
assert hass.states.get("binary_sensor.sub_two_battery").state == STATE_ON
|
|
|
|
|
|
async def test_entity_rekeyed_while_old_key_reused_by_renamed_entity(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a re-key where the freed key is reused by a renamed entity.
|
|
|
|
The renamed entity arrives first in the info list and matches
|
|
Sensor Two's old key, but Sensor Two's identity is claimed by a
|
|
later info, so the in place key match must not consume it.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.test_sensor_two")
|
|
|
|
# Sensor Two is re-keyed 2 to 1 while a renamed entity takes over
|
|
# key 2; the renamed entity is listed first
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_renamed",
|
|
key=2,
|
|
name="Sensor Renamed",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=1,
|
|
name="Sensor Two",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_two = entity_registry.async_get("binary_sensor.test_sensor_two")
|
|
assert new_entry_two is not None
|
|
assert new_entry_two.id == entry_two.id
|
|
assert new_entry_two.unique_id == entry_two.unique_id
|
|
assert actions_two == []
|
|
|
|
# Sensor One is gone and the renamed entity is brand new
|
|
assert entity_registry.async_get("binary_sensor.test_sensor_one") is None
|
|
assert actions_one == ["remove"]
|
|
entry_renamed = entity_registry.async_get("binary_sensor.test_sensor_renamed")
|
|
assert entry_renamed is not None
|
|
assert entry_renamed.id != entry_one.id
|
|
|
|
# Each entity must follow its own key with its own identity
|
|
device.set_state(BinarySensorState(key=1, state=False, missing_state=False))
|
|
device.set_state(BinarySensorState(key=2, state=True, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
state_two = hass.states.get("binary_sensor.test_sensor_two")
|
|
state_renamed = hass.states.get("binary_sensor.test_sensor_renamed")
|
|
assert state_two.state == STATE_OFF
|
|
assert state_two.attributes[ATTR_FRIENDLY_NAME] == "Test Sensor Two"
|
|
assert state_renamed.state == STATE_ON
|
|
|
|
|
|
async def test_entity_moved_into_removed_entities_key_slot(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a device move into a key slot vacated by a removed entity.
|
|
|
|
The moved entity keeps its key and lands on the removed entity's
|
|
old (device_id, key) slot; the name match must win so the move is
|
|
migrated instead of being mistaken for a rename of the removed
|
|
entity.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="batt",
|
|
key=5,
|
|
name="Batt",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="volt",
|
|
key=5,
|
|
name="Volt",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=5, state=True, missing_state=False, device_id=11111111),
|
|
BinarySensorState(key=5, state=True, missing_state=False, device_id=22222222),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_batt = entity_registry.async_get("binary_sensor.sub_one_batt")
|
|
entry_volt = entity_registry.async_get("binary_sensor.sub_two_volt")
|
|
assert entry_batt is not None
|
|
assert entry_volt is not None
|
|
|
|
actions_batt = track_entity_registry_actions(hass, "binary_sensor.sub_one_batt")
|
|
actions_volt = track_entity_registry_actions(hass, "binary_sensor.sub_two_volt")
|
|
|
|
# Batt moves to sub_two keeping key 5, taking over Volt's old
|
|
# (device_id, key) slot; Volt is removed
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="batt",
|
|
key=5,
|
|
name="Batt",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_batt = entity_registry.async_get("binary_sensor.sub_one_batt")
|
|
assert new_entry_batt is not None
|
|
assert new_entry_batt.id == entry_batt.id
|
|
assert "remove" not in actions_batt
|
|
|
|
sub_two = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id
|
|
)
|
|
assert sub_two is not None
|
|
assert new_entry_batt.device_id == sub_two.id
|
|
|
|
assert entity_registry.async_get("binary_sensor.sub_two_volt") is None
|
|
assert actions_volt == ["remove"]
|
|
|
|
device.set_state(
|
|
BinarySensorState(key=5, state=False, missing_state=False, device_id=22222222)
|
|
)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.sub_one_batt")
|
|
assert state.state == STATE_OFF
|
|
assert state.attributes[ATTR_FRIENDLY_NAME] == "sub_two Batt"
|
|
|
|
|
|
async def test_entity_moved_while_new_entity_takes_old_slot_listed_first(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test a move where a new entity takes the old slot and is listed first.
|
|
|
|
The new entity arrives first and matches the mover's old
|
|
(device_id, key) slot, but the mover's name is claimed by a later
|
|
info, so the in place key match must not consume it.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="foo",
|
|
key=1,
|
|
name="Foo",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_foo = entity_registry.async_get("binary_sensor.test_foo")
|
|
assert entry_foo is not None
|
|
|
|
actions_foo = track_entity_registry_actions(hass, "binary_sensor.test_foo")
|
|
|
|
# Foo moves to the sub device with a new key while new entity Bar
|
|
# takes over Foo's old (device_id, key) slot and is listed first
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="bar",
|
|
key=1,
|
|
name="Bar",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="foo",
|
|
key=9,
|
|
name="Foo",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
with caplog.at_level(logging.DEBUG, "homeassistant.components.esphome"):
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
# Every candidate slot was occupied, so the ambiguous fallback ran
|
|
assert "Ambiguous move for Foo" in caplog.text
|
|
new_entry_foo = entity_registry.async_get("binary_sensor.test_foo")
|
|
assert new_entry_foo is not None
|
|
assert new_entry_foo.id == entry_foo.id
|
|
assert "remove" not in actions_foo
|
|
|
|
sub_one = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_11111111"), device.entry.entry_id
|
|
)
|
|
assert sub_one is not None
|
|
assert new_entry_foo.device_id == sub_one.id
|
|
|
|
entry_bar = entity_registry.async_get("binary_sensor.test_bar")
|
|
assert entry_bar is not None
|
|
assert entry_bar.id != entry_foo.id
|
|
|
|
# Each entity must follow its own key
|
|
device.set_state(
|
|
BinarySensorState(key=9, state=False, missing_state=False, device_id=11111111)
|
|
)
|
|
device.set_state(BinarySensorState(key=1, state=True, missing_state=False))
|
|
await hass.async_block_till_done()
|
|
assert hass.states.get("binary_sensor.test_foo").state == STATE_OFF
|
|
assert hass.states.get("binary_sensor.test_bar").state == STATE_ON
|
|
|
|
|
|
async def test_entity_renamed_with_stable_key_and_same_named_sibling(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a stable key rename while a sibling device shares the old name.
|
|
|
|
The sibling's Battery is claimed by its own unique_id match, so it
|
|
must not block the rename from being matched in place.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111),
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert entry_one is not None
|
|
assert entry_two is not None
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.sub_one_battery")
|
|
actions_two = track_entity_registry_actions(hass, "binary_sensor.sub_two_battery")
|
|
|
|
# Case only rename on sub_one keeps the object_id and key stable;
|
|
# sub_two still carries the old name
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="BATTERY",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_one = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
new_entry_two = entity_registry.async_get("binary_sensor.sub_two_battery")
|
|
assert new_entry_one is not None
|
|
assert new_entry_two is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert new_entry_two.id == entry_two.id
|
|
assert actions_two == []
|
|
assert "remove" not in actions_one
|
|
# The renamed entity's registry entry follows the new unique_id
|
|
assert new_entry_one.unique_id == build_device_unique_id(
|
|
device.device_info.mac_address, updated_entity_info[0]
|
|
)
|
|
|
|
device.set_state(
|
|
BinarySensorState(key=1, state=False, missing_state=False, device_id=11111111)
|
|
)
|
|
await hass.async_block_till_done()
|
|
state = hass.states.get("binary_sensor.sub_one_battery")
|
|
assert state.state == STATE_OFF
|
|
assert state.attributes[ATTR_FRIENDLY_NAME] == "sub_one BATTERY"
|
|
|
|
|
|
async def test_disabled_entity_rekeyed(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test a key change for a disabled entity.
|
|
|
|
A disabled entity has no live object and no key subscriptions, so
|
|
the key change has no subscriber; the registry entry must still be
|
|
preserved.
|
|
"""
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=1,
|
|
name="Sensor One",
|
|
disabled_by_default=True,
|
|
),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
)
|
|
entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert entry_one is not None
|
|
assert entry_one.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
|
|
|
actions_one = track_entity_registry_actions(hass, "binary_sensor.test_sensor_one")
|
|
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
disabled_by_default=True,
|
|
),
|
|
]
|
|
with caplog.at_level(logging.DEBUG, "homeassistant.components.esphome"):
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
assert "no subscriber for key change 1 -> 101" in caplog.text
|
|
new_entry_one = entity_registry.async_get("binary_sensor.test_sensor_one")
|
|
assert new_entry_one is not None
|
|
assert new_entry_one.id == entry_one.id
|
|
assert actions_one == []
|
|
|
|
|
|
async def test_entity_renamed_while_same_named_sibling_moves(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
device_registry: dr.DeviceRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a stable key rename while a same named sibling moves devices.
|
|
|
|
The mover must claim the sibling on sub_one, not the rename
|
|
candidate on the main device, and both registry entries must be
|
|
preserved.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_main = entity_registry.async_get("binary_sensor.test_battery")
|
|
entry_sub = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
assert entry_main is not None
|
|
assert entry_sub is not None
|
|
|
|
actions_main = track_entity_registry_actions(hass, "binary_sensor.test_battery")
|
|
actions_sub = track_entity_registry_actions(hass, "binary_sensor.sub_one_battery")
|
|
|
|
# Case only rename on the main device while the sub_one sibling
|
|
# moves to sub_two; the renamed entity is listed first
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="BATTERY",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="battery",
|
|
key=1,
|
|
name="Battery",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(hass, device, updated_entity_info)
|
|
|
|
new_entry_main = entity_registry.async_get("binary_sensor.test_battery")
|
|
new_entry_sub = entity_registry.async_get("binary_sensor.sub_one_battery")
|
|
assert new_entry_main is not None
|
|
assert new_entry_sub is not None
|
|
assert new_entry_main.id == entry_main.id
|
|
assert new_entry_sub.id == entry_sub.id
|
|
assert "remove" not in actions_main
|
|
assert "remove" not in actions_sub
|
|
|
|
# The rename followed the new unique_id and stayed on the main device
|
|
assert new_entry_main.unique_id == build_device_unique_id(
|
|
device.device_info.mac_address, updated_entity_info[0]
|
|
)
|
|
# The sibling migrated to sub_two
|
|
sub_two = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, f"{device.device_info.mac_address}_22222222"), device.entry.entry_id
|
|
)
|
|
assert sub_two is not None
|
|
assert new_entry_sub.device_id == sub_two.id
|
|
|
|
device.set_state(BinarySensorState(key=1, state=False, missing_state=False))
|
|
device.set_state(
|
|
BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222)
|
|
)
|
|
await hass.async_block_till_done()
|
|
state_main = hass.states.get("binary_sensor.test_battery")
|
|
state_sub = hass.states.get("binary_sensor.sub_one_battery")
|
|
assert state_main.state == STATE_OFF
|
|
assert state_main.attributes[ATTR_FRIENDLY_NAME] == "Test BATTERY"
|
|
assert state_sub.state == STATE_ON
|
|
|
|
|
|
async def test_new_entities_reusing_retired_keys_do_not_adopt_stale_states(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test new entities reusing retired keys start unknown.
|
|
|
|
Keys retired by a re-key or a removal may be handed to unrelated
|
|
entities by a later firmware build; those entities must come up
|
|
unknown instead of adopting the retired key's last state.
|
|
"""
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_ON
|
|
|
|
# Sensor One is re-keyed to 101, Sensor Two is removed
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass,
|
|
device,
|
|
updated_entity_info,
|
|
states=[BinarySensorState(key=101, state=True, missing_state=False)],
|
|
)
|
|
assert hass.states.get("binary_sensor.test_sensor_two") is None
|
|
|
|
# A later build hands the retired keys 1 and 2 to new entities;
|
|
# no states are streamed for them
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=101,
|
|
name="Sensor One",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_three",
|
|
key=1,
|
|
name="Sensor Three",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_four",
|
|
key=2,
|
|
name="Sensor Four",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass, device, updated_entity_info, states=[]
|
|
)
|
|
|
|
assert hass.states.get("binary_sensor.test_sensor_three").state == STATE_UNKNOWN
|
|
assert hass.states.get("binary_sensor.test_sensor_four").state == STATE_UNKNOWN
|
|
|
|
|
|
async def test_new_entity_on_other_device_reusing_removed_entities_key(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a new entity on another device reusing a removed entity's key.
|
|
|
|
The key stays live on the other device, so the removed entity's
|
|
cached state must be dropped by slot, not by key, or the new
|
|
entity would adopt it.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="volt",
|
|
key=2,
|
|
name="Volt",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_volt").state == STATE_ON
|
|
|
|
# Volt is removed while a new Batt on sub_one reuses key 2;
|
|
# no state is streamed for Batt
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="batt",
|
|
key=2,
|
|
name="Batt",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass, device, updated_entity_info, states=[]
|
|
)
|
|
|
|
assert hass.states.get("binary_sensor.test_volt") is None
|
|
assert hass.states.get("binary_sensor.sub_one_batt").state == STATE_UNKNOWN
|
|
|
|
|
|
async def test_unnamed_entities_do_not_pair_across_devices(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test unnamed entities are not matched as moves across devices.
|
|
|
|
An unnamed entity's identity is its device derived object_id, so a
|
|
removed unnamed entity must not hand its registry entry to an
|
|
unrelated unnamed entity appearing on another device.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="test",
|
|
key=1,
|
|
name="",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
entry_main = entity_registry.async_get("binary_sensor.test")
|
|
assert entry_main is not None
|
|
|
|
actions_main = track_entity_registry_actions(hass, "binary_sensor.test")
|
|
|
|
# The main device's unnamed entity is removed while an unrelated
|
|
# unnamed entity appears on sub_one
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sub_one",
|
|
key=2,
|
|
name="",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass, device, updated_entity_info, states=[]
|
|
)
|
|
|
|
assert entity_registry.async_get("binary_sensor.test") is None
|
|
assert actions_main == ["remove"]
|
|
entry_sub = entity_registry.async_get("binary_sensor.sub_one")
|
|
assert entry_sub is not None
|
|
assert entry_sub.id != entry_main.id
|
|
|
|
|
|
async def test_moved_entity_does_not_adopt_removed_entities_state(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a mover landing on a removed entity's key starts unknown.
|
|
|
|
The removed entity's cached state sits under the mover's new key
|
|
and must be dropped, or the re-added mover would adopt it.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="foo",
|
|
key=5,
|
|
name="Foo",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="bar",
|
|
key=9,
|
|
name="Bar",
|
|
device_id=11111111,
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=5, state=True, missing_state=False),
|
|
BinarySensorState(key=9, state=False, missing_state=False, device_id=11111111),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_foo").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.sub_one_bar").state == STATE_OFF
|
|
|
|
# Foo is removed while Bar moves to the main device and a firmware
|
|
# rebuild hands it Foo's old key; no states are streamed
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="bar",
|
|
key=5,
|
|
name="Bar",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass, device, updated_entity_info, states=[]
|
|
)
|
|
|
|
assert hass.states.get("binary_sensor.test_foo") is None
|
|
assert hass.states.get("binary_sensor.sub_one_bar").state == STATE_UNKNOWN
|
|
|
|
|
|
async def test_entity_move_with_claimed_unique_id(
|
|
hass: HomeAssistant,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test a device move whose target unique_id is already claimed.
|
|
|
|
An orphaned registry entry holding the target unique_id must not
|
|
abort the update; the move keeps its old unique_id and the rest of
|
|
the entities are still processed.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="foo",
|
|
key=1,
|
|
name="Foo",
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="other",
|
|
key=2,
|
|
name="Other",
|
|
),
|
|
]
|
|
states = [
|
|
BinarySensorState(key=1, state=True, missing_state=False),
|
|
BinarySensorState(key=2, state=True, missing_state=False),
|
|
]
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_foo").state == STATE_ON
|
|
entry_foo = entity_registry.async_get("binary_sensor.test_foo")
|
|
assert entry_foo is not None
|
|
|
|
# An orphaned registry entry already claims Foo's post move unique_id
|
|
moved_info = BinarySensorInfo(
|
|
object_id="foo",
|
|
key=1,
|
|
name="Foo",
|
|
device_id=11111111,
|
|
)
|
|
orphan = entity_registry.async_get_or_create(
|
|
"binary_sensor",
|
|
DOMAIN,
|
|
build_device_unique_id(device.device_info.mac_address, moved_info),
|
|
config_entry=device.entry,
|
|
)
|
|
|
|
updated_entity_info = [
|
|
moved_info,
|
|
BinarySensorInfo(
|
|
object_id="other",
|
|
key=2,
|
|
name="Other",
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass,
|
|
device,
|
|
updated_entity_info,
|
|
states=[
|
|
BinarySensorState(
|
|
key=1, state=True, missing_state=False, device_id=11111111
|
|
),
|
|
BinarySensorState(key=2, state=False, missing_state=False),
|
|
],
|
|
)
|
|
|
|
# The update completed: the sibling still processes states
|
|
assert hass.states.get("binary_sensor.test_other").state == STATE_OFF
|
|
# The blocked migration leaves the original entry untouched; the
|
|
# moved entity binds to the orphan
|
|
original = entity_registry.async_get("binary_sensor.test_foo")
|
|
assert original is not None
|
|
assert original.unique_id == entry_foo.unique_id
|
|
assert original.device_id == entry_foo.device_id
|
|
assert hass.states.get("binary_sensor.test_foo").state == STATE_UNAVAILABLE
|
|
assert entity_registry.async_get(orphan.entity_id) is not None
|
|
assert hass.states.get(orphan.entity_id).state == STATE_ON
|
|
|
|
|
|
async def test_mover_does_not_adopt_other_movers_state(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Test two movers from one device cannot adopt each other's state.
|
|
|
|
A mover landing on another mover's old key must start unknown; the
|
|
cached state under that key was written from a different slot.
|
|
"""
|
|
sub_devices = [
|
|
SubDeviceInfo(device_id=11111111, name="sub_one", area_id=0),
|
|
SubDeviceInfo(device_id=22222222, name="sub_two", area_id=0),
|
|
]
|
|
device_info = {
|
|
"devices": sub_devices,
|
|
}
|
|
entity_info, states = _two_binary_sensor_setup()
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info=device_info,
|
|
entity_info=entity_info,
|
|
states=states,
|
|
)
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_ON
|
|
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_ON
|
|
|
|
# Both entities move to sub devices while a firmware rebuild
|
|
# re-derives keys; Sensor Two lands on Sensor One's old key and
|
|
# no states are streamed
|
|
updated_entity_info = [
|
|
BinarySensorInfo(
|
|
object_id="sensor_one",
|
|
key=5,
|
|
name="Sensor One",
|
|
device_id=11111111,
|
|
),
|
|
BinarySensorInfo(
|
|
object_id="sensor_two",
|
|
key=1,
|
|
name="Sensor Two",
|
|
device_id=22222222,
|
|
),
|
|
]
|
|
await reconnect_with_updated_entity_info(
|
|
hass, device, updated_entity_info, states=[]
|
|
)
|
|
|
|
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_UNKNOWN
|
|
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_UNKNOWN
|