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

115 lines
4.0 KiB
Python

"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56)."""
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.fan import LocalThingsAirflowFan
from custom_components.localthings.registry.by_type import air_purifier
from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_key = "TEST-AIRFLOW-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _entity(resources, coordinator=None):
bound = discover(
resources,
air_purifier.REGISTRY.capabilities,
air_purifier.REGISTRY.pattern_capabilities,
)
fan_bound = next(
item for item in bound if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
)
return LocalThingsAirflowFan(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
fan_bound,
)
def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
entity = _entity(_load_device("air_purifier"))
assert entity.is_on is False
assert entity.speed_count == 5
assert entity.percentage == 0
def test_active_codes_map_to_ordered_percentages():
"""Confirmed via issue #56's second, properly-spaced diagnostics round:
/airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High."""
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)):
resources["/airflow/0"]["speed"] = code
assert _entity(resources).percentage == expected_percentage
async def test_power_write_prefers_standard_resource_when_both_exist():
"""Both /power/0 and /power/vs/0 are present on this family's dumps --
/power/0 must win, matching common.POWER_GENERIC's own preference (the
power_switch entity is unconditionally bound to /power/0 whenever it's
present, so writing here to /power/vs/0 first would leave the two
entities disagreeing until the next poll)."""
resources = _load_device("air_purifier")
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ("power", True, "/power/0")
async def test_power_write_falls_back_to_vendor_resource():
resources = _load_device("air_purifier")
resources.pop("/power/0")
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ("power", False, "/power/vs/0")
async def test_set_percentage_writes_raw_speed_code():
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(60)
assert coordinator.commands[-1][1] == ("speed", 2)
async def test_set_percentage_zero_turns_off():
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(0)
assert coordinator.commands[-1][1] == ("power", False, "/power/0")