From 1a69130e469fe215130457ea22dcf4552a8141f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 21:26:04 +0000 Subject: [PATCH] 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. --- .../components/mobile_app/binary_sensor.py | 13 +- homeassistant/components/mobile_app/const.py | 2 + homeassistant/components/mobile_app/entity.py | 3 + homeassistant/components/mobile_app/sensor.py | 18 +- homeassistant/components/mobile_app/util.py | 24 ++ .../components/mobile_app/webhook.py | 148 ++++++++ tests/components/mobile_app/test_init.py | 47 +++ tests/components/mobile_app/test_webhook.py | 354 ++++++++++++++++++ 8 files changed, 605 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 5a203ee47983..cca6e286ded6 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -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)) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598bd..01a78843cb75 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -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" diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 84527a528c07..dd8433a3790e 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -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 diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 65770b99aad6..abdb65445f83 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -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) diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index 3c52e858a396..126f02cb8203 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -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.""" diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 232c4c50c6c3..4a4f6fa0b55d 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -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( diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index a67ed39b7603..6bc86a5bdfd3 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -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, diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index b7a247bc9736..f376d85c702e 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -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 + )