Merge remote-tracking branch 'origin/main' into claude/device-support-issue-88-dehumidifier
# Conflicts: # custom_components/localthings/registry/by_type/__init__.py # tests/test_by_type.py # tests/test_golden_regression.py
This commit is contained in:
14 files changed
+738
-9
No files matched your search
@@ -5,10 +5,12 @@
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"buzz_lock",
|
||||
"coldwater_lock",
|
||||
"dispense_capacity",
|
||||
"dispense_type",
|
||||
"favorite_capacity",
|
||||
"favorite_capacity_enabled",
|
||||
"filter_clean_remain_time",
|
||||
"filter_door_status",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"hot_water_temperature",
|
||||
"hotwater_lock",
|
||||
"pouring",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sterilize_last_time",
|
||||
"sterilize_period",
|
||||
"sterilize_plan_time",
|
||||
"sterilize_run_time",
|
||||
"waterpurifier_status"
|
||||
]
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
],
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"WATERFILTER_DISABLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"modes": [
|
||||
"WATERFILTER_DISABLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000",
|
||||
"x.com.samsung.da.description": "TP2X_WATERPURIFIER_20K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "540",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02144A240615",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24061111, 24091218, 21072807, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-WPWW-TP2-20-COMMON",
|
||||
"versions": [
|
||||
"20240615"
|
||||
],
|
||||
"visVersion": "240615"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "03800013234100132441",
|
||||
"versions": [
|
||||
"24061111",
|
||||
"24091218"
|
||||
],
|
||||
"visVersion": "240912"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "038000132541FFFFFFFF",
|
||||
"versions": [
|
||||
"21072807",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "210728"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "28",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.lastResetDate": "2026-04-14T14:33:15",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hotwaterLock": "Unlocked",
|
||||
"x.com.samsung.da.coldwaterLock": "Unlocked",
|
||||
"x.com.samsung.da.buzzLock": "Unlocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.desiredType": "coldwater",
|
||||
"x.com.samsung.da.tempDesiredHotWater": "85",
|
||||
"x.com.samsung.da.tempUnit": "C",
|
||||
"x.com.samsung.da.desiredCapacity": "100",
|
||||
"x.com.samsung.da.capacityUnit": "C",
|
||||
"x.com.samsung.da.capacityResolution": "10",
|
||||
"x.com.samsung.da.desiredCapacityRange": [
|
||||
"50",
|
||||
"2000"
|
||||
],
|
||||
"x.com.samsung.da.supportedTypes": [
|
||||
"ambientwater",
|
||||
"coldwater",
|
||||
"hotwater"
|
||||
],
|
||||
"x.com.samsung.da.supportedHotTemperatures": [
|
||||
"40",
|
||||
"75",
|
||||
"85"
|
||||
],
|
||||
"x.com.samsung.da.triggerTime": "1785063153",
|
||||
"x.com.samsung.da.pourStatus": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.filterDoorStatus": "Close",
|
||||
"x.com.samsung.da.sterilizeRunTime": "0",
|
||||
"x.com.samsung.da.sterilizeStartTime": "2026-07-27T18:00:00",
|
||||
"x.com.samsung.da.sterilizePeriod": "3",
|
||||
"x.com.samsung.da.sterilizePlanTime": "2026-07-27T18:00:00",
|
||||
"x.com.samsung.da.sterilizeLastTime": "2026-07-24T18:09:25",
|
||||
"x.com.samsung.da.sterilizeMaxTime": "10",
|
||||
"x.com.samsung.da.filterCleanRemainTime": "0",
|
||||
"x.com.samsung.da.filterCleanMaxTime": "15"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/favorite/capacity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.switchCapacity": "On",
|
||||
"x.com.samsung.da.defaultCapacity": "100",
|
||||
"x.com.samsung.da.maxCapacity": "6",
|
||||
"x.com.samsung.da.capacityList": [
|
||||
"100",
|
||||
"150",
|
||||
"200",
|
||||
"250",
|
||||
"550",
|
||||
"9999"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/automation/waterpurifier/vs/0",
|
||||
"rep": {
|
||||
"automation.supported.modes": [
|
||||
"0"
|
||||
],
|
||||
"automation.supported.options": [
|
||||
"capacity"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -169,7 +169,7 @@ def test_run_discovery_detects_cooktop_via_resource_signature(
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(_load_device('cooktop'))
|
||||
|
||||
assert coordinator.device_type_name == 'cooktop'
|
||||
assert coordinator.device_type_name == 'gas_cooktop'
|
||||
assert coordinator._unbound_hrefs == []
|
||||
|
||||
|
||||
|
||||
+19
-4
@@ -42,9 +42,12 @@ class TestForDevice:
|
||||
assert registry.name == 'refrigerator'
|
||||
|
||||
def test_for_device_returns_cooktop_registry(self):
|
||||
"""The registry's own .name is 'gas_cooktop' (disambiguated from
|
||||
induction_cooktop), but the lookup key devices route through stays
|
||||
'cooktop' -- oneUiVersion "Cooktop" still resolves here."""
|
||||
registry = for_device('7.0 Cooktop')
|
||||
assert registry is not None
|
||||
assert registry.name == 'cooktop'
|
||||
assert registry.name == 'gas_cooktop'
|
||||
|
||||
def test_for_device_returns_range_hood_registry(self):
|
||||
registry = for_device('7.0 Range Hood')
|
||||
@@ -238,7 +241,19 @@ class TestForDeviceByModel:
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'dehumidifier'
|
||||
|
||||
|
||||
def test_water_purifier_via_waterpurifier_token(self):
|
||||
"""Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no
|
||||
oneUiVersion and no consumer-prefix match; falls back to the
|
||||
'WATERPURIFIER' token shared by modelNum and description."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000',
|
||||
'TP2X_WATERPURIFIER_20K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'water_purifier'
|
||||
|
||||
def test_airconditioner_via_wac_token(self):
|
||||
"""Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no
|
||||
oneUiVersion and a modelNum carrying the '_WAC_' (Window Air
|
||||
@@ -260,7 +275,7 @@ class TestForDeviceByModel:
|
||||
'ARTIK051_GLOBAL_COOKTOP',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
assert reg.name == 'gas_cooktop'
|
||||
|
||||
def test_range_hood_via_ahd_model(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
@@ -321,7 +336,7 @@ class TestForDeviceByResources:
|
||||
reg = for_device_by_resources(_load_device('cooktop'))
|
||||
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
assert reg.name == 'gas_cooktop'
|
||||
|
||||
def test_unrelated_mode_options_are_not_cooktop(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
|
||||
@@ -119,6 +119,22 @@ def test_registry_reproduces_golden_state_keys_for_dehumidifier():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_water_purifier():
|
||||
"""TP2X_WATERPURIFIER_20K (issue #90) reports no oneUiVersion; resolved
|
||||
via the 'WATERPURIFIER' modelNum/description fallback into a dedicated
|
||||
water_purifier registry (dispense settings, sterilize/filter status,
|
||||
favorite capacity, and the three lock switches)."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('water_purifier')
|
||||
golden = json.loads((GOLDEN / 'water_purifier.json').read_text())
|
||||
state_keys = _new_state_keys('water_purifier', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_cooktop():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('cooktop')
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for Samsung water-purifier support (issue #90, TP2X_WATERPURIFIER_20K).
|
||||
|
||||
HA-free like the rest of the suite: exercises the registry, discovery/
|
||||
flatten, and the write contracts.
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _water_purifier():
|
||||
resources = _load_device('water_purifier')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _bound():
|
||||
reg, resources = _water_purifier()
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def _state():
|
||||
bound, resources = _bound()
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def _desc(key):
|
||||
bound, _ = _bound()
|
||||
return next(b.desc for b in bound if b.desc.key == key)
|
||||
|
||||
|
||||
def test_model_resolves_to_water_purifier_registry():
|
||||
reg, _ = _water_purifier()
|
||||
assert reg is not None and reg.name == 'water_purifier'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #90 dump binds or is covered -- clears
|
||||
the coverage-gap repair."""
|
||||
reg, resources = _water_purifier()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_expected_state_keys_present():
|
||||
state = _state()
|
||||
for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity',
|
||||
'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status',
|
||||
'hotwater_lock', 'coldwater_lock', 'buzz_lock'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_dispense_type_options_come_from_live_supported_types():
|
||||
desc = _desc('dispense_type')
|
||||
assert desc.options_field == 'x.com.samsung.da.supportedTypes'
|
||||
|
||||
|
||||
def test_dispense_type_write_contract():
|
||||
desc = _desc('dispense_type')
|
||||
path, body = desc.write_fn('hotwater', {})
|
||||
assert path == ['setting', 'waterpurifier', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.desiredType': 'hotwater'}
|
||||
|
||||
|
||||
def test_hot_water_temperature_is_a_select_not_a_number():
|
||||
"""Only a handful of discrete temperatures are selectable (not a
|
||||
continuous range) -- confirmed by supportedHotTemperatures being a short
|
||||
enumerated list, not a [min, max] range field."""
|
||||
desc = _desc('hot_water_temperature')
|
||||
assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures'
|
||||
|
||||
|
||||
def test_dispense_capacity_bounds_come_live_not_hardcoded():
|
||||
"""Bounds and step come from the device's own desiredCapacityRange/
|
||||
capacityResolution fields, not a hardcoded constant -- see the
|
||||
adding-device-support skill's 'never hard-code the one dump's values'
|
||||
section."""
|
||||
desc = _desc('dispense_capacity')
|
||||
assert isinstance(desc, NumberDesc)
|
||||
assert desc.native_min is None
|
||||
assert desc.native_max is None
|
||||
assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange'
|
||||
rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'],
|
||||
'x.com.samsung.da.capacityResolution': '10'}
|
||||
assert desc.step_fn(rep) == 10
|
||||
|
||||
|
||||
def test_dispense_capacity_write_contract():
|
||||
desc = _desc('dispense_capacity')
|
||||
path, body = desc.write_fn('550', {})
|
||||
assert path == ['setting', 'waterpurifier', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.desiredCapacity': '550'}
|
||||
|
||||
|
||||
def test_lock_switches_read_unlocked_as_off():
|
||||
state = _state()
|
||||
assert state['hotwater_lock'] is False
|
||||
assert state['coldwater_lock'] is False
|
||||
assert state['buzz_lock'] is False
|
||||
|
||||
|
||||
def test_lock_switch_write_contracts():
|
||||
hot = _desc('hotwater_lock')
|
||||
cold = _desc('coldwater_lock')
|
||||
buzz = _desc('buzz_lock')
|
||||
assert hot.write_fn('On', {}) == (
|
||||
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'})
|
||||
assert cold.write_fn('Off', {}) == (
|
||||
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'})
|
||||
assert buzz.write_fn('On', {}) == (
|
||||
['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'})
|
||||
|
||||
|
||||
def test_favorite_capacity_options_come_from_live_capacity_list():
|
||||
desc = _desc('favorite_capacity')
|
||||
assert desc.options_field == 'x.com.samsung.da.capacityList'
|
||||
|
||||
|
||||
def test_sterilize_timestamps_parsed_as_utc():
|
||||
state = _state()
|
||||
assert state['sterilize_last_time'].tzinfo is not None
|
||||
assert state['sterilize_plan_time'].tzinfo is not None
|
||||
|
||||
|
||||
def test_mode_hrefs_are_ignored_not_guessed():
|
||||
"""/mode/vs/0's supportedModes carries a single opaque wizard token and
|
||||
modes reports an unrelated value not even in supportedModes -- internal
|
||||
plumbing, left unmodeled per the 'don't guess' rule rather than exposed
|
||||
as a nonsensical select."""
|
||||
from custom_components.localthings.registry.capabilities import water_purifier
|
||||
ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE}
|
||||
assert '/mode/vs/0' in ignored_hrefs
|
||||
assert '/automation/waterpurifier/vs/0' in ignored_hrefs
|
||||
Reference in new issue
Block a user