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

239 lines
9.0 KiB
Python

"""HA fan-entity mapping tests for the range hood."""
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import microwave, range_hood
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-HOOD-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, registry=range_hood.REGISTRY):
bound = discover(
resources,
registry.capabilities,
registry.pattern_capabilities,
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
fan_bound,
)
def _microwave_entity(resources, coordinator=None):
return _entity(resources, coordinator, registry=microwave.REGISTRY)
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
entity = _entity(_load_device("range_hood"))
assert entity.is_on is False
assert entity.speed_count == 4
assert entity.percentage == 0
def test_active_codes_map_to_ordered_percentages():
resources = _load_device("range_hood")
resources["/power/0"]["value"] = True
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "14"
assert _entity(resources).percentage == 25
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "15"
assert _entity(resources).percentage == 50
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "16"
assert _entity(resources).percentage == 75
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "17"
assert _entity(resources).percentage == 100
async def test_power_write_prefers_standard_resource_when_both_exist():
resources = _load_device("range_hood")
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("range_hood")
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")
# ---------------------------------------------------------------------------
# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
# itself the off state.
# ---------------------------------------------------------------------------
def test_standalone_hood_has_separate_power():
"""Explicit converse of the microwave case below: guards the
discriminator itself, not just its downstream effects, so a future
change to it fails loudly here instead of only via behavioral drift."""
entity = _entity(_load_device("range_hood"))
assert entity._has_separate_power() is True
assert entity._speed_zero_is_off() is False
def test_microwave_vent_fan_has_no_separate_power_resource():
resources = _load_device("microwave_me7500d")
assert "/power/0" not in resources
assert "/power/vs/0" not in resources
entity = _microwave_entity(resources)
assert entity._has_separate_power() is False
assert entity._speed_zero_is_off() is True
async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off():
"""A combi over-the-range microwave can report a /power/0 resource for
the cavity while the vent fan still has no power resource of its own
(settableMinFanSpeed '0' -- same board shape as microwave_me7500d).
_speed_zero_is_off must key off the hood resource itself, not merely
"some power resource exists on this device", so turning the fan off
writes fan speed rather than the shared cavity power resource."""
resources = _load_device("microwave_me7500d")
resources["/power/0"] = {"value": True}
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2"
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
assert entity._has_separate_power() is True
assert entity._speed_zero_is_off() is True
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ("speed", "0")
def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
"""supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
standalone hood's codes, which never include an off entry."""
resources = _load_device("microwave_me7500d")
entity = _microwave_entity(resources)
assert entity.is_on is False
assert entity.percentage == 0
assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
def test_microwave_vent_fan_nonzero_speed_reads_as_on():
resources = _load_device("microwave_me7500d")
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2"
entity = _microwave_entity(resources)
assert entity.is_on is True
assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
resources = _load_device("microwave_me7500d")
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2"
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ("speed", "0")
async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
resources = _load_device("microwave_me7500d")
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ("speed", "1")
async def test_microwave_vent_fan_set_percentage_writes_speed_only():
resources = _load_device("microwave_me7500d")
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_set_percentage(100)
assert coordinator.commands == [(entity._bound, ("speed", "4"))]
async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly():
resources = _load_device("microwave_me7500d")
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_on(percentage=75)
assert coordinator.commands == [(entity._bound, ("speed", "3"))]
async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop():
"""A scene or automation calling fan.turn_on on an already-running vent
fan must not reset it to the lowest speed."""
resources = _load_device("microwave_me7500d")
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "3"
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands == []
async def test_microwave_vent_fan_set_percentage_zero_turns_off():
resources = _load_device("microwave_me7500d")
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2"
coordinator = _FakeCoordinator(resources)
entity = _microwave_entity(resources, coordinator)
await entity.async_set_percentage(0)
assert coordinator.commands[-1][1] == ("speed", "0")
def test_microwave_vent_fan_missing_supported_fan_speed_falls_back_to_settable_min_max():
"""Hardware omitting supportedFanSpeed (e.g. ME8000T / issue #172) falls back
to building speed codes from settableMinFanSpeed ('0') and settableMaxFanSpeed ('3')."""
resources = _load_device("microwave_me7500d")
resources["/hood/fanspeed/vs/0"].pop("x.com.samsung.da.hood.supportedFanSpeed", None)
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.settableMinFanSpeed"] = "0"
resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.settableMaxFanSpeed"] = "3"
entity = _microwave_entity(resources)
assert entity._all_speed_codes() == ["0", "1", "2", "3"]
assert entity._active_speed_codes() == ["1", "2", "3"]
assert entity.speed_count == 3
assert entity.supported_features & 1 # FanEntityFeature.SET_SPEED is present