diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 8ab93ac..c283cce 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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: diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index c860084..aa80c94 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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), ), ) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 4e815ad..7323c60 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -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', diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json index 892ba89..9970ed3 100644 --- a/tests/fixtures/golden/air_purifier_vtww.json +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -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" ] } diff --git a/tests/fixtures/golden/microwave_me7500d_lamp_high.json b/tests/fixtures/golden/microwave_me7500d_lamp_high.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d_lamp_high.json @@ -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" + ] +} diff --git a/tests/fixtures/microwave_me7500d_lamp_high_device.json b/tests/fixtures/microwave_me7500d_lamp_high_device.json new file mode 100644 index 0000000..3ac621a --- /dev/null +++ b/tests/fixtures/microwave_me7500d_lamp_high_device.json @@ -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" + } + } + ] +} diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py index 3756a4e..5a4e838 100644 --- a/tests/test_air_dresser_capabilities.py +++ b/tests/test_air_dresser_capabilities.py @@ -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 diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py index 81d3351..e03ad61 100644 --- a/tests/test_air_purifier_vtww_fan.py +++ b/tests/test_air_purifier_vtww_fan.py @@ -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' diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py index 5a5dea6..0b948b8 100644 --- a/tests/test_airconditioner_tp1x_rac_01001_fan.py +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -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') diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index df28521..c5076ae 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -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' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index f9db9e5..b7ed571 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 5629322..f01d096 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -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 # ---------------------------------------------------------------------------