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
116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
"""An enum sensor's reported state must always be inside its options.
|
|
|
|
Home Assistant raises for an enum sensor whose state isn't in `options`
|
|
(sensor/__init__.py: "provides state value ... which is not in the list of
|
|
options provided"), so a value outside the list isn't a cosmetic problem --
|
|
it takes the entity out.
|
|
|
|
Two ways that bites, both from PR #341 giving `progress` a `device_class`
|
|
of enum:
|
|
|
|
- the sticky hold (issue #345) froze the entity at the device's raw
|
|
'Finish' while `rep_fn` had been normalized to 'finish', so every
|
|
completed cycle -- the exact path #345 exists to serve -- produced a
|
|
state outside the options;
|
|
- any progress value not in the translation catalog. Every token the
|
|
shipped fixtures advertise is covered today, but this registry's rule is
|
|
that an unrecognized device value renders raw rather than breaking, and
|
|
Samsung ships more devices than we have dumps for.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import cast
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
|
from custom_components.localthings.registry.discovery import BoundEntity
|
|
from custom_components.localthings.registry.entities import SensorDesc
|
|
from custom_components.localthings.sensor import LocalThingsSensor
|
|
|
|
_HREF = "/operational/state/vs/0"
|
|
_PROGRESS = next(
|
|
e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc)
|
|
)
|
|
_ALL_BOUND = [
|
|
BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
|
for desc in OPERATIONAL_STATE.entities
|
|
]
|
|
|
|
|
|
class _FakeConfigEntry:
|
|
def __init__(self):
|
|
self.options: dict = {}
|
|
|
|
|
|
class _FakeCoordinator:
|
|
def __init__(self):
|
|
self.device_key = "TEST-SERIAL"
|
|
self.config_entry = _FakeConfigEntry()
|
|
self.resources: dict[str, dict] = {}
|
|
|
|
def resource(self, href: str) -> dict:
|
|
return self.resources.get(href) or {}
|
|
|
|
@property
|
|
def data(self) -> dict:
|
|
return flatten(_ALL_BOUND, self.resources)
|
|
|
|
|
|
def _sensor(desc):
|
|
coordinator = _FakeCoordinator()
|
|
bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
|
return LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound), coordinator
|
|
|
|
|
|
def _set(coordinator, **fields):
|
|
coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
|
|
|
|
|
def test_the_sticky_hold_freezes_at_a_value_inside_the_options():
|
|
"""Issue #345's grace window fires on every finished cycle, so a held
|
|
value outside the options would break the common path, not an edge."""
|
|
sensor, coordinator = _sensor(_PROGRESS)
|
|
|
|
_set(coordinator, state="Run", progress="Wash")
|
|
assert sensor.native_value == "wash"
|
|
|
|
# Cycle finishes, then the device drops out of active -- the hold engages.
|
|
_set(coordinator, state="Run", progress="Finish")
|
|
assert sensor.native_value in sensor.options
|
|
_set(coordinator, state="Ready", progress="Finish")
|
|
held = sensor.native_value
|
|
assert held == "finish"
|
|
assert held in sensor.options
|
|
|
|
|
|
def test_a_progress_value_we_cannot_translate_still_reports():
|
|
"""An unrecognized device value renders raw rather than taking the
|
|
entity out -- the same rule the course tables follow."""
|
|
sensor, coordinator = _sensor(_PROGRESS)
|
|
|
|
_set(coordinator, state="Run", progress="SomeFutureStage")
|
|
value = sensor.native_value
|
|
assert value == "somefuturestage"
|
|
assert value in sensor.options
|
|
# ...and admitting it doesn't drop the translated ones.
|
|
assert "rinse" in sensor.options
|
|
|
|
|
|
def test_known_values_do_not_grow_the_options_list():
|
|
assert _PROGRESS.options is not None
|
|
sensor, coordinator = _sensor(_PROGRESS)
|
|
|
|
_set(coordinator, state="Run", progress="Rinse")
|
|
assert sensor.options == list(_PROGRESS.options)
|
|
|
|
|
|
def test_a_non_enum_sensor_has_no_options():
|
|
percentage = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage")
|
|
sensor, coordinator = _sensor(percentage)
|
|
|
|
_set(coordinator, state="Run", progressPercentage="40")
|
|
assert sensor.options is None
|
|
assert sensor.native_value == 40
|