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
239 lines
9.0 KiB
Python
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
|