Files
localthings/tests/test_sensor_hysteresis.py
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

112 lines
3.7 KiB
Python

"""Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import cast
from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.sensor import LocalThingsSensor
_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time")
class _FakeConfigEntry:
def __init__(self, options):
self.options = options
class _FakeCoordinator:
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
def __init__(self, threshold_minutes):
self.device_key = "TEST-SERIAL"
self.config_entry = _FakeConfigEntry(
{
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
}
)
self.data: dict = {}
def _sensor(threshold_minutes=3):
coordinator = _FakeCoordinator(threshold_minutes)
href = OPERATIONAL_STATE.href
assert href is not None
bound = BoundEntity(
href=href,
capability=OPERATIONAL_STATE,
desc=_FINISH_TIME_DESC,
)
sensor = LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound)
return sensor, coordinator
def test_small_change_is_suppressed():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC)
coordinator.data = {"finish_time": base}
assert sensor.native_value == base
coordinator.data = {"finish_time": base + timedelta(minutes=1)}
assert sensor.native_value == base, "a 1-minute wobble should be held back"
def test_change_past_threshold_is_reported():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC)
coordinator.data = {"finish_time": base}
assert sensor.native_value == base
new = base + timedelta(minutes=5)
coordinator.data = {"finish_time": new}
assert sensor.native_value == new
def test_zero_threshold_disables_hysteresis():
sensor, coordinator = _sensor(threshold_minutes=0)
base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC)
coordinator.data = {"finish_time": base}
assert sensor.native_value == base
new = base + timedelta(seconds=1)
coordinator.data = {"finish_time": new}
assert sensor.native_value == new
def test_cycle_end_passes_through_immediately():
"""A cycle ending (finish_time -> None) must never be held back."""
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC)
coordinator.data = {"finish_time": base}
assert sensor.native_value == base
coordinator.data = {"finish_time": None}
assert sensor.native_value is None
def test_non_hysteresis_sensor_is_unaffected():
"""A SensorDesc without hysteresis=True reads straight through, unchanged."""
machine_state_desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state")
coordinator = _FakeCoordinator(threshold_minutes=3)
href = OPERATIONAL_STATE.href
assert href is not None
bound = BoundEntity(
href=href,
capability=OPERATIONAL_STATE,
desc=machine_state_desc,
)
sensor = LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound)
coordinator.data = {"machine_state": "active"}
assert sensor.native_value == "active"
coordinator.data = {"machine_state": "idle"}
assert sensor.native_value == "idle"