Add sub-device support to mobile_app

Allow a single mobile_app config entry to expose more than one device in
the device registry with a parent/child relationship via two new webhook
commands (`register_device`, `unregister_device`) and an optional
`device_id` field on `register_sensor`. Existing companion apps that
do not send these new fields keep behaving exactly as today; sensors
that are re-registered with a `device_id` are migrated to the target
sub-device while preserving their entity_id and history.

The driving use case is the iOS companion app exposing the iPhone as
the primary device and the paired Apple Watch as a sub-device, but the
contract is generic enough that the Android companion can adopt it
later.
This commit is contained in:
Claude
2026-04-29 21:26:04 +00:00
parent fe577eaa1f
commit 1a69130e46
8 changed files with 605 additions and 4 deletions
@@ -4,9 +4,9 @@ from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON, STATE_UNKNOWN
from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID, STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,6 +23,7 @@ from .const import (
DOMAIN,
)
from .entity import MobileAppEntity
from .util import sub_device_id_for_entry
async def async_setup_entry(
@@ -34,8 +35,10 @@ async def async_setup_entry(
entities = []
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
primary_device_id = config_entry.data[ATTR_DEVICE_ID]
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
for entry in entries:
if entry.domain != ENTITY_TYPE or entry.disabled_by:
@@ -49,6 +52,12 @@ async def async_setup_entry(
ATTR_SENSOR_TYPE: entry.domain,
ATTR_SENSOR_UNIQUE_ID: entry.unique_id,
ATTR_SENSOR_ENTITY_CATEGORY: entry.entity_category,
ATTR_DEVICE_ID: sub_device_id_for_entry(
device_registry,
config_entry.entry_id,
primary_device_id,
entry.device_id,
),
}
entities.append(MobileAppBinarySensor(config, config_entry))
@@ -29,6 +29,7 @@ ATTR_APP_VERSION = "app_version"
ATTR_DEVICE_NAME = "device_name"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL = "model"
ATTR_VIA_DEVICE_ID = "via_device_id"
ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption"
ATTR_OS_NAME = "os_name"
ATTR_OS_VERSION = "os_version"
@@ -64,6 +65,7 @@ ERR_ENCRYPTION_NOT_AVAILABLE = "encryption_not_available"
ERR_ENCRYPTION_REQUIRED = "encryption_required"
ERR_SENSOR_NOT_REGISTERED = "not_registered"
ERR_INVALID_FORMAT = "invalid_format"
ERR_INVALID_DEVICE_ID = "invalid_device_id"
ATTR_SENSOR_ATTRIBUTES = "attributes"
@@ -6,6 +6,7 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ICON,
CONF_NAME,
CONF_UNIQUE_ID,
@@ -98,6 +99,8 @@ class MobileAppEntity(RestoreEntity):
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
if (sub_device_id := self._config.get(ATTR_DEVICE_ID)) is not None:
return DeviceInfo(identifiers={(DOMAIN, sub_device_id)})
return device_info(self._registration)
@callback
+16 -2
View File
@@ -7,9 +7,14 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_WEBHOOK_ID,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -30,6 +35,7 @@ from .const import (
DOMAIN,
)
from .entity import MobileAppEntity
from .util import sub_device_id_for_entry
from .webhook import _extract_sensor_unique_id
@@ -42,8 +48,10 @@ async def async_setup_entry(
entities = []
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
primary_device_id = config_entry.data[ATTR_DEVICE_ID]
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
for entry in entries:
if entry.domain != ENTITY_TYPE or entry.disabled_by:
@@ -58,6 +66,12 @@ async def async_setup_entry(
ATTR_SENSOR_UNIQUE_ID: entry.unique_id,
ATTR_SENSOR_UOM: entry.unit_of_measurement,
ATTR_SENSOR_ENTITY_CATEGORY: entry.entity_category,
ATTR_DEVICE_ID: sub_device_id_for_entry(
device_registry,
config_entry.entry_id,
primary_device_id,
entry.device_id,
),
}
if capabilities := entry.capabilities:
config[ATTR_SENSOR_STATE_CLASS] = capabilities.get(ATTR_SENSOR_STATE_CLASS)
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from homeassistant.components import cloud
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from .const import (
ATTR_APP_DATA,
@@ -26,6 +27,29 @@ if TYPE_CHECKING:
from .notify import MobileAppNotificationService
@callback
def sub_device_id_for_entry(
device_registry: dr.DeviceRegistry,
entry_id: str,
primary_device_id: str,
ha_device_id: str | None,
) -> str | None:
"""Return the mobile_app sub-device id for an entity registry entry.
Returns ``None`` when the entity is linked to the primary device or when
the device cannot be resolved to a sub-device of the given config entry.
"""
if ha_device_id is None:
return None
device = device_registry.async_get(ha_device_id)
if device is None or entry_id not in device.config_entries:
return None
for domain, identifier in device.identifiers:
if domain == DOMAIN and identifier != primary_device_id:
return identifier
return None
@callback
def webhook_id_from_device_id(hass: HomeAssistant, device_id: str) -> str | None:
"""Get webhook ID from device ID."""
@@ -70,6 +70,7 @@ from .const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NO_LEGACY_ENCRYPTION,
ATTR_OS_NAME,
ATTR_OS_VERSION,
ATTR_SENSOR_ATTRIBUTES,
ATTR_SENSOR_DEVICE_CLASS,
@@ -88,6 +89,7 @@ from .const import (
ATTR_TEMPLATE,
ATTR_TEMPLATE_VARIABLES,
ATTR_VERTICAL_ACCURACY,
ATTR_VIA_DEVICE_ID,
ATTR_WEBHOOK_DATA,
ATTR_WEBHOOK_ENCRYPTED,
ATTR_WEBHOOK_ENCRYPTED_DATA,
@@ -103,6 +105,7 @@ from .const import (
DOMAIN,
ERR_ENCRYPTION_ALREADY_ENABLED,
ERR_ENCRYPTION_REQUIRED,
ERR_INVALID_DEVICE_ID,
ERR_INVALID_FORMAT,
ERR_SENSOR_NOT_REGISTERED,
SCHEMA_APP_DATA,
@@ -533,6 +536,7 @@ def _extract_sensor_unique_id(webhook_id: str, unique_id: str) -> str:
vol.Required(ATTR_SENSOR_NAME): cv.string,
vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES),
vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string,
vol.Optional(ATTR_DEVICE_ID): vol.Any(None, cv.string),
vol.Optional(ATTR_SENSOR_UOM): vol.Any(None, cv.string),
vol.Optional(ATTR_SENSOR_STATE, default=None): vol.Any(
None, bool, int, float, str
@@ -558,6 +562,25 @@ async def webhook_register_sensor(
entity_type: str = data[ATTR_SENSOR_TYPE]
unique_id: str = data[ATTR_SENSOR_UNIQUE_ID]
device_name: str = config_entry.data[ATTR_DEVICE_NAME]
primary_device_id: str = config_entry.data[ATTR_DEVICE_ID]
sub_device_id = data.get(ATTR_DEVICE_ID)
if sub_device_id is not None and sub_device_id != primary_device_id:
device_registry = dr.async_get(hass)
target_device = device_registry.async_get_device(
identifiers={(DOMAIN, sub_device_id)}
)
if target_device is None or config_entry.entry_id not in (
target_device.config_entries
):
return error_response(
ERR_INVALID_DEVICE_ID,
f"Device {sub_device_id} is not registered for this config entry",
)
else:
# Normalize: an explicit primary device_id behaves like omitting it.
sub_device_id = None
data[ATTR_DEVICE_ID] = sub_device_id
unique_store_key = _gen_unique_id(config_entry.data[CONF_WEBHOOK_ID], unique_id)
entity_registry = er.async_get(hass)
@@ -600,6 +623,12 @@ async def webhook_register_sensor(
if data_key in data and getattr(entry, ent_reg_key) != data[data_key]:
changes[ent_reg_key] = data[data_key]
new_ha_device_id = _resolve_target_ha_device_id(
hass, config_entry, primary_device_id, sub_device_id
)
if new_ha_device_id is not None and entry.device_id != new_ha_device_id:
changes["device_id"] = new_ha_device_id
if changes:
entity_registry.async_update_entity(existing_sensor, **changes)
@@ -622,6 +651,125 @@ async def webhook_register_sensor(
)
def _resolve_target_ha_device_id(
hass: HomeAssistant,
config_entry: ConfigEntry,
primary_device_id: str,
sub_device_id: str | None,
) -> str | None:
"""Resolve the HA core device id for the given mobile_app device id."""
target_id = sub_device_id if sub_device_id is not None else primary_device_id
device = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, target_id)})
if device is None or config_entry.entry_id not in device.config_entries:
return None
return device.id
@WEBHOOK_COMMANDS.register("register_device")
@validate_schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Optional(ATTR_MANUFACTURER): vol.Any(None, cv.string),
vol.Optional(ATTR_MODEL): vol.Any(None, cv.string),
vol.Optional(ATTR_OS_NAME): vol.Any(None, cv.string),
vol.Optional(ATTR_OS_VERSION): vol.Any(None, cv.string),
vol.Optional(ATTR_APP_VERSION): vol.Any(None, cv.string),
vol.Optional(ATTR_VIA_DEVICE_ID): vol.Any(None, cv.string),
}
)
async def webhook_register_device(
hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any]
) -> Response:
"""Handle a register sub-device webhook."""
device_id: str = data[ATTR_DEVICE_ID]
primary_device_id: str = config_entry.data[ATTR_DEVICE_ID]
if device_id == primary_device_id:
return error_response(
ERR_INVALID_DEVICE_ID,
"Cannot register sub-device with the same id as the primary device",
)
device_registry = dr.async_get(hass)
# Resolve the parent (via_device). Defaults to the primary device.
via_device_id = data.get(ATTR_VIA_DEVICE_ID) or primary_device_id
if via_device_id == device_id:
return error_response(
ERR_INVALID_DEVICE_ID,
"via_device_id cannot reference the device being registered",
)
via_device = device_registry.async_get_device(
identifiers={(DOMAIN, via_device_id)}
)
if via_device is None or config_entry.entry_id not in via_device.config_entries:
return error_response(
ERR_INVALID_DEVICE_ID,
f"via_device_id {via_device_id} is not registered for this config entry",
)
# Reject if the device id already exists for a different config entry.
existing = device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
if existing is not None and config_entry.entry_id not in existing.config_entries:
return error_response(
ERR_INVALID_DEVICE_ID,
f"Device {device_id} is already registered to another config entry",
)
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, device_id)},
via_device=(DOMAIN, via_device_id),
manufacturer=data.get(ATTR_MANUFACTURER),
model=data.get(ATTR_MODEL),
name=data[ATTR_DEVICE_NAME],
sw_version=data.get(ATTR_OS_VERSION),
)
return webhook_response(
{"success": True},
registration=config_entry.data,
status=HTTPStatus.CREATED,
)
@WEBHOOK_COMMANDS.register("unregister_device")
@validate_schema({vol.Required(ATTR_DEVICE_ID): cv.string})
async def webhook_unregister_device(
hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any]
) -> Response:
"""Handle an unregister sub-device webhook."""
device_id: str = data[ATTR_DEVICE_ID]
primary_device_id: str = config_entry.data[ATTR_DEVICE_ID]
if device_id == primary_device_id:
return error_response(
ERR_INVALID_DEVICE_ID,
"Cannot unregister the primary device; remove the config entry instead",
)
device_registry = dr.async_get(hass)
device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
if device is None or config_entry.entry_id not in device.config_entries:
return error_response(
ERR_INVALID_DEVICE_ID,
f"Device {device_id} is not registered for this config entry",
)
# Remove entities linked to this device first so they don't linger as
# orphans (mirrors the cleanup that happens on config-entry removal).
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
):
entity_registry.async_remove(entity_entry.entity_id)
device_registry.async_remove_device(device.id)
return empty_okay_response()
@WEBHOOK_COMMANDS.register("update_sensor_states")
@validate_schema(
vol.All(
+47
View File
@@ -63,6 +63,53 @@ async def test_remove_entry(
assert len(entity_registry.entities) == 0
async def test_remove_entry_with_sub_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client,
) -> None:
"""Test that sub-devices are removed when the config entry is removed."""
webhook_id = create_registrations[1]["webhook_id"]
url = f"/api/webhook/{webhook_id}"
await webhook_client.post(
url,
json={
"type": "register_device",
"data": {"device_id": "mock-device-id_watch", "name": "Apple Watch"},
},
)
await webhook_client.post(
url,
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 87,
"type": "sensor",
"unique_id": "watch_battery",
"device_id": "mock-device-id_watch",
},
},
)
await hass.async_block_till_done()
assert (
device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
is not None
)
for config_entry in hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.async_remove(config_entry.entry_id)
assert len(device_registry.devices) == 0
assert len(entity_registry.entities) == 0
async def _test_create_cloud_hook(
hass: HomeAssistant,
hass_admin_user: MockUser,
+354
View File
@@ -1303,3 +1303,357 @@ async def test_sending_sensor_state(
state = hass.states.get("sensor.test_1_battery_health")
assert state is not None
assert state.state == "okay-ish"
async def test_webhook_register_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test registering a sub-device under an existing config entry."""
webhook_id = create_registrations[1]["webhook_id"]
primary = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")})
assert primary is not None
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "register_device",
"data": {
"device_id": "mock-device-id_watch",
"name": "Apple Watch",
"manufacturer": "Apple",
"model": "Apple Watch Series 9",
"os_name": "watchOS",
"os_version": "11.2",
},
},
)
assert resp.status == HTTPStatus.CREATED
sub_device = device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
assert sub_device is not None
assert sub_device.via_device_id == primary.id
assert sub_device.manufacturer == "Apple"
assert sub_device.model == "Apple Watch Series 9"
assert sub_device.name == "Apple Watch"
assert sub_device.sw_version == "11.2"
async def test_webhook_register_device_idempotent(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that re-registering a sub-device updates it instead of duplicating."""
webhook_id = create_registrations[1]["webhook_id"]
url = f"/api/webhook/{webhook_id}"
payload: dict[str, Any] = {
"type": "register_device",
"data": {
"device_id": "mock-device-id_watch",
"name": "Apple Watch",
"model": "Apple Watch Series 9",
"os_version": "11.2",
},
}
resp = await webhook_client.post(url, json=payload)
assert resp.status == HTTPStatus.CREATED
sub_device = device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
assert sub_device is not None
original_id = sub_device.id
payload["data"]["os_version"] = "11.3"
payload["data"]["model"] = "Apple Watch Series 10"
resp = await webhook_client.post(url, json=payload)
assert resp.status == HTTPStatus.CREATED
sub_device = device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
assert sub_device is not None
assert sub_device.id == original_id
assert sub_device.sw_version == "11.3"
assert sub_device.model == "Apple Watch Series 10"
async def test_webhook_register_device_unknown_via_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that an unknown via_device_id is rejected and no device is created."""
webhook_id = create_registrations[1]["webhook_id"]
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "register_device",
"data": {
"device_id": "mock-device-id_watch",
"name": "Apple Watch",
"via_device_id": "does-not-exist",
},
},
)
assert resp.status == HTTPStatus.BAD_REQUEST
body = await resp.json()
assert body["success"] is False
assert body["error"]["code"] == "invalid_device_id"
assert (
device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
is None
)
async def test_webhook_register_device_rejects_primary_id(
hass: HomeAssistant,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that registering a sub-device using the primary id is rejected."""
webhook_id = create_registrations[1]["webhook_id"]
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "register_device",
"data": {"device_id": "mock-device-id", "name": "iPhone"},
},
)
assert resp.status == HTTPStatus.BAD_REQUEST
body = await resp.json()
assert body["error"]["code"] == "invalid_device_id"
async def test_webhook_register_sensor_with_device_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that a sensor can be linked to a sub-device on registration."""
webhook_id = create_registrations[1]["webhook_id"]
url = f"/api/webhook/{webhook_id}"
reg_resp = await webhook_client.post(
url,
json={
"type": "register_device",
"data": {"device_id": "mock-device-id_watch", "name": "Apple Watch"},
},
)
assert reg_resp.status == HTTPStatus.CREATED
sub_device = device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
assert sub_device is not None
reg_resp = await webhook_client.post(
url,
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 87,
"type": "sensor",
"unique_id": "watch_battery",
"device_id": "mock-device-id_watch",
"device_class": "battery",
"unit_of_measurement": "%",
},
},
)
assert reg_resp.status == HTTPStatus.CREATED
await hass.async_block_till_done()
entry = entity_registry.async_get("sensor.test_1_battery_level")
assert entry is not None
assert entry.device_id == sub_device.id
async def test_webhook_register_sensor_migrates_to_sub_device(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that re-registering a sensor with a device_id migrates the entity."""
webhook_id = create_registrations[1]["webhook_id"]
url = f"/api/webhook/{webhook_id}"
primary = device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")})
assert primary is not None
# Initial registration without device_id - lives on the primary device.
reg_resp = await webhook_client.post(
url,
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 50,
"type": "sensor",
"unique_id": "watch_battery",
},
},
)
assert reg_resp.status == HTTPStatus.CREATED
await hass.async_block_till_done()
entity_id = "sensor.test_1_battery_level"
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.device_id == primary.id
# Now register the watch sub-device and re-register the sensor under it.
reg_resp = await webhook_client.post(
url,
json={
"type": "register_device",
"data": {"device_id": "mock-device-id_watch", "name": "Apple Watch"},
},
)
assert reg_resp.status == HTTPStatus.CREATED
sub_device = device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
assert sub_device is not None
reg_resp = await webhook_client.post(
url,
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 87,
"type": "sensor",
"unique_id": "watch_battery",
"device_id": "mock-device-id_watch",
},
},
)
assert reg_resp.status == HTTPStatus.CREATED
await hass.async_block_till_done()
migrated = entity_registry.async_get(entity_id)
assert migrated is not None
assert migrated.entity_id == entity_id
assert migrated.device_id == sub_device.id
async def test_webhook_register_sensor_unknown_device_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that register_sensor rejects a device_id we don't know about."""
webhook_id = create_registrations[1]["webhook_id"]
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 87,
"type": "sensor",
"unique_id": "watch_battery",
"device_id": "does-not-exist",
},
},
)
assert resp.status == HTTPStatus.BAD_REQUEST
body = await resp.json()
assert body["success"] is False
assert body["error"]["code"] == "invalid_device_id"
assert entity_registry.async_get("sensor.test_1_battery_level") is None
async def test_webhook_unregister_device(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test unregistering a sub-device removes it and cleans up its entities."""
webhook_id = create_registrations[1]["webhook_id"]
url = f"/api/webhook/{webhook_id}"
await webhook_client.post(
url,
json={
"type": "register_device",
"data": {"device_id": "mock-device-id_watch", "name": "Apple Watch"},
},
)
await webhook_client.post(
url,
json={
"type": "register_sensor",
"data": {
"name": "Battery Level",
"state": 87,
"type": "sensor",
"unique_id": "watch_battery",
"device_id": "mock-device-id_watch",
},
},
)
await hass.async_block_till_done()
assert entity_registry.async_get("sensor.test_1_battery_level") is not None
resp = await webhook_client.post(
url,
json={"type": "unregister_device", "data": {"device_id": "mock-device-id_watch"}},
)
assert resp.status == HTTPStatus.OK
assert (
device_registry.async_get_device(
identifiers={(DOMAIN, "mock-device-id_watch")}
)
is None
)
assert entity_registry.async_get("sensor.test_1_battery_level") is None
async def test_webhook_unregister_device_refuses_primary(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
create_registrations: tuple[dict[str, Any], dict[str, Any]],
webhook_client: TestClient,
) -> None:
"""Test that unregister_device refuses to remove the primary device."""
webhook_id = create_registrations[1]["webhook_id"]
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={"type": "unregister_device", "data": {"device_id": "mock-device-id"}},
)
assert resp.status == HTTPStatus.BAD_REQUEST
body = await resp.json()
assert body["error"]["code"] == "invalid_device_id"
assert (
device_registry.async_get_device(identifiers={(DOMAIN, "mock-device-id")})
is not None
)