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
60 lines
2.3 KiB
Python
60 lines
2.3 KiB
Python
"""Tests for translated entity naming and dynamic instance placeholders."""
|
|
|
|
from typing import cast
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.entity import LocalThingsEntity
|
|
from custom_components.localthings.registry.capability import Capability
|
|
from custom_components.localthings.registry.discovery import BoundEntity
|
|
from custom_components.localthings.registry.entities import BinarySensorDesc
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_key = "TEST-SERIAL"
|
|
|
|
def __init__(self, last_resources=None):
|
|
self.last_resources = last_resources or {}
|
|
|
|
|
|
def _make_entity(desc, href="/x/vs/0", key_override=None, instance="", instance_name=None):
|
|
capability = Capability(href=href, entities=(desc,))
|
|
bound = BoundEntity(
|
|
href=href,
|
|
capability=capability,
|
|
desc=desc,
|
|
instance=instance,
|
|
key_override=key_override,
|
|
instance_name=instance_name,
|
|
)
|
|
return LocalThingsEntity(cast(LocalThingsCoordinator, _FakeCoordinator()), bound)
|
|
|
|
|
|
def test_descriptor_key_is_the_default_translation_key():
|
|
"""A descriptor names itself through the catalog, keyed by its own key.
|
|
|
|
Nothing sets _attr_name: that would take precedence over HA's
|
|
translation catalog and make the entity untranslatable.
|
|
"""
|
|
desc = BinarySensorDesc(key="enabled")
|
|
entity = _make_entity(desc, instance_name="Cubed Ice")
|
|
assert entity.translation_key == "enabled"
|
|
assert not hasattr(entity, "_attr_name")
|
|
|
|
|
|
def test_device_instance_name_becomes_translation_placeholder():
|
|
desc = BinarySensorDesc(
|
|
key="enabled", translation_key="instance_enabled", use_instance_name=True
|
|
)
|
|
entity = _make_entity(desc, key_override="icemaker_one_enabled", instance_name="Cubed Ice")
|
|
assert entity.translation_key == "instance_enabled"
|
|
assert entity.translation_placeholders == {"instance_name": "Cubed Ice"}
|
|
assert not hasattr(entity, "_attr_name")
|
|
|
|
|
|
def test_href_instance_name_becomes_translation_placeholder():
|
|
desc = BinarySensorDesc(
|
|
key="enabled", translation_key="instance_enabled", use_instance_name=True
|
|
)
|
|
entity = _make_entity(desc, key_override="icemaker_one_enabled")
|
|
assert entity.translation_placeholders == {"instance_name": "Icemaker One"}
|