Files
localthings/tests/test_sensor_enum_options.py
T
Marc Billow 81dbc6fa03 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
2026-08-17 05:24:22 +00:00

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