Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's unique_id, the device registry identifiers and every entity unique_id were all minted from that string, the second unit was refused as already configured, and would have collided entity-for-entity even if it hadn't been. This is the third firmware family to ship an unusable serialNum, after `Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first one no heuristic can catch: the value is well formed, it's just shared. `is_placeholder_serial` was a dead end. So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then the serial, then the host. `di` is what the protocol already uses to address the endpoint -- if it were wrong or shared, OCF discovery and the DTLS association wouldn't work at all -- and it's device-scoped, where `pi` is platform-scoped and would be shared by a board hosting several logical devices. Both units in #381 report a distinct `di`. A board that answers neither resource lands exactly where it did before, so no existing hardware regresses. The re-key can't happen in async_migrate_entry: the UUID is only readable from the device, and an entry can load entirely from its snapshot while the appliance is off (#295). So v3 -> v4 only records the legacy key, and the coordinator adopts the UUID on the first live poll, rewriting the entity registry, the device registry (including subdevice identifiers) and the entry's unique_id together. Rewriting rather than recreating is what lets a user keep entity_ids, names, areas, statistics and every automation that references them. Three rules keep that adoption from misfiring: - A poll that reads no UUID never demotes a UUID-keyed entry back onto its serial, so one failed reconnect doesn't re-key every entity. - A changed UUID is followed only when the serial still corroborates it (a factory reset may regenerate `di`) or when the entry was keyed on its IP, which was never an identity to defend. - When the identity is rejected as a different appliance, the serial isn't adopted either -- otherwise the intruder would gain exactly the corroboration needed to win the next poll. Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned per-unit UUIDs, not account data, and blanking them is what made the first #381 diagnostics download unable to answer the only question it was requested to answer. The owner-set device name stays redacted. Fixes #381
This commit is contained in:
@@ -14,6 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -82,6 +83,10 @@ MOCK_PORT = 49154
|
||||
# what mock_coordinator_session polls -- so an entry built from ENTRY_DATA and
|
||||
# the device it "reaches" agree on who they are, the same as in production.
|
||||
MOCK_SERIAL = "TEST-SERIAL-0000"
|
||||
# The OCF device UUID (/oic/d's `di`) the probe resolves the entry's key from
|
||||
# (issue #381). Distinct from MOCK_SERIAL so a test that confuses the two
|
||||
# fails rather than passing by coincidence.
|
||||
MOCK_DEVICE_KEY = "7b1f0c9e-2a44-4d6b-9f10-4c8e2b5a0d31"
|
||||
MOCK_MODEL = "TEST-MODEL"
|
||||
MOCK_DEVICE_TYPE = "refrigerator"
|
||||
MOCK_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
@@ -98,6 +103,7 @@ ENTRY_DATA = {
|
||||
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
||||
# Identity the config flow's probe resolved (issue #236) -- what the
|
||||
# coordinator keys its devices and entities on from construction.
|
||||
CONF_DEVICE_KEY: MOCK_DEVICE_KEY,
|
||||
CONF_SERIAL: MOCK_SERIAL,
|
||||
CONF_MODEL: MOCK_MODEL,
|
||||
CONF_MANUFACTURER: "Samsung",
|
||||
@@ -131,6 +137,7 @@ def fridge_resources():
|
||||
def _probe_result(*, recognized: bool) -> dict:
|
||||
return {
|
||||
"port": MOCK_PORT,
|
||||
"device_key": MOCK_DEVICE_KEY,
|
||||
"serial": MOCK_SERIAL,
|
||||
"model": MOCK_MODEL,
|
||||
"manufacturer": "Samsung",
|
||||
@@ -244,8 +251,8 @@ def mock_entry(hass):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=2,
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
@@ -16,11 +16,13 @@ from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_LEARNED_MODES,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -28,11 +30,13 @@ from .conftest import (
|
||||
ENTRY_DATA,
|
||||
MOCK_CA_CERT_PEM,
|
||||
MOCK_CA_KEY_PEM,
|
||||
MOCK_DEVICE_KEY,
|
||||
MOCK_HOST,
|
||||
MOCK_LEAF_CERT_PEM,
|
||||
MOCK_MODEL,
|
||||
MOCK_PORT,
|
||||
MOCK_SERIAL,
|
||||
_probe_result,
|
||||
)
|
||||
|
||||
|
||||
@@ -1047,7 +1051,11 @@ async def test_unknown_type_step_description_makes_no_version_claim(
|
||||
|
||||
|
||||
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""Second add of same serial: flow aborts.
|
||||
"""Second add of the same *device key*: flow aborts.
|
||||
|
||||
Keyed on the OCF device UUID rather than the serialNum (issue #381), so
|
||||
this is now the check that two genuinely distinct units can no longer
|
||||
trip -- see test_same_serial_on_two_units_is_not_a_duplicate.
|
||||
|
||||
When a device already exists the form only asks for host (CA creds are
|
||||
reused), so we only submit CONF_HOST in the second configure call.
|
||||
@@ -1055,7 +1063,7 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
|
||||
existing = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
)
|
||||
existing.add_to_hass(hass)
|
||||
|
||||
@@ -1069,6 +1077,88 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_same_serial_on_two_units_is_not_a_duplicate(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""Issue #381: two Samsung air purifiers of one model ship the identical,
|
||||
well-formed serialNum, so keying the entry on it turned the second one
|
||||
away as already configured. The OCF device UUID differs between them,
|
||||
and it is what the entry is keyed on now, so both can be added.
|
||||
|
||||
Deliberately holds the serial *constant* across the two probes and varies
|
||||
only the device key -- the exact shape of the bug report.
|
||||
"""
|
||||
first = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_HOST: "192.168.0.3"},
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
)
|
||||
first.add_to_hass(hass)
|
||||
|
||||
second_probe = {
|
||||
**_probe_result(recognized=True),
|
||||
"device_key": "3771f8bf-c184-3a2d-d885-e4c9818736d2",
|
||||
"serial": MOCK_SERIAL,
|
||||
}
|
||||
with patch(
|
||||
"custom_components.localthings.config_flow._probe_and_validate",
|
||||
return_value=second_probe,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.0.14"}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_DEVICE_KEY] == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
# Both entries exist, and the shared serial is still recorded on each --
|
||||
# it is what corroborates a later change of key.
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 2
|
||||
assert result["data"][CONF_SERIAL] == first.data[CONF_SERIAL]
|
||||
|
||||
|
||||
def test_probe_reads_the_device_key_from_oic_d_without_an_extra_round_trip(monkeypatch):
|
||||
"""`_read_device` already fetches /oic/p and /oic/d for the device-type
|
||||
signal, so keying on the OCF UUID costs no additional GET -- it reads
|
||||
the identity that call already returned."""
|
||||
from custom_components.localthings.config_flow import _read_device
|
||||
|
||||
device0 = [
|
||||
{"rt": ["x.com.samsung.devcol"]},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10251941",
|
||||
"x.com.samsung.da.serialNum": "BS7SP9AW400114A",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
self.paths = []
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
self.paths.append(tuple(path))
|
||||
table = {
|
||||
("oic", "p"): {"mnmn": "Samsung Electronics", "pi": "PLATFORM-UUID"},
|
||||
("oic", "d"): {"di": "CCFD73B3-AEB4-792A-1100-68F06F5D603B"},
|
||||
("device", "0"): device0,
|
||||
}
|
||||
body = table.get(tuple(path))
|
||||
if body is None:
|
||||
return 0x84, b""
|
||||
import cbor2
|
||||
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
sess = _Session()
|
||||
info = _read_device(sess, "192.168.0.3", MOCK_PORT)
|
||||
|
||||
assert info["device_key"] == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
assert info["serial"] == "BS7SP9AW400114A"
|
||||
# Exactly the three reads the probe already made before this change.
|
||||
assert sess.paths == [("oic", "p"), ("oic", "d"), ("oic", "res"), ("device", "0")]
|
||||
|
||||
|
||||
def test_probe_marks_washer_as_recognized(monkeypatch):
|
||||
"""A washer reports no oneUiVersion at all -- its consumer-model code
|
||||
must still resolve so setup doesn't warn about an unrecognized type."""
|
||||
|
||||
@@ -33,7 +33,13 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import (
|
||||
ENTRY_DATA,
|
||||
MOCK_DEVICE_KEY,
|
||||
MOCK_MODEL,
|
||||
MOCK_SERIAL,
|
||||
FakeObserveSession,
|
||||
)
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -150,7 +156,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
) -> None:
|
||||
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
|
||||
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
|
||||
two such units get the same device_serial (which feeds both the HA
|
||||
two such units get the same device_key (which feeds both the HA
|
||||
device-registry identifier and every entity's unique_id), so the
|
||||
second one's entities silently collide and get dropped. It must be
|
||||
treated the same as an empty serial and fall back to the host."""
|
||||
@@ -164,7 +170,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
@@ -175,7 +181,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
character the same repeated hex digit. A washer and a dryer, two
|
||||
different physical units, both reported the literal serialNum
|
||||
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
|
||||
device_serial exactly like the #83 case above."""
|
||||
device_key exactly like the #83 case above."""
|
||||
resources = {
|
||||
"/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", # noqa: E501
|
||||
@@ -186,7 +192,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,7 +204,7 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""The coordinator mints registry keys from the entry's stored identity at
|
||||
construction time.
|
||||
|
||||
`device_serial` is what entity unique_ids and device identifiers are built
|
||||
`device_key` is what entity unique_ids and device identifiers are built
|
||||
from, and those are permanent. Seeding it with the host meant anything that
|
||||
registered before the first poll returned -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was written
|
||||
@@ -207,8 +213,8 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)}
|
||||
assert coordinator.device_info["model"] == MOCK_MODEL
|
||||
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
@@ -239,7 +245,7 @@ def test_discovery_keeps_the_registered_identity(hass: HomeAssistant, mock_entry
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
|
||||
|
||||
def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_stale_device_can_be_removed(
|
||||
stale = _device(
|
||||
hass,
|
||||
mock_entry,
|
||||
{(DOMAIN, f"{coordinator.device_serial}_1")},
|
||||
{(DOMAIN, f"{coordinator.device_key}_1")},
|
||||
)
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
|
||||
|
||||
@@ -76,8 +76,11 @@ async def test_diagnostics_include_ocf_identity(
|
||||
# fields identify a device type, so nothing is dropped up front beyond
|
||||
# what redaction takes out.
|
||||
assert identity["resources"]["/oic/p"]["mnmn"] == "Samsung Electronics"
|
||||
assert identity["resources"]["/oic/d"]["di"] == REDACTED
|
||||
assert identity["resources"]["/oic/p"]["pi"] == REDACTED
|
||||
# The OCF UUIDs are reported rather than redacted: they are what this
|
||||
# entry is keyed on (issue #381), and blanking them is what made the
|
||||
# first duplicate-serial report unanswerable.
|
||||
assert identity["resources"]["/oic/d"]["di"] == "ab-cd-ef"
|
||||
assert identity["resources"]["/oic/p"]["pi"] == "12-34-56"
|
||||
# The owner-settable device name is redacted; `rt` -- the reason this
|
||||
# block exists -- is not.
|
||||
assert identity["resources"]["/oic/d"]["n"] == REDACTED
|
||||
@@ -125,9 +128,9 @@ async def test_diagnostics_include_oic_res_links(
|
||||
|
||||
links = diag["identity"]["resources"]["/oic/res"]
|
||||
assert len(links) == 2
|
||||
assert links[0]["di"] == REDACTED
|
||||
assert links[0]["di"] == "aaaa-1111"
|
||||
assert links[0]["href"] == "/device/0"
|
||||
assert links[1]["di"] == REDACTED
|
||||
assert links[1]["di"] == "bbbb-2222"
|
||||
assert links[1]["href"] == "/device/1"
|
||||
assert links[1]["rt"] == ["x.com.samsung.devcol", "oic.wk.col"]
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Config-entry migration and the placeholder-identity repair (issue #236)."""
|
||||
"""Config-entry migration, the placeholder-identity repair (issue #236), and
|
||||
the move onto the OCF device UUID (issue #381)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_HOST,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
@@ -39,36 +43,37 @@ async def test_migration_recovers_serial_from_unique_id(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Straight through to the current version: v2 -> v3 is a statistics
|
||||
# relabel that no-ops for a family without particulate sensors.
|
||||
assert entry.version == 3
|
||||
# relabel that no-ops for a family without particulate sensors, and
|
||||
# v3 -> v4 only records the legacy key for the coordinator to re-key
|
||||
# from once a poll produces an OCF device id.
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
|
||||
|
||||
async def test_migration_collapses_the_host_port_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_collapses_the_host_port_unique_id(hass: HomeAssistant) -> None:
|
||||
"""A board with no usable serial (issues #83/#189) used to be keyed two
|
||||
different ways at once: `host:port` on the config entry, `host` in the
|
||||
device and entity registries. Migration collapses the entry onto the
|
||||
registry's form, so the two finally name the same thing."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}:{MOCK_PORT}")
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
|
||||
|
||||
async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_resolves_a_placeholder_serial_unique_id(hass: HomeAssistant) -> None:
|
||||
"""An entry created before the placeholder rules landed was keyed on the
|
||||
placeholder itself (issues #83/#189), while the coordinator has been
|
||||
resolving those boards to the host ever since. The unique_id records what
|
||||
the flow believed then, not what the registry holds -- taking it at face
|
||||
value would re-key working devices back onto a string every unit of the
|
||||
family reports, which is the collision those issues are about."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_Nothing(SVC)")
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
@@ -76,8 +81,7 @@ async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
@@ -86,14 +90,13 @@ async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
|
||||
|
||||
|
||||
async def test_migration_resolves_an_all_hex_placeholder_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_resolves_an_all_hex_placeholder_unique_id(hass: HomeAssistant) -> None:
|
||||
"""The issue #189 flash-unset sentinel, same reasoning."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_FFFFFFFFFFFFFFF")
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
|
||||
@@ -231,13 +234,13 @@ async def test_migration_removes_an_orphan_that_is_already_duplicated(
|
||||
assert dev_reg.async_get(real_device.id) is not None
|
||||
|
||||
|
||||
async def test_migration_leaves_a_host_identity_device_alone(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_leaves_a_host_identity_device_alone(hass: HomeAssistant) -> None:
|
||||
"""A board whose serial resolves *to* the host was never keyed on a
|
||||
placeholder -- its host-keyed device is the real one, and re-keying or
|
||||
removing it would orphan a working device to fix a problem it doesn't
|
||||
have."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}")
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
@@ -245,8 +248,7 @@ async def test_migration_leaves_a_host_identity_device_alone(
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
unchanged = dev_reg.async_get(device.id)
|
||||
@@ -259,7 +261,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) ->
|
||||
written by a newer release."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4)
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=5)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_migrate_entry(hass, entry) is False
|
||||
@@ -277,3 +279,312 @@ async def test_migration_without_a_unique_id_falls_back_to_host(hass: HomeAssist
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST]
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3 -> v4: onto the OCF device UUID (issue #381)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The two purifiers from issue #381: one serialNum, two device UUIDs.
|
||||
SHARED_SERIAL = "BS7SP9AW400114A"
|
||||
UUID_A = "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
UUID_B = "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
|
||||
|
||||
def _identity_reporting(device_id: str | None):
|
||||
"""A patched _connect_session that hands the coordinator the identity a
|
||||
real DTLS connect would have read off /oic/p and /oic/d."""
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
def _connect(self):
|
||||
self._identity = (
|
||||
None
|
||||
if device_id is None
|
||||
else DeviceIdentity(
|
||||
manufacturer="Samsung Electronics",
|
||||
model="AVT-WW-TP1-23-AXX500",
|
||||
name="Samsung AirPurifier",
|
||||
serial=None,
|
||||
device_id=device_id,
|
||||
)
|
||||
)
|
||||
|
||||
return patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session",
|
||||
_connect,
|
||||
)
|
||||
|
||||
|
||||
def _v3_entry(hass: HomeAssistant, key: str, *, serial: str | None = None) -> MockConfigEntry:
|
||||
"""An entry as it sits on disk before this release: keyed on CONF_SERIAL,
|
||||
with no CONF_DEVICE_KEY."""
|
||||
data = {**LEGACY_ENTRY_DATA, CONF_SERIAL: serial if serial is not None else key}
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=f"{DOMAIN}_{key}", version=3)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_v3_entry_moves_onto_the_device_uuid_keeping_its_entity_ids(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The whole migration promise for an existing user, asserted end to end.
|
||||
|
||||
The re-key happens on the first live poll rather than in
|
||||
async_migrate_entry, because the UUID is only readable from the device
|
||||
and an entry can load entirely from its snapshot while the appliance is
|
||||
off (issue #295). What the user must keep across it: the entity_id (and
|
||||
with it the entity's name, area, long-term statistics, and every
|
||||
automation and dashboard that references it), and the device row.
|
||||
"""
|
||||
entry = _v3_entry(hass, MOCK_SERIAL)
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, MOCK_SERIAL)},
|
||||
)
|
||||
existing = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode",
|
||||
config_entry=entry,
|
||||
device_id=device.id,
|
||||
suggested_object_id="kitchen_purifier_connection",
|
||||
)
|
||||
|
||||
with (
|
||||
_identity_reporting(UUID_A),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
return_value=fridge_resources,
|
||||
),
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
# The serial is kept alongside the key, not replaced by it: it is what
|
||||
# corroborates a later change of UUID.
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
# All three permanent places moved together.
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
rekeyed_device = dev_reg.async_get(device.id)
|
||||
assert rekeyed_device is not None
|
||||
assert rekeyed_device.identifiers == {(DOMAIN, UUID_A)}
|
||||
kept = ent_reg.async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.kitchen_purifier_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# And nothing is left behind on the old key.
|
||||
assert dev_reg.async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) is None
|
||||
|
||||
|
||||
async def test_a_host_keyed_entry_adopts_a_real_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A placeholder-serial board (issues #83/#189) was keyed on its IP, which
|
||||
is an address rather than an identity -- a new DHCP lease silently makes
|
||||
it someone else's. Such an entry never made an identity claim to defend,
|
||||
so a real UUID is adopted without needing the serial to corroborate it;
|
||||
requiring corroboration would strand exactly these boards, since their
|
||||
serial resolves to the host and can never match."""
|
||||
entry = _v3_entry(hass, MOCK_HOST)
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
|
||||
with (
|
||||
_identity_reporting(UUID_B),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
return_value=fridge_resources,
|
||||
),
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
rekeyed = dev_reg.async_get(device.id)
|
||||
assert rekeyed is not None
|
||||
assert rekeyed.identifiers == {(DOMAIN, UUID_B)}
|
||||
|
||||
|
||||
async def test_a_poll_that_reads_no_uuid_does_not_demote_a_keyed_entry(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The device saying nothing is not the device saying something different.
|
||||
|
||||
A reconnect that can't read /oic/d (a timeout, a firmware hiccup) must
|
||||
leave the key alone -- demoting back onto the serial would re-key every
|
||||
entity the user has for the duration of an outage, and re-key them all
|
||||
back afterwards."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**LEGACY_ENTRY_DATA, CONF_SERIAL: MOCK_SERIAL, CONF_DEVICE_KEY: UUID_A},
|
||||
unique_id=f"{DOMAIN}_{UUID_A}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
_identity_reporting(None),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
return_value=fridge_resources,
|
||||
),
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
|
||||
|
||||
async def test_a_rotated_uuid_on_the_same_serial_is_followed(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""OCF permits a hard factory reset to regenerate `di`. The serialNum is
|
||||
what tells that apart from a different appliance moving onto the address,
|
||||
and following it keeps the user's history rather than stranding it on a
|
||||
UUID the device will never report again."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**LEGACY_ENTRY_DATA, CONF_SERIAL: MOCK_SERIAL, CONF_DEVICE_KEY: UUID_A},
|
||||
unique_id=f"{DOMAIN}_{UUID_A}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, UUID_A)},
|
||||
)
|
||||
|
||||
# fridge_resources reports MOCK_SERIAL, matching what the entry stored.
|
||||
with (
|
||||
_identity_reporting(UUID_B),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
return_value=fridge_resources,
|
||||
),
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
rekeyed = dev_reg.async_get(device.id)
|
||||
assert rekeyed is not None
|
||||
assert rekeyed.identifiers == {(DOMAIN, UUID_B)}
|
||||
|
||||
|
||||
async def test_a_different_appliance_on_the_same_address_keeps_the_registered_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Neither the UUID nor the serial matches what this entry was registered
|
||||
with, so this is a different appliance answering at this address -- not a
|
||||
reset of the registered one. Re-keying here would hand one appliance's
|
||||
entities, history and automations to another; re-adding is the user's
|
||||
call."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
**LEGACY_ENTRY_DATA,
|
||||
CONF_SERIAL: "SOME-OTHER-APPLIANCE",
|
||||
CONF_DEVICE_KEY: UUID_A,
|
||||
},
|
||||
unique_id=f"{DOMAIN}_{UUID_A}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, UUID_A)},
|
||||
)
|
||||
|
||||
with (
|
||||
_identity_reporting(UUID_B),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
return_value=fridge_resources,
|
||||
),
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
unchanged = dev_reg.async_get(device.id)
|
||||
assert unchanged is not None
|
||||
assert unchanged.identifiers == {(DOMAIN, UUID_A)}
|
||||
# The rejected appliance's serial is not written either. The serial is
|
||||
# what corroborates a later change of key, so adopting it here would
|
||||
# hand the intruder exactly the corroboration it needs to win the *next*
|
||||
# poll -- defending the identity once and then surrendering it on the
|
||||
# following cycle.
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
|
||||
async def test_rekey_moves_subdevice_identifiers_too(hass: HomeAssistant) -> None:
|
||||
"""A composite appliance (issue #177) registers one device per logical
|
||||
subdevice, keyed f"{key}_{subdevice}" and linked via_device to the
|
||||
master's bare key. Rewriting only the exact-match identifier would strand
|
||||
every sibling, so the prefix form moves with it.
|
||||
|
||||
Calls rekey_entry directly: what it leaves behind is the contract, and
|
||||
going through a setup would let the platforms re-adding their entities
|
||||
hide a row that had in fact been orphaned.
|
||||
"""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _v3_entry(hass, MOCK_SERIAL)
|
||||
dev_reg = dr.async_get(hass)
|
||||
master = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, MOCK_SERIAL)},
|
||||
)
|
||||
sub = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"{MOCK_SERIAL}_subdevice_1")},
|
||||
)
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
assert dev_reg.async_get(master.id).identifiers == {(DOMAIN, UUID_A)}
|
||||
assert dev_reg.async_get(sub.id).identifiers == {(DOMAIN, f"{UUID_A}_subdevice_1")}
|
||||
|
||||
|
||||
async def test_rekey_is_idempotent(hass: HomeAssistant) -> None:
|
||||
"""Safe to attempt on every poll rather than having to track whether it
|
||||
has already run -- a second call finds nothing under the old key."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _v3_entry(hass, MOCK_SERIAL)
|
||||
ent_reg = er.async_get(hass)
|
||||
existing = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", config_entry=entry
|
||||
)
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
kept = ent_reg.async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
|
||||
@@ -72,7 +72,7 @@ async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
|
||||
# µg/m³ has a converter, so the class must be named, not None --
|
||||
# passing neither is deprecated and breaks in HA Core 2026.11.
|
||||
assert call.kwargs["new_unit_class"] == "concentration"
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
|
||||
@@ -102,7 +102,7 @@ async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) ->
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert relabel.call_args_list == [], device_type
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
|
||||
@@ -127,7 +127,7 @@ async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert len(relabel.call_args_list) == 1
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
|
||||
@@ -151,7 +151,7 @@ async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant)
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
|
||||
@@ -165,7 +165,7 @@ async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> N
|
||||
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
|
||||
@@ -214,8 +214,16 @@ async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant
|
||||
|
||||
|
||||
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
|
||||
"""A newly created entry has no statistics to relabel, so the config flow
|
||||
mints v3 directly rather than walking through the migration."""
|
||||
"""A newly created entry has nothing either migration step needs to do --
|
||||
no statistics to relabel, and the probe already resolved its device key
|
||||
(issue #381) -- so the config flow mints the current version directly
|
||||
rather than walking through them.
|
||||
|
||||
Pinned rather than compared to a constant on purpose: the two must be
|
||||
bumped together, and a migration step added without moving the flow's
|
||||
VERSION never runs at all, because Home Assistant only calls
|
||||
async_migrate_entry for an entry *behind* the flow's version.
|
||||
"""
|
||||
from custom_components.localthings.config_flow import LocalThingsConfigFlow
|
||||
|
||||
assert LocalThingsConfigFlow.VERSION == 3
|
||||
assert LocalThingsConfigFlow.VERSION == 4
|
||||
|
||||
@@ -12,7 +12,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-AIRFLOW-SERIAL"
|
||||
device_key = "TEST-AIRFLOW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-VTWW-SERIAL"
|
||||
device_key = "TEST-VTWW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ MODEL = "ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000"
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-KRAC-SERIAL"
|
||||
device_key = "TEST-KRAC-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ FIXTURE = "airconditioner_tp1x_rac_01001"
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-RAC-01001-SERIAL"
|
||||
device_key = "TEST-RAC-01001-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-FAC-BORA-SERIAL"
|
||||
device_key = "TEST-FAC-BORA-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, last_resources=None):
|
||||
self.last_resources = last_resources or {}
|
||||
|
||||
@@ -463,7 +463,7 @@ class TestKimchiZone:
|
||||
)
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, resources, data):
|
||||
self.last_resources = resources
|
||||
|
||||
+117
-1
@@ -1,6 +1,12 @@
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry.identity import read_identity
|
||||
from custom_components.localthings.registry.identity import (
|
||||
DeviceIdentity,
|
||||
is_usable_device_id,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -124,3 +130,113 @@ def test_read_identity_tolerates_malformed_oic_res():
|
||||
_device_types' handling of a malformed /oic/d rt."""
|
||||
ident = read_identity(FakeSession({("oic", "res"): {"not": "a list"}}), serial=None)
|
||||
assert ident.raw["/oic/res"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The device key: which identity field registry keys are minted from (#381)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _identity(**kwargs) -> DeviceIdentity:
|
||||
base = {"manufacturer": "Samsung", "model": "M", "name": "N", "serial": None}
|
||||
return DeviceIdentity(**{**base, **kwargs})
|
||||
|
||||
|
||||
def test_read_identity_captures_the_ocf_uuids_as_named_fields():
|
||||
"""`di`/`pi` are what resolve_device_key mints keys from, so they are
|
||||
lifted out of `raw` rather than dug back out of it at every call site."""
|
||||
sess = FakeSession(
|
||||
{
|
||||
("oic", "p"): {"pi": "ccfd73b3-aeb4-792a-1100-68f06f5d603b"},
|
||||
("oic", "d"): {"di": "3771f8bf-c184-3a2d-d885-e4c9818736d2"},
|
||||
}
|
||||
)
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.device_id == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
assert ident.platform_id == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_read_identity_ignores_non_string_uuids():
|
||||
"""Firmware answering with a number or a map must not put a non-string
|
||||
into a field that goes on to be string-formatted into a unique_id."""
|
||||
ident = read_identity(FakeSession({("oic", "d"): {"di": 42}, ("oic", "p"): {"pi": {}}}), None)
|
||||
assert ident.device_id is None
|
||||
assert ident.platform_id is None
|
||||
|
||||
|
||||
def test_two_units_sharing_a_serial_get_distinct_keys():
|
||||
"""Issue #381 exactly: two Samsung air purifiers of the same model ship
|
||||
the identical, well-formed serialNum 'BS7SP9AW400114A', so keying on it
|
||||
collapsed them onto one identity and the second was refused as already
|
||||
configured. Their `di` differs, which is what makes them separable."""
|
||||
shared_serial = "BS7SP9AW400114A"
|
||||
first = resolve_device_key(
|
||||
_identity(device_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b"), shared_serial, "192.168.0.3"
|
||||
)
|
||||
second = resolve_device_key(
|
||||
_identity(device_id="3771f8bf-c184-3a2d-d885-e4c9818736d2"), shared_serial, "192.168.0.14"
|
||||
)
|
||||
assert first != second
|
||||
assert shared_serial not in (first, second)
|
||||
|
||||
|
||||
def test_platform_id_is_the_fallback_when_oic_d_is_unreadable():
|
||||
ident = _identity(device_id=None, platform_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
)
|
||||
|
||||
|
||||
def test_device_id_wins_over_platform_id():
|
||||
"""`pi` is platform-scoped, so a board hosting more than one logical OCF
|
||||
device shares it -- the collision this exists to prevent."""
|
||||
ident = _identity(device_id="dddddddd-0000-1111-2222-333333333333", platform_id="shared-plat")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"dddddddd-0000-1111-2222-333333333333"
|
||||
)
|
||||
|
||||
|
||||
def test_falls_back_to_the_serial_then_the_host():
|
||||
"""A board that answers neither OCF resource lands exactly where it did
|
||||
before any of this existed -- no regression for existing hardware."""
|
||||
assert resolve_device_key(None, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert resolve_device_key(_identity(), "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
# ...and a placeholder serial still resolves to the host (#83/#189).
|
||||
assert resolve_device_key(_identity(), "Nothing(SVC)", "10.0.0.1") == "10.0.0.1"
|
||||
|
||||
|
||||
def test_the_key_is_case_normalized():
|
||||
"""The stored key is compared against a freshly polled one on every
|
||||
poll; firmware that changed case between reads would otherwise look
|
||||
like a different appliance every time."""
|
||||
ident = _identity(device_id="CCFD73B3-AEB4-792A-1100-68F06F5D603B")
|
||||
assert resolve_device_key(ident, None, "10.0.0.1") == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_the_nil_uuid_is_not_an_identity():
|
||||
"""OCF's unset UUID is identical on every unit that never had one
|
||||
assigned -- the #189 failure mode on a new field. Its dashes stop
|
||||
is_placeholder_serial's repeated-digit rule from seeing it, so it needs
|
||||
its own check; falling through to the serial is the right answer."""
|
||||
ident = _identity(device_id="00000000-0000-0000-0000-000000000000")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert not is_usable_device_id("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
|
||||
def test_known_junk_disqualifies_a_uuid_the_same_way_it_does_a_serial():
|
||||
"""A board firmware-flashed with 'Nothing(SVC)' in one identity field is
|
||||
not a board to trust in another."""
|
||||
assert not is_usable_device_id("Nothing(SVC)")
|
||||
assert not is_usable_device_id("FFFFFFFFFFFFFFF")
|
||||
assert not is_usable_device_id("")
|
||||
assert not is_usable_device_id(None)
|
||||
assert is_usable_device_id("3771f8bf-c184-3a2d-d885-e4c9818736d2")
|
||||
|
||||
|
||||
def test_ocf_device_key_reports_absence_rather_than_collapsing_to_the_serial():
|
||||
"""The coordinator needs "the device said nothing" and "the device said
|
||||
this" to be different answers, so it never demotes a UUID-keyed entry
|
||||
onto a serial because one poll couldn't read /oic/d."""
|
||||
assert ocf_device_key(None) is None
|
||||
assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None
|
||||
assert ocf_device_key(_identity(device_id="abc-123")) == "abc-123"
|
||||
|
||||
@@ -11,7 +11,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-HOOD-SERIAL"
|
||||
device_key = "TEST-HOOD-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
+15
-8
@@ -75,9 +75,18 @@ def test_redact_resources_does_not_mutate_input():
|
||||
assert resources["/information/vs/0"]["x.com.samsung.da.serialNum"] == original_serial
|
||||
|
||||
|
||||
def test_redacts_bare_ocf_identity_keys():
|
||||
"""/oic/d and /oic/p identify the unit with two-letter keys ('di', 'pi')
|
||||
that the substring rules can't see."""
|
||||
def test_keeps_ocf_identity_uuids_but_redacts_the_owner_set_name():
|
||||
"""/oic/d's `di` and /oic/p's `pi` survive redaction.
|
||||
|
||||
They are randomly-assigned per-unit UUIDs, not account data, and they
|
||||
are what the entry's registry keys are minted from (issue #381) -- a
|
||||
report that blanks them hides the identity every entity in it is named
|
||||
after, and can't answer the one question a duplicate-serial report
|
||||
exists to ask: whether two units differ here at all.
|
||||
|
||||
`n` is the opposite case and stays redacted: free text the owner sets
|
||||
from the SmartThings app, so it can carry a person's name.
|
||||
"""
|
||||
redacted = redact_resources(
|
||||
{
|
||||
"/oic/d": {
|
||||
@@ -89,12 +98,10 @@ def test_redacts_bare_ocf_identity_keys():
|
||||
}
|
||||
)
|
||||
|
||||
assert redacted["/oic/d"]["di"] == REDACTED
|
||||
assert redacted["/oic/p"]["pi"] == REDACTED
|
||||
# 'n' is free text the owner sets from the SmartThings app, so it can
|
||||
# carry a person's name -- redacted too. `rt`, the device-type signal
|
||||
# we actually want out of /oic/d, is not.
|
||||
assert redacted["/oic/d"]["di"] == "ab-cd-ef"
|
||||
assert redacted["/oic/p"]["pi"] == "12-34-56"
|
||||
assert redacted["/oic/d"]["n"] == REDACTED
|
||||
# `rt`, the device-type signal we actually want out of /oic/d, is kept.
|
||||
assert redacted["/oic/d"]["rt"] == ["oic.wk.d", "oic.d.refrigerator"]
|
||||
assert redacted["/oic/p"]["mnmo"] == "RF9000B"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from custom_components.localthings.select import LocalThingsSelect
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
@@ -46,7 +46,7 @@ class _FakeConfigEntry:
|
||||
|
||||
class _FakeCoordinator:
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class _FakeCoordinator:
|
||||
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
|
||||
|
||||
def __init__(self, threshold_minutes):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry(
|
||||
{
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
|
||||
|
||||
@@ -50,7 +50,7 @@ class _FakeCoordinator:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeA
|
||||
sub1 = next(su for su in coordinator.subdevices if su.key == "1")
|
||||
info = coordinator.device_info_for(sub1)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
@@ -245,7 +245,7 @@ async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
subdevice = coordinator.subdevices[0]
|
||||
info = coordinator.device_info_for(subdevice)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_{_SUB_UUID}")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
@@ -266,7 +266,7 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
||||
entity = LocalThingsEntity(coordinator, sub_climate)
|
||||
expected_slug = _SUB_UUID.replace("-", "")
|
||||
assert entity._attr_unique_id == (
|
||||
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
|
||||
f"{DOMAIN}_{coordinator.device_key}_subdevice_{expected_slug}_climate"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-EHS-SERIAL"
|
||||
device_key = "TEST-EHS-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user