Address independent code review findings on PR #167
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.
This commit is contained in:
@@ -268,19 +268,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
Delegates the board-generation test to is_legacy_board (the same
|
||||
test capabilities/airconditioner.py's token entities are gated on)
|
||||
instead of re-implementing it, via a minimal presence dict built
|
||||
from the two hrefs it actually inspects -- cheaper than
|
||||
last_resources' full snapshot copy, since is_legacy_board only
|
||||
checks key membership.
|
||||
instead of re-implementing it. Uses last_resources rather than a
|
||||
two-key presence dict built from coordinator.resource()'s truthiness
|
||||
-- resource() 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. A
|
||||
presence dict built from truthiness alone would disagree with the
|
||||
token entities on a board reporting a genuinely empty /airflow/vs/0,
|
||||
silently reintroducing the drift this delegation exists to prevent.
|
||||
"""
|
||||
airflow = self.coordinator.resource(AIRFLOW_HREF)
|
||||
wind_strength = self.coordinator.resource(WIND_STRENGTH_HREF)
|
||||
presence = {}
|
||||
if airflow:
|
||||
presence[AIRFLOW_HREF] = airflow
|
||||
if wind_strength:
|
||||
presence[WIND_STRENGTH_HREF] = wind_strength
|
||||
return airflow if is_legacy_board(presence) else {}
|
||||
if not is_legacy_board(self.coordinator.last_resources):
|
||||
return {}
|
||||
return self.coordinator.resource(AIRFLOW_HREF) or {}
|
||||
|
||||
def _legacy_preset(self) -> bool:
|
||||
"""Whether presets come from the Comode_* token rather than a resource.
|
||||
@@ -563,14 +562,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ('fan_legacy', level))
|
||||
return
|
||||
supported = self._supported(WIND_STRENGTH_HREF)
|
||||
device = _FAN_TO_DEVICE.get(fan_mode)
|
||||
if device is None:
|
||||
# fan_mode came from _wind_strength_label's dynamic path (issue
|
||||
# #155) -- resolve back to the device's own code the same way
|
||||
# async_set_preset_mode does for its dynamic codes.
|
||||
# A static hit is only trustworthy if this unit's own supportedModes
|
||||
# actually includes that code -- a board can use non-standard codes
|
||||
# (issue #155's "31"-"35") while still spelling a standard label
|
||||
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
|
||||
# return a plausible-looking code ('1'/'3') the device never
|
||||
# advertised at all. Fall through to the live scan whenever the
|
||||
# static guess isn't actually one of this unit's own codes.
|
||||
if device is None or (supported and device not in supported):
|
||||
rep = self._rep(WIND_STRENGTH_HREF)
|
||||
for code in self._supported(WIND_STRENGTH_HREF):
|
||||
if code not in _DEVICE_TO_FAN and _wind_strength_label(code, rep) == fan_mode:
|
||||
for code in supported:
|
||||
if _wind_strength_label(code, rep) == fan_mode:
|
||||
device = code
|
||||
break
|
||||
if device is not None:
|
||||
|
||||
@@ -137,14 +137,22 @@ DEVICE_ACTIVE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
def _power_write(power_href, value):
|
||||
"""Shared 'power' payload handling for this family's three FanDesc write
|
||||
functions -- targets whichever power href fan.py's _power_payload picked
|
||||
(the board may only report /power/0); a hardcoded vendor href here would
|
||||
silently no-op on such a board even though the entity's own is_on
|
||||
already falls back to reading it correctly."""
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
|
||||
|
||||
def _airflow_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/vs/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'speed':
|
||||
return ['airflow', '0'], {'speed': int(value)}
|
||||
return None
|
||||
@@ -234,15 +242,7 @@ MODE = Capability(
|
||||
def _fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
# Targets whichever power href fan.py's _power_payload picked (the
|
||||
# board may only report /power/0) -- a hardcoded vendor href here
|
||||
# would silently no-op on such a board even though the entity's
|
||||
# own is_on already falls back to reading it correctly.
|
||||
power_href = args[0] if args else '/power/vs/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
|
||||
return None
|
||||
@@ -277,11 +277,7 @@ FAN = Capability(
|
||||
def _wind_strength_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/vs/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
return _power_write(args[0] if args else '/power/vs/0', value)
|
||||
if kind == 'mode':
|
||||
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
|
||||
return None
|
||||
@@ -294,11 +290,17 @@ def _wind_strength_fan_write(payload, rep, href=None):
|
||||
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
|
||||
# per-model map. modes here is a bare string ('87'), not a single-element
|
||||
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
|
||||
#
|
||||
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
|
||||
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
|
||||
# (entity.py's _key), not href, so two same-key FanDescs in one registry
|
||||
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
|
||||
# comment on this exact hazard -- missed here in the initial cut).
|
||||
WIND_STRENGTH_FAN = Capability(
|
||||
href=HREF_WIND_STRENGTH,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
FanDesc(key='fan', translation_key='air_purifier_fan',
|
||||
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
|
||||
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -112,8 +112,21 @@ def _power_level_watts(v):
|
||||
return int_or_none(s)
|
||||
|
||||
|
||||
def _cooking_mode_options(resources):
|
||||
"""Live mode list from the device's own /mode/vs/0 supportedModes when
|
||||
it reports one (both known dumps do); the union-of-all-dumps
|
||||
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
|
||||
pattern as oven._oven_mode_options -- a fixed list here would offer
|
||||
users modes their own unit doesn't have (issue #152's ME7500D reports
|
||||
only 4 of _MICROWAVE_MODES' 11)."""
|
||||
rep = resources.get('/mode/vs/0') or {}
|
||||
live = rep.get('x.com.samsung.da.supportedModes')
|
||||
return list(live) if live else list(_MICROWAVE_MODES)
|
||||
|
||||
|
||||
def _mode_write(p, rep, href=None):
|
||||
if p not in _MICROWAVE_MODES:
|
||||
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
|
||||
if p not in valid:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
|
||||
@@ -187,7 +200,7 @@ MICROWAVE_MODE = Capability(
|
||||
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_MICROWAVE_MODES,
|
||||
options=_cooking_mode_options,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@
|
||||
"device_active",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"fan",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"hepa_filter_status",
|
||||
"hepa_filter_usage",
|
||||
"odor",
|
||||
"power_switch",
|
||||
"super_fine_dust"
|
||||
"super_fine_dust",
|
||||
"wind_strength_fan"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cavity_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooking_mode",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"fan",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"power_level",
|
||||
"progress_percentage",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "103700",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/fanspeed/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hood.fanSpeed": "0",
|
||||
"x.com.samsung.da.hood.supportedFanSpeed": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.hood.settableMaxFanSpeed": "4",
|
||||
"x.com.samsung.da.hood.settableMinFanSpeed": "0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.hood.fanSpeed"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40475141|50040000011811000A00000000000000",
|
||||
"x.com.samsung.da.description": "ME7500D-/AA2",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "24111400",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04751A23121900",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04754B23120800",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"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": "KM5",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"NoOperation",
|
||||
"MicroWave",
|
||||
"Autocook",
|
||||
"KeepWarm"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_ME7500D-/AA2",
|
||||
"TimeAutoSync_On",
|
||||
"weight_LBS",
|
||||
"TimeSystem_12",
|
||||
"Sound_Off",
|
||||
"RemindBeep_Off",
|
||||
"FilterRemind_Off",
|
||||
"Lamp_High"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "MicroWave",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"flashingProgress": "",
|
||||
"otnStatus": "None",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
|
||||
"versions": [
|
||||
"40241114"
|
||||
],
|
||||
"visVersion": "241114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "074240475141FFFFFFFF",
|
||||
"versions": [
|
||||
"23121900",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231219"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "074240475442FFFFFFFF",
|
||||
"versions": [
|
||||
"23120800",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231208"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.recipe": "00000000000000",
|
||||
"x.com.samsung.da.powerLevel": "0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/New_York",
|
||||
"offset": "-04:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "REDACTED",
|
||||
"macaddressBLE": "REDACTED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162)."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -9,7 +9,7 @@ already. LocalThingsAirPurifierFan._label_for_code resolves both shapes
|
||||
without a per-model map.
|
||||
"""
|
||||
from custom_components.localthings.fan import LocalThingsAirPurifierFan
|
||||
from custom_components.localthings.registry.by_type import air_purifier, for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
@@ -89,3 +89,40 @@ async def test_set_preset_mode_writes_back_the_raw_code():
|
||||
def test_is_on_reads_vendor_power():
|
||||
entity = _entity(_resources())
|
||||
assert entity.is_on is False
|
||||
|
||||
|
||||
def test_wind_strength_fan_key_does_not_collide_with_mode_fan():
|
||||
"""WIND_STRENGTH_FAN and FAN both live in this registry and both are
|
||||
FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's
|
||||
_key), not href, so a shared key would silently shadow one entity if a
|
||||
board ever bound both (see AIRFLOW_GENERIC's own comment on this exact
|
||||
hazard). No real dump reports both hrefs today, but the keys must stay
|
||||
distinct regardless."""
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
|
||||
fan_keys = {
|
||||
entity.key
|
||||
for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN)
|
||||
for entity in cap.entities
|
||||
}
|
||||
assert fan_keys == {'fan', 'wind_strength_fan'}
|
||||
|
||||
|
||||
def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
|
||||
"""Synthetic combination (no real dump reports both hrefs) proving the
|
||||
two FanDescs don't shadow each other in flatten()'s key-based state dict
|
||||
even if a future board did report both."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
|
||||
resources = _resources()
|
||||
resources['/mode/vs/0'] = {
|
||||
'x.com.samsung.da.modes': ['Smart'],
|
||||
'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'],
|
||||
}
|
||||
reg = _reg(resources)
|
||||
unbound = []
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['fan'] == 'Smart'
|
||||
assert state['wind_strength_fan'] == '87'
|
||||
|
||||
@@ -103,3 +103,32 @@ async def test_set_fan_mode_still_resolves_standard_scale_labels():
|
||||
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')
|
||||
|
||||
@@ -7,7 +7,7 @@ testable directly.
|
||||
from homeassistant.components.climate import HVACMode
|
||||
|
||||
from custom_components.localthings.climate import (
|
||||
_AI_COMFORT_MODE, _DEVICE_TO_FAN, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE,
|
||||
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE,
|
||||
PRESET_AI_COMFORT, _preset_to_ha,
|
||||
)
|
||||
|
||||
@@ -81,6 +81,38 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
(0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already
|
||||
match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName
|
||||
fallback needed for this particular board, unlike issue #155's
|
||||
TP1X_DA-AC-RAC-01001_0000."""
|
||||
for code in ('0', '2', '3', '4'):
|
||||
assert code in _DEVICE_TO_FAN
|
||||
TP1X_DA-AC-RAC-01001_0000.
|
||||
|
||||
Asserts the live climate entity's actual fan_modes/fan_mode output
|
||||
(not just that the module constant _DEVICE_TO_FAN happens to have
|
||||
these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass
|
||||
even if fan_modes/fan_mode were completely broken, since it never
|
||||
touches the entity at all.
|
||||
"""
|
||||
from custom_components.localthings.climate import LocalThingsClimate
|
||||
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
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-FAC-BORA-SERIAL'
|
||||
device_info = {}
|
||||
data = {}
|
||||
|
||||
def __init__(self, resources):
|
||||
self.last_resources = resources
|
||||
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
resources = _load_device('airconditioner_fac_bora')
|
||||
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))
|
||||
entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound)
|
||||
|
||||
assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo']
|
||||
assert entity.fan_mode == 'auto'
|
||||
|
||||
@@ -478,6 +478,12 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop():
|
||||
resources = _load_device('induction_cooktop')
|
||||
golden = json.loads((GOLDEN / 'induction_cooktop.json').read_text())
|
||||
state_keys = _new_state_keys('induction_cooktop', 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_range_no_info():
|
||||
"""NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no
|
||||
@@ -668,6 +674,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high():
|
||||
"""Same TP1X_DA-KS-MICROWAVE-01051/ME7500D board as microwave_me7500d
|
||||
above, but this live capture (issue #152) is the first to report a
|
||||
non-Off Lamp token ('Lamp_High'). Locks in that the lamp switch reads
|
||||
it as on rather than the previously-hardcoded 'On'-only comparison."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_me7500d_lamp_high')
|
||||
golden = json.loads((GOLDEN / 'microwave_me7500d_lamp_high.json').read_text())
|
||||
state_keys = _new_state_keys('microwave_me7500d_lamp_high', 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_air_purifier_tp1x_da_ac_air():
|
||||
"""TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion
|
||||
'7.0 Air purifier' and resolves via for_device() onto the existing
|
||||
|
||||
@@ -120,9 +120,23 @@ def test_power_level_handles_missing_value():
|
||||
|
||||
def test_microwave_mode_options_nonempty():
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
assert len(desc.options) > 0
|
||||
assert 'MicroWave' in desc.options
|
||||
assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry'
|
||||
assert callable(desc.options)
|
||||
options = desc.options({})
|
||||
assert len(options) > 0
|
||||
assert 'MicroWave' in options
|
||||
assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry'
|
||||
|
||||
|
||||
def test_microwave_mode_options_reads_live_supported_modes():
|
||||
"""issue #152's ME7500D reports only 4 of the 11 union-of-all-dumps
|
||||
_MICROWAVE_MODES -- the live supportedModes list is used verbatim when
|
||||
present, same live-first pattern as oven._oven_mode_options, instead of
|
||||
offering users modes their own unit doesn't have."""
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
resources = {'/mode/vs/0': {
|
||||
'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'],
|
||||
}}
|
||||
assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']
|
||||
|
||||
|
||||
def test_microwave_mode_write_round_trips():
|
||||
@@ -137,6 +151,19 @@ def test_microwave_mode_rejects_unknown():
|
||||
assert desc.write_fn('SpaghettiMode', {}) is None
|
||||
|
||||
|
||||
def test_microwave_mode_write_validates_against_live_supported_modes():
|
||||
"""A device reporting its own supportedModes is validated against that
|
||||
list, not the static union-of-all-dumps fallback -- 'AirFryer' is a
|
||||
valid _MICROWAVE_MODES entry but must still be rejected for a unit
|
||||
whose own supportedModes doesn't include it."""
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']}
|
||||
path, body = desc.write_fn('MicroWave', rep)
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body['x.com.samsung.da.modes'] == ['MicroWave']
|
||||
assert desc.write_fn('AirFryer', rep) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_MODE — lamp/sound options-array writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user