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
223 lines
7.7 KiB
Python
223 lines
7.7 KiB
Python
"""HA water_heater-entity mapping tests for the EHS DHW loop."""
|
|
|
|
from typing import ClassVar, cast
|
|
|
|
from homeassistant.components.water_heater import (
|
|
STATE_ECO,
|
|
STATE_HEAT_PUMP,
|
|
STATE_HIGH_DEMAND,
|
|
STATE_PERFORMANCE,
|
|
WaterHeaterEntity,
|
|
)
|
|
from homeassistant.const import STATE_OFF, UnitOfTemperature
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.registry.by_type import ehs
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import WaterHeaterDesc
|
|
from custom_components.localthings.water_heater import LocalThingsWaterHeater
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_key = "TEST-EHS-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,
|
|
ehs.REGISTRY.capabilities,
|
|
ehs.REGISTRY.pattern_capabilities,
|
|
)
|
|
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc))
|
|
return LocalThingsWaterHeater(
|
|
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), water_heater_bound
|
|
)
|
|
|
|
|
|
def test_current_operation_reads_eco_when_on():
|
|
entity = _entity(_load_device("ehs"))
|
|
assert entity.current_operation == STATE_ECO
|
|
|
|
|
|
def test_current_operation_is_off_when_powered_off():
|
|
resources = _load_device("ehs")
|
|
resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off"
|
|
entity = _entity(resources)
|
|
assert entity.current_operation == STATE_OFF
|
|
|
|
|
|
def test_operation_list_includes_off_and_mapped_modes():
|
|
"""Fixture's supportedModes is Eco/Std/Power/Force -- all four map onto
|
|
HA's own standard water_heater states (see water_heater.py's module
|
|
docstring for the SmartThings-cloud precedent this mirrors)."""
|
|
entity = _entity(_load_device("ehs"))
|
|
assert entity.operation_list == [
|
|
STATE_OFF,
|
|
STATE_ECO,
|
|
STATE_HEAT_PUMP,
|
|
STATE_PERFORMANCE,
|
|
STATE_HIGH_DEMAND,
|
|
]
|
|
|
|
|
|
def test_temperature_reads_current_and_target():
|
|
entity = _entity(_load_device("ehs"))
|
|
assert entity.current_temperature == 38.0
|
|
assert entity.target_temperature == 40.0
|
|
assert entity.temperature_unit == UnitOfTemperature.CELSIUS
|
|
assert entity.min_temp == 40.0
|
|
assert entity.max_temp == 62.0
|
|
assert entity.target_temperature_step == 0.5
|
|
|
|
|
|
def test_temperature_bounds_fall_back_together():
|
|
"""A board reporting only one end of the range must not pair a real
|
|
device bound with an HA default -- both ends or neither, same rule as
|
|
climate._range()."""
|
|
resources = _load_device("ehs")
|
|
del resources["/temperatures/dhw/vs/0"]["x.com.samsung.da.maximum"]
|
|
entity = _entity(resources)
|
|
|
|
# Neither end comes from the device: the device minimum (40.0) is
|
|
# dropped along with the missing maximum, so both fall back to HA's own
|
|
# water_heater defaults rather than being mixed.
|
|
assert entity.min_temp != 40.0
|
|
assert (entity.min_temp, entity.max_temp) == (
|
|
WaterHeaterEntity.min_temp.fget(entity),
|
|
WaterHeaterEntity.max_temp.fget(entity),
|
|
)
|
|
|
|
|
|
def test_zero_increment_is_not_collapsed_into_the_default():
|
|
"""`or` would turn a genuine 0 into 0.5 (issue #160)."""
|
|
resources = _load_device("ehs")
|
|
resources["/temperatures/dhw/vs/0"]["x.com.samsung.da.increment"] = "0"
|
|
entity = _entity(resources)
|
|
|
|
assert entity.target_temperature_step == 0.0
|
|
|
|
|
|
def test_current_operation_tolerates_lowercase_device_codes():
|
|
resources = _load_device("ehs")
|
|
resources["/mode/dhw/vs/0"]["x.com.samsung.da.modes"] = ["eco"]
|
|
entity = _entity(resources)
|
|
|
|
assert entity.current_operation == STATE_ECO
|
|
|
|
|
|
def test_entity_is_named_rather_than_taking_the_device_name():
|
|
"""Unlike climate.py's AC, DHW is one loop of a two-loop device, so it
|
|
takes a catalog name instead of presenting as the device itself."""
|
|
entity = _entity(_load_device("ehs"))
|
|
|
|
# No _attr_name override (that would present as the bare device name,
|
|
# and would also beat the catalog); the name comes from the catalog key.
|
|
assert "_attr_name" not in entity.__dict__
|
|
assert entity.translation_key == "dhw"
|
|
|
|
|
|
async def test_set_temperature_writes_dhw_temperature():
|
|
resources = _load_device("ehs")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_temperature(temperature=45.0)
|
|
|
|
assert coordinator.commands == [(entity._bound, ("temperature", 45.0))]
|
|
|
|
|
|
async def test_set_temperature_honours_operation_mode():
|
|
"""water_heater.set_temperature carries an optional operation_mode
|
|
(SET_TEMPERATURE_SCHEMA); dropping it would move the setpoint without
|
|
ever changing mode -- same bug fixed for climate's hvac_mode."""
|
|
resources = _load_device("ehs")
|
|
resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off"
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_HIGH_DEMAND)
|
|
|
|
assert coordinator.commands == [
|
|
(entity._bound, ("power", True)),
|
|
(entity._bound, ("mode", "Force")),
|
|
(entity._bound, ("temperature", 55.0)),
|
|
]
|
|
|
|
|
|
async def test_set_temperature_with_off_mode_skips_the_setpoint_write():
|
|
resources = _load_device("ehs")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_OFF)
|
|
|
|
assert coordinator.commands == [(entity._bound, ("power", False))]
|
|
|
|
|
|
async def test_turn_on_and_off_write_dhw_power():
|
|
resources = _load_device("ehs")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_turn_on()
|
|
await entity.async_turn_off()
|
|
|
|
assert coordinator.commands == [
|
|
(entity._bound, ("power", True)),
|
|
(entity._bound, ("power", False)),
|
|
]
|
|
|
|
|
|
async def test_set_operation_mode_off_turns_off_without_writing_mode():
|
|
resources = _load_device("ehs")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_operation_mode(STATE_OFF)
|
|
|
|
assert coordinator.commands == [(entity._bound, ("power", False))]
|
|
|
|
|
|
async def test_set_operation_mode_writes_mapped_device_code():
|
|
resources = _load_device("ehs")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_operation_mode(STATE_HIGH_DEMAND)
|
|
|
|
# DHW power is already on (fixture default) -- no extra power write.
|
|
assert coordinator.commands == [(entity._bound, ("mode", "Force"))]
|
|
|
|
|
|
async def test_set_operation_mode_powers_on_first_when_off():
|
|
resources = _load_device("ehs")
|
|
resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off"
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_set_operation_mode(STATE_PERFORMANCE)
|
|
|
|
assert coordinator.commands == [
|
|
(entity._bound, ("power", True)),
|
|
(entity._bound, ("mode", "Power")),
|
|
]
|