mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 07:25:52 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user