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
216 lines
7.4 KiB
Python
216 lines
7.4 KiB
Python
"""Tests for LocalThingsSelect's option-list resolution
|
|
(custom_components/localthings/select.py) -- the static tuple, options_field,
|
|
and callable forms of SelectDesc.options.
|
|
"""
|
|
|
|
from typing import ClassVar, cast
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.registry.capabilities.laundry import (
|
|
BUZZER_SOUND,
|
|
cycle_select,
|
|
washer_cycle_fallback,
|
|
)
|
|
from custom_components.localthings.registry.capability import Capability
|
|
from custom_components.localthings.registry.discovery import BoundEntity
|
|
from custom_components.localthings.registry.entities import SelectDesc
|
|
from custom_components.localthings.select import LocalThingsSelect
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_key = "TEST-SERIAL"
|
|
|
|
def __init__(self, last_resources):
|
|
self.last_resources = last_resources
|
|
|
|
def canonical_resources(self, subdevice):
|
|
# Every entity built by _make_select uses the default MAIN
|
|
# subdevice, so the canonical view is just the raw snapshot
|
|
# (issue #177 -- see LocalThingsEntity._resources).
|
|
return self.last_resources
|
|
|
|
|
|
def _make_select(desc, href, last_resources):
|
|
capability = Capability(href=href, entities=(desc,))
|
|
bound = BoundEntity(href=href, capability=capability, desc=desc)
|
|
return LocalThingsSelect(cast(LocalThingsCoordinator, _FakeCoordinator(last_resources)), bound)
|
|
|
|
|
|
def test_static_options_unaffected():
|
|
desc = SelectDesc(key="x", options=("A", "B"))
|
|
entity = _make_select(desc, "/x/vs/0", {})
|
|
assert entity.options == ["A", "B"]
|
|
|
|
|
|
def test_options_field_unaffected():
|
|
desc = SelectDesc(key="x", options_field="supported")
|
|
entity = _make_select(desc, "/x/vs/0", {"/x/vs/0": {"supported": ["Lo", "Hi"]}})
|
|
assert entity.options == ["Lo", "Hi"]
|
|
|
|
|
|
def test_buzzer_volume_options_normalize_to_translation_keys():
|
|
desc = next(e for e in BUZZER_SOUND.entities if e.key == "buzzer_sound")
|
|
entity = _make_select(
|
|
desc,
|
|
"/buzzersound/vs/0",
|
|
{
|
|
"/buzzersound/vs/0": {
|
|
"supportedBuzzerSound": [
|
|
"Volume_Off",
|
|
"Volume_Low",
|
|
"Volume_Med",
|
|
"Volume_High",
|
|
]
|
|
}
|
|
},
|
|
)
|
|
assert entity.options == ["volume_off", "volume_low", "volume_med", "volume_high"]
|
|
|
|
|
|
def test_callable_options_receives_full_resource_snapshot():
|
|
"""A callable options is handed the coordinator's full href->rep
|
|
snapshot, not just this entity's own href's rep -- needed for course
|
|
lists decoded from a sibling resource (see laundry.cycle_options)."""
|
|
calls = []
|
|
|
|
def _options_fn(resources):
|
|
calls.append(resources)
|
|
return list(resources.get("/other/vs/0", {}).get("codes", []))
|
|
|
|
desc = SelectDesc(key="cycle", translation_key="fake_cycle", options=_options_fn)
|
|
resources = {
|
|
"/x/vs/0": {},
|
|
"/other/vs/0": {"codes": ["1C", "1D"]},
|
|
}
|
|
entity = _make_select(desc, "/x/vs/0", resources)
|
|
assert entity.options == ["1C", "1D"]
|
|
assert calls == [resources]
|
|
|
|
|
|
def test_callable_options_empty_result():
|
|
desc = SelectDesc(key="cycle", options=lambda resources: [])
|
|
entity = _make_select(desc, "/x/vs/0", {})
|
|
assert entity.options == []
|
|
|
|
|
|
def test_callable_translation_key_reresolves_live_not_once_at_construction():
|
|
"""A callable translation_key (laundry.cycle_select's table-id-gated
|
|
resolver) must be re-evaluated against current coordinator data on
|
|
every access, not baked in once at __init__ -- discovery can run while
|
|
a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub
|
|
(see entity.py's _is_included docstring), and a one-time resolution
|
|
would permanently show untranslated codes even after a later poll
|
|
populates the real value."""
|
|
desc = SelectDesc(key="cycle", translation_key=lambda resources: resources.get("key"))
|
|
resources = {"key": None}
|
|
entity = _make_select(desc, "/x/vs/0", resources)
|
|
assert entity.translation_key is None
|
|
|
|
resources["key"] = "washer_cycle_table_02"
|
|
assert entity.translation_key == "washer_cycle_table_02"
|
|
|
|
|
|
async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
|
|
"""Readable fallback labels must still write the exact Samsung token."""
|
|
|
|
class _WritableCoordinator(_FakeCoordinator):
|
|
data: ClassVar[dict] = {"mode": "FutureVendorMode"}
|
|
|
|
def __init__(self, last_resources):
|
|
super().__init__(last_resources)
|
|
self.writes = []
|
|
|
|
async def async_send_command(self, bound, value):
|
|
self.writes.append(value)
|
|
|
|
desc = SelectDesc(
|
|
key="mode",
|
|
translation_key="door_alert",
|
|
options=("Known", "FutureVendorMode"),
|
|
write_fn=lambda *args: None,
|
|
)
|
|
capability = Capability(href="/x/vs/0", entities=(desc,))
|
|
bound = BoundEntity(href="/x/vs/0", capability=capability, desc=desc)
|
|
coordinator = _WritableCoordinator({})
|
|
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound)
|
|
|
|
assert entity.options[-1] == "Future Vendor Mode"
|
|
await entity.async_select_option("Future Vendor Mode")
|
|
assert coordinator.writes == ["FutureVendorMode"]
|
|
|
|
|
|
async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path():
|
|
"""Regression for Course_69/EditCourseList_696F... from real hardware."""
|
|
|
|
class _WritableCoordinator(_FakeCoordinator):
|
|
data: ClassVar[dict] = {"cycle": "69"}
|
|
|
|
def __init__(self, last_resources):
|
|
super().__init__(last_resources)
|
|
self.writes = []
|
|
|
|
async def async_send_command(self, bound, value):
|
|
self.writes.append(value)
|
|
|
|
desc = cycle_select(
|
|
translation_key="washer_cycle",
|
|
icon="mdi:washing-machine",
|
|
table_href="/st/washercourse/vs/0",
|
|
display_fn=washer_cycle_fallback,
|
|
)
|
|
capability = Capability(href="/course/vs/0", entities=(desc,))
|
|
bound = BoundEntity(href="/course/vs/0", capability=capability, desc=desc)
|
|
resources = {
|
|
"/course/vs/0": {"x.com.samsung.da.options": ["Course_69"]},
|
|
"/st/washercourse/vs/0": {
|
|
"x.com.samsung.da.st.courseTable": "Table_02",
|
|
},
|
|
"/wm/editcourse/vs/0": {
|
|
"x.com.samsung.da.editCourseList": (
|
|
"EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3"
|
|
),
|
|
},
|
|
"/wm/personalcourse/vs/0": {
|
|
"x.com.samsung.da.courses": [
|
|
"F1_0106EC868DEC98B7",
|
|
"F3_0109EC9A94EAB8B0EBB3B4",
|
|
],
|
|
},
|
|
}
|
|
coordinator = _WritableCoordinator(resources)
|
|
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound)
|
|
first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8")
|
|
second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8")
|
|
|
|
# Known catalog states stay as HA translation keys; the frontend renders
|
|
# this confirmed Table_02 mapping as "AI Wash".
|
|
assert entity.current_option == "69"
|
|
assert entity.options == [
|
|
"69",
|
|
"6f",
|
|
"75",
|
|
"78",
|
|
"01",
|
|
"71",
|
|
"96",
|
|
"88",
|
|
"70",
|
|
"6d",
|
|
"6a",
|
|
"73",
|
|
"76",
|
|
"72",
|
|
"6c",
|
|
"6e",
|
|
"6b",
|
|
"77",
|
|
"74",
|
|
"79",
|
|
first_name,
|
|
second_name,
|
|
]
|
|
|
|
await entity.async_select_option("6f")
|
|
await entity.async_select_option(first_name)
|
|
assert coordinator.writes == ["6F", "F1"]
|