Two real bugs, both latent (no shipped fixture exercised them), plus a
consistency gap and a couple of correctness/DRY nits flagged by review:
- async_set_fan_mode resolved a fan_mode label against the static
_FAN_TO_DEVICE reverse map before checking whether the resulting code is
actually one of the unit's own supportedModes. A board using non-standard
wind-strength codes while still spelling a standard-looking label in
modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently
write a code ("1"/"3"/"4") the device never advertised. Now validates the
static hit against the unit's own supported codes before trusting it,
falling through to the live modesName scan otherwise.
- air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in
the same registry -- BoundEntity's unique_id is built from key alone, not
href, so a board reporting both hrefs would have one fan entity silently
shadow the other. Renamed to 'wind_strength_fan' (translation_key
unchanged). No shipped fixture reports both hrefs today, but the two caps
living in the same registry made this a real latent hazard, the exact one
AIRFLOW_GENERIC's own comment already documents and deliberately avoids.
- microwave.py's cooking_mode select still used a static, union-of-all-
dumps mode list, even though both shipped microwave fixtures already
report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape
oven._oven_mode_options was just built to prefer over exactly this kind
of static list (issue #138's follow-up, this same PR's skill update).
ME7500D advertises 4 modes; the select was offering 11. Applied the same
live-first, static-fallback pattern.
- Added the issue #152 fixture the microwave lamp fix was missing (the
SKILL.md step this PR itself added asks for one).
- climate.py's _legacy_airflow rebuilt a 2-key presence dict from
coordinator.resource()'s truthiness, which collapses "href absent" and
"href present with an empty {} rep" to the same falsy value -- while
is_legacy_board (and discover()'s own binding) test key membership, not
truthiness. Simplified to pass last_resources through directly, matching
is_legacy_board's actual contract instead of a cheaper approximation of
it, so the "can never disagree" claim in both docstrings is actually true.
- Hoisted the 'power' payload branch duplicated verbatim across
_airflow_fan_write/_fan_write/_wind_strength_fan_write into one
_power_write helper (registry/capabilities/air_purifier.py).
- Removed two now-unused imports (test_air_dresser_capabilities.py,
test_air_purifier_vtww_fan.py) and replaced a tautological
code-in-_DEVICE_TO_FAN check with one that actually exercises the live
climate entity's fan_modes/fan_mode (test_climate_ac_modes.py).
- Fixed a pre-existing (not from this PR) no-op test on main --
test_registry_reproduces_golden_state_keys_for_induction_cooktop computed
golden/state_keys and never asserted on them.
756 tests pass.
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
"""TP1X_DA-AC-RAC-01001_0000 fan-strength codes (model AR07C9150HZN, issue
|
|
#155).
|
|
|
|
Its /wind/strength/vs/0 reports supportedModes "0"/"31"/"32"/"33"/"34"/"35"
|
|
instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from
|
|
(every other AC fixture in this repo uses "0"-"4", some with a 6th "5" --
|
|
see airconditioner_window_ac_device.json). Only "0" matched _DEVICE_TO_FAN,
|
|
so fan_modes silently dropped every speed but Auto. The fix reads the
|
|
device's own modesName labels (parallel-indexed with supportedModes) for
|
|
any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a
|
|
second numeric scale.
|
|
"""
|
|
from custom_components.localthings.climate import (
|
|
LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label,
|
|
)
|
|
from custom_components.localthings.registry import by_type
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import ClimateDesc
|
|
|
|
from tests.conftest import _load_device
|
|
|
|
FIXTURE = 'airconditioner_tp1x_rac_01001'
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_serial = 'TEST-RAC-01001-SERIAL'
|
|
device_info = {}
|
|
data = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
self.commands = []
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
async def async_send_command(self, bound, payload):
|
|
self.commands.append((bound, payload))
|
|
|
|
|
|
def _climate(resources, coordinator=None):
|
|
info = resources['/information/vs/0']
|
|
reg = by_type.for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
|
|
return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound)
|
|
|
|
|
|
def test_wind_strength_label_reads_the_devices_own_modes_name():
|
|
rep = {
|
|
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'],
|
|
'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'],
|
|
}
|
|
assert _wind_strength_label('32', rep) == '2'
|
|
assert _wind_strength_label('35', rep) == 'max'
|
|
|
|
|
|
def test_wind_strength_label_falls_back_to_raw_code_when_names_absent():
|
|
assert _wind_strength_label('32', {}) == '32'
|
|
|
|
|
|
def test_fan_modes_include_every_supported_speed_not_just_auto():
|
|
"""Before the fix, only '0' matched _DEVICE_TO_FAN and fan_modes was
|
|
['auto'] -- exactly the reported symptom."""
|
|
resources = _load_device(FIXTURE)
|
|
entity = _climate(resources)
|
|
assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max']
|
|
|
|
|
|
def test_fan_mode_reads_the_current_dynamic_code():
|
|
"""Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'."""
|
|
resources = _load_device(FIXTURE)
|
|
entity = _climate(resources)
|
|
assert entity.fan_mode == '2'
|
|
|
|
|
|
def test_standard_scale_codes_still_use_device_to_fan():
|
|
"""A code _DEVICE_TO_FAN already covers keeps its existing friendly
|
|
label rather than falling through to the device's own (blunter) one --
|
|
no regression for boards using the standard "0"-"4" scale."""
|
|
resources = _load_device(FIXTURE)
|
|
resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0'
|
|
entity = _climate(resources)
|
|
assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto'
|
|
|
|
|
|
async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code():
|
|
resources = _load_device(FIXTURE)
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
await entity.async_set_fan_mode('max')
|
|
|
|
assert coordinator.commands[-1][1] == ('fan', '35')
|
|
|
|
|
|
async def test_set_fan_mode_still_resolves_standard_scale_labels():
|
|
resources = _load_device(FIXTURE)
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
await entity.async_set_fan_mode('auto')
|
|
|
|
assert coordinator.commands[-1][1] == ('fan', '0')
|
|
|
|
|
|
async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_collide():
|
|
"""A board can use non-standard codes ('31'-'33') while modesName still
|
|
spells a standard-looking label ('Low'/'High') that _FAN_TO_DEVICE's
|
|
static reverse map also happens to have an entry for ('1'/'3') -- but
|
|
that entry is for a *different* code this unit never advertises at all.
|
|
Resolving the static hit without checking it against this unit's own
|
|
supportedModes would silently write a code the device doesn't have.
|
|
"""
|
|
resources = _load_device(FIXTURE)
|
|
resources['/wind/strength/vs/0'] = {
|
|
'x.com.samsung.da.modes': '0',
|
|
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33'],
|
|
'x.com.samsung.da.modesName': ['Auto', 'Low', 'High', 'Turbo'],
|
|
}
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
assert entity.fan_modes == ['auto', 'low', 'high', 'turbo']
|
|
|
|
await entity.async_set_fan_mode('high')
|
|
assert coordinator.commands[-1][1] == ('fan', '32')
|
|
|
|
await entity.async_set_fan_mode('low')
|
|
assert coordinator.commands[-1][1] == ('fan', '31')
|
|
|
|
await entity.async_set_fan_mode('turbo')
|
|
assert coordinator.commands[-1][1] == ('fan', '33')
|