From e9281aaeade261703acffc0e49c8af7041f5a270 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 00:15:48 +0000 Subject: [PATCH 1/3] Bind microwave built-in vent fan's /hood/fanspeed/vs/0 (issues #137, #142) Combi microwave units report their vent fan on the same resource shape a standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the microwave registry. Unlike a standalone hood, this board has no sibling /power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls back to treating fan speed 0 as the off state when no separate power resource is present. Also gate HOOD_FAN's automatic_operation sensor on field presence, since this board doesn't report it. --- custom_components/localthings/fan.py | 44 +++- .../localthings/registry/by_type/microwave.py | 11 +- .../registry/capabilities/range_hood.py | 5 + tests/fixtures/golden/microwave_me7500d.json | 22 ++ tests/fixtures/microwave_me7500d_device.json | 201 ++++++++++++++++++ tests/test_golden_regression.py | 24 +++ tests/test_microwave_capabilities.py | 16 ++ tests/test_range_hood_fan.py | 75 ++++++- 8 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/golden/microwave_me7500d.json create mode 100644 tests/fixtures/microwave_me7500d_device.json diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9b7ebed..dbc73c1 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -62,7 +62,16 @@ async def async_setup_entry( class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): - """A hood fan combining sibling power and fan-speed resources.""" + """A hood fan combining sibling power and fan-speed resources. + + Some boards that reuse this capability (built-in microwave vent fans, + issues #137/#142) report no sibling `/power/0` or `/power/vs/0` resource + at all -- fan speed 0 is itself the off state there, with no separate + power toggle to write. `_has_separate_power` detects that shape and + switches every method below to drive off/on purely through the + fanSpeed field, including '0' in the ordered speed codes as the off + step instead of assuming every advertised code is an active speed. + """ _enable_turn_on_off_backwards_compatibility = False _attr_supported_features = ( @@ -78,15 +87,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _rep(self, href: str) -> dict: return self.coordinator.resource(href) or {} + def _has_separate_power(self) -> bool: + resources = self.coordinator.last_resources + return POWER_HREF in resources or POWER_VS_HREF in resources + def _all_speed_codes(self) -> list[str]: rep = self._rep(self._bound.href) return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())] def _active_speed_codes(self) -> list[str]: - # Power is carried by the separate /power resource. fanSpeed retains - # the selected setting while power is off (as the lamp's `current` - # field does), so every advertised code is an active ordered speed. - return self._all_speed_codes() + codes = self._all_speed_codes() + if self._has_separate_power(): + # Power is carried by the separate /power resource. fanSpeed + # retains the selected setting while power is off (as the + # lamp's `current` field does), so every advertised code is an + # active ordered speed. + return codes + # No separate power resource: '0' is the off step, not a speed. + return [code for code in codes if code != '0'] def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: """Target whichever power resource this hood actually exposes.""" @@ -96,6 +114,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: + if not self._has_separate_power(): + current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) + return current not in ('', '0') rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -121,6 +142,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: + if not self._has_separate_power(): + if percentage is not None: + await self.async_set_percentage(percentage) + return + codes = self._active_speed_codes() + if codes: + await self.coordinator.async_send_command(self._bound, ('speed', codes[0])) + return await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) @@ -128,6 +157,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: + if not self._has_separate_power(): + await self.coordinator.async_send_command(self._bound, ('speed', '0')) + return await self.coordinator.async_send_command( self._bound, self._power_payload(False), ) @@ -139,7 +171,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if not self.is_on: + if self._has_separate_power() and not self.is_on: await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index c038e49..cbe85fa 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -7,8 +7,16 @@ Cooking mode, setpoint, cavity power level, and lamp are genuinely different for this family (different mode vocabulary, different setpoint bounds, an extra powerLevel field, a differently-named lamp option) and are defined fresh in capabilities/microwave.py -- see that module's docstring. + +Some combi units (built-in over-the-range microwaves, issues #137/#142) +also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same +shape a standalone range hood reports it in -- reused directly from +range_hood.py rather than duplicated. Unlike a standalone hood, this dump +has no sibling `/power/0` or `/power/vs/0` resource; fan.py's +LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that +case (see its `_has_separate_power` check). """ -from ..capabilities import common, ignored, microwave, oven +from ..capabilities import common, ignored, microwave, oven, range_hood from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -24,5 +32,6 @@ REGISTRY = DeviceRegistry( oven.OVEN_DOOR, oven.OVEN_CONNECTED, oven.OVEN_RECIPE_COOK, + range_hood.HOOD_FAN, ]), ) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 59cebc9..f3b4eca 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -99,6 +99,11 @@ HOOD_FAN = Capability( field='x.com.samsung.da.hood.autoOperation', icon='mdi:fan-auto', entity_category='diagnostic', + # Absent on the microwave family's built-in vent fan (issue + # #137) -- this board has no auto-ventilation mode, unlike the + # standalone range hood this capability was written for. + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.hood.autoOperation' in rep), value_fn=lambda value: str(value).lower() == 'on', ), ), diff --git a/tests/fixtures/golden/microwave_me7500d.json b/tests/fixtures/golden/microwave_me7500d.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d.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_device.json b/tests/fixtures/microwave_me7500d_device.json new file mode 100644 index 0000000..d37c591 --- /dev/null +++ b/tests/fixtures/microwave_me7500d_device.json @@ -0,0 +1,201 @@ +{ + "device0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "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": "07424047334140473241", + "versions": ["24071000", "23120700"], + "visVersion": "240710" + }, + { + "type": "Micom", + "modelId": "074240475443FFFFFFFF", + "versions": ["23120800", "FFFFFFFF"], + "visVersion": "231208" + } + ] + } + }, + { + "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": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000", + "x.com.samsung.da.description": "ME7500D-/AA1", + "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": "04733A24071000, 04732A23120700", + "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": "04754C23120800", + "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": "/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-/AA1", + "TimeAutoSync_On", + "weight_LBS", + "TimeSystem_12", + "Sound_On", + "RemindBeep_On", + "FilterRemind_Off", + "Lamp_Off" + ], + "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": "/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": "2026-07-27T20:03:10" + } + ] + } + }, + { + "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": "/timezone/vs/0", + "rep": { + "timezoneid": "America/New_York", + "offset": "-04:00", + "DST": "ON" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "3400", + "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" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..1c27880 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -589,6 +589,30 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b(): ) +def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): + """TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues + #137/#142) -- the same microwave registry as MW7300B above, but this + board also reports the built-in vent fan's `/hood/fanspeed/vs/0` + resource, previously unbound. Bound via range_hood.HOOD_FAN (reused + directly, same resource shape a standalone range hood reports); unlike + a standalone hood this board has no sibling `/power/0` or + `/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats + fan speed 0 as the off state instead of writing a separate power + resource -- see its `_has_separate_power` check. This board also has + no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation` + field, unlike MW7300B, so `setpoint`/`current_temp_c` and + `automatic_operation` are correctly absent here.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d') + golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text()) + state_keys = _new_state_keys('microwave_me7500d', 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 45a7487..05f0b91 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -23,6 +23,22 @@ def test_microwave_fixture_resolves_and_has_no_unbound_hrefs(): assert unbound == [] +def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs(): + """Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in + vent fan) was previously unbound on this family.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None + assert reg.name == 'microwave' + + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + # --------------------------------------------------------------------------- # MICROWAVE_SETPOINT — NumberDesc with RMW write semantics # --------------------------------------------------------------------------- diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 3f52d78..4da948b 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -1,7 +1,7 @@ """HA fan-entity mapping tests for the range hood.""" from custom_components.localthings.fan import LocalThingsRangeHoodFan -from custom_components.localthings.registry.by_type import range_hood +from custom_components.localthings.registry.by_type import microwave, range_hood from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import FanDesc from tests.conftest import _load_device @@ -23,11 +23,11 @@ class _FakeCoordinator: self.commands.append((bound, payload)) -def _entity(resources, coordinator=None): +def _entity(resources, coordinator=None, registry=range_hood.REGISTRY): bound = discover( resources, - range_hood.REGISTRY.capabilities, - range_hood.REGISTRY.pattern_capabilities, + registry.capabilities, + registry.pattern_capabilities, ) fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc)) return LocalThingsRangeHoodFan( @@ -35,6 +35,10 @@ def _entity(resources, coordinator=None): ) +def _microwave_entity(resources, coordinator=None): + return _entity(resources, coordinator, registry=microwave.REGISTRY) + + def test_power_off_maps_to_zero_percent_and_four_retained_speeds(): entity = _entity(_load_device('range_hood')) assert entity.is_on is False @@ -86,3 +90,66 @@ async def test_power_write_falls_back_to_vendor_resource(): await entity.async_turn_off() assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0') + + +# --------------------------------------------------------------------------- +# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this +# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is +# itself the off state. +# --------------------------------------------------------------------------- + +def test_microwave_vent_fan_has_no_separate_power_resource(): + resources = _load_device('microwave_me7500d') + assert '/power/0' not in resources + assert '/power/vs/0' not in resources + entity = _microwave_entity(resources) + assert entity._has_separate_power() is False + + +def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes(): + """supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the + standalone hood's codes, which never include an off entry.""" + resources = _load_device('microwave_me7500d') + entity = _microwave_entity(resources) + assert entity.is_on is False + assert entity.percentage == 0 + assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded + + +def test_microwave_vent_fan_nonzero_speed_reads_as_on(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + entity = _microwave_entity(resources) + assert entity.is_on is True + assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4'] + + +async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('speed', '0') + + +async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands[-1][1] == ('speed', '1') + + +async def test_microwave_vent_fan_set_percentage_writes_speed_only(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_set_percentage(100) + + assert coordinator.commands == [(entity._bound, ('speed', '4'))] From 265d94eded5886665551be64e30d377358e58d61 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 00:35:13 +0000 Subject: [PATCH 2/3] Add kimchi-refrigerator compartment coverage (issue #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TP2X_REF_20K-class 3-compartment kimchi refrigerators report each compartment's storage mode and ripening status/timer on /status/kimchi//vs/0, plus a top-compartment door sensor on /kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving the per-compartment entity key and display name from the href's top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC already do. Storage-mode option labels were translated directly from the reporter's own SmartThings app screenshots rather than guessed from the raw device codes or their English paraphrase, confirming the on-screen option order matches supportMode's array order (including the freezer triplet's -19/-21/-17°C -> Standard/Strong/Weak mapping). Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also populates modes/supportedOptions, but with a token shape that never overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never repeats), so the existing "supportedOptions is nonempty" check let the entity bind anyway and get stuck permanently on "unknown". Requiring an actual resolvable value keeps it working for the RF9000/Bespoke-class fridges it was built for while leaving it absent here. --- .../registry/by_type/refrigerator.py | 2 + .../registry/capabilities/fridge.py | 106 ++++++- .../localthings/translations/en.json | 40 +++ .../localthings/translations/nl.json | 40 +++ .../refrigerator_tp2x_ref_20k_kimchi.json | 24 ++ ...frigerator_tp2x_ref_20k_kimchi_device.json | 274 ++++++++++++++++++ tests/test_fridge_capabilities.py | 78 +++++ tests/test_golden_regression.py | 17 ++ 8 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json create mode 100644 tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 43e776b..4c0abf5 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -36,5 +36,7 @@ REGISTRY = DeviceRegistry( fridge.TEMP_SETPOINT, fridge.ICEMAKER_GENERIC, fridge.DOOR_GENERIC, + fridge.KIMCHI_ZONE, + fridge.KIMCHI_DOOR_GENERIC, ], ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 3e9677a..e90666b 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -590,8 +590,17 @@ FLEX_ZONE = Capability( translation_key='flex_zone_mode', entity_category='config', options_field='x.com.samsung.da.supportedOptions', - exists_fn=lambda rep, resources: bool( - rep.get('x.com.samsung.da.supportedOptions')), + # A nonempty supportedOptions alone isn't sufficient: the + # kimchi-refrigerator family (issue #26) also populates + # /mode/vs/0's modes/supportedOptions with real data, but + # its tokens carry a "_[n]:[n]" parameter suffix on + # supportedOptions that modes never repeats, so no item + # ever overlaps -- the RF9000/Bespoke-class overlap this + # capability was built for never happens there. Require an + # actual resolvable value instead of just a populated + # list, so this stays absent on that family rather than + # showing a select permanently stuck on "unknown". + exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None, rep_fn=_flex_zone_current, write_fn=_flex_zone_write), ), @@ -625,6 +634,99 @@ DOOR_GENERIC = Capability( ), ) +# --------------------------------------------------------------------------- +# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi +# units, issue #26) -- top/middle/bottom each report their own storage mode +# plus a ripening status/timer on /status/kimchi//vs/0, all three in +# an identical shape; modeled as a pattern capability the same way +# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment +# key and {instance_name} from the href's top/middle/bottom segment. Only +# the top compartment's door has been seen reported separately (kimchidoors); +# middle/bottom apparently have no contact switch of their own, so that's +# its own narrower pattern cap rather than assumed universal. +# +# The same state is also mirrored -- packed into single tokens like +# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with +# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads +# for RF9000-class fridges. /status/kimchi//vs/0's plain currentMode/ +# supportMode fields are unpacked and self-describing, so that's what this +# binds to instead. +# +# Write path is unconfirmed (no live write against a real unit) -- same +# "write the same field back to the entity's own href" convention as +# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test. +# +# translations/en.json's kimchi_zone_mode state labels were translated +# directly from the reporter's own (Korean-language) SmartThings app +# screenshots, not guessed from the codes or from their English paraphrase. +# Cross-checking the screenshots against supportMode confirms the on-screen +# option order matches the array order everywhere it's verifiable: the top +# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at +# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the +# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry +# ripening list, and 4-entry custom-storage list each line up 1:1 with their +# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a +# colder or warmer preset around the NORMAL setpoint) everywhere that suffix +# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants, +# which weren't directly screenshotted but share the same NORMAL/COLD/WARM +# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy") +# and BUY (구입, "purchased") are also confirmed exact matches, not +# abbreviation guesses. +# --------------------------------------------------------------------------- + +def _kimchi_mode_write(p, rep, href=None): + if not href: + return None + return [s for s in href.strip('/').split('/') if s], { + 'x.com.samsung.da.currentMode': p, + } + + +KIMCHI_ZONE = Capability( + href=None, + href_prefix='/status/kimchi/', + strip_prefix_in_key=True, + poll_tier='warm', + entities=( + SelectDesc(key='mode', field='x.com.samsung.da.currentMode', + use_instance_name=True, icon='mdi:fridge-outline', + translation_key='kimchi_zone_mode', + entity_category='config', + options_field='x.com.samsung.da.supportMode', + write_fn=_kimchi_mode_write), + SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus', + use_instance_name=True, icon='mdi:progress-clock', + translation_key='kimchi_ripening_status', + entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime', + use_instance_name=True, icon='mdi:timer-sand', + translation_key='kimchi_ripening_remaining', + entity_category='diagnostic', + # No dump has this nonzero (ripeStatus is always "Off" so + # far) -- device-reported unit unconfirmed, so this stays + # a bare number rather than asserting minutes or hours. + value_fn=_int), + SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount', + use_instance_name=True, icon='mdi:tray-full', + translation_key='kimchi_rack_count', + entity_category='diagnostic', enabled_default=False, + value_fn=_int), + ), +) + +KIMCHI_DOOR_GENERIC = Capability( + href=None, + href_prefix='/kimchidoors/', + strip_prefix_in_key=True, + poll_tier='hot', + entities=( + BinarySensorDesc(key='open', rep_fn=_door_open_state, + translation_key='instance_open', + use_instance_name=True, device_class='door'), + ), +) + # --------------------------------------------------------------------------- # Aggregate-resource fallbacks # diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index dc92b91..2199e3f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -381,6 +381,37 @@ "operating_mode": { "name": "Operating mode" }, + "kimchi_zone_mode": { + "name": "{instance_name} storage mode", + "state": { + "off": "Off", + "kimchi_storage_normal": "Kimchi", + "kimchi_storage_cold": "Kimchi, strong", + "kimchi_storage_warm": "Kimchi, weak", + "kimchi_storage_low_salt_normal": "Low-salt kimchi", + "kimchi_storage_low_salt_cold": "Low-salt kimchi, strong", + "kimchi_storage_low_salt_warm": "Low-salt kimchi, weak", + "kimchi_storage_crunfch": "Crisp kimchi", + "kimchi_storage_buy": "Purchased kimchi", + "storage_fridge_normal": "Fridge", + "storage_fridge_cold": "Fridge, strong", + "storage_fridge_warm": "Fridge, weak", + "storage_freezer_normal": "Freezer", + "storage_freezer_cold": "Freezer, strong", + "storage_freezer_warm": "Freezer, weak", + "kimchi_ripe_low_temp": "Kimchi ripening, low temperature", + "kimchi_ripe_normal_temp": "Kimchi ripening, room temperature", + "kimchi_ripe_kkakdugi": "Kkakdugi ripening", + "kimchi_ripe_dongchimi": "Dongchimi ripening", + "meat_ripe_normal": "Meat ripening", + "storage_meat": "Meat & fish", + "storage_fridge_vegetables_fruit": "Fruit & vegetables", + "storage_fresh_cereal": "Grains", + "storage_fridge_drink": "Beverages", + "storage_fresh_wine": "Wine", + "storage_fresh_potato_banana": "Potato & banana" + } + }, "pantry_zone_mode": { "name": "Pantry zone mode", "state": { @@ -678,6 +709,15 @@ "fridge_temperature": { "name": "Fridge temperature" }, + "kimchi_ripening_status": { + "name": "{instance_name} ripening status" + }, + "kimchi_ripening_remaining": { + "name": "{instance_name} ripening time remaining" + }, + "kimchi_rack_count": { + "name": "{instance_name} rack count" + }, "hood_filter_capacity": { "name": "Filter capacity" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b23b93f..7015d9f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -381,6 +381,37 @@ "operating_mode": { "name": "Bedrijfsmodus" }, + "kimchi_zone_mode": { + "name": "{instance_name} opslagmodus", + "state": { + "off": "Uit", + "kimchi_storage_normal": "Kimchi", + "kimchi_storage_cold": "Kimchi, sterk", + "kimchi_storage_warm": "Kimchi, zwak", + "kimchi_storage_low_salt_normal": "Kimchi met weinig zout", + "kimchi_storage_low_salt_cold": "Kimchi met weinig zout, sterk", + "kimchi_storage_low_salt_warm": "Kimchi met weinig zout, zwak", + "kimchi_storage_crunfch": "Knapperige kimchi", + "kimchi_storage_buy": "Gekochte kimchi", + "storage_fridge_normal": "Koelkast", + "storage_fridge_cold": "Koelkast, sterk", + "storage_fridge_warm": "Koelkast, zwak", + "storage_freezer_normal": "Vriezer", + "storage_freezer_cold": "Vriezer, sterk", + "storage_freezer_warm": "Vriezer, zwak", + "kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur", + "kimchi_ripe_normal_temp": "Kimchi rijpen, kamertemperatuur", + "kimchi_ripe_kkakdugi": "Kkakdugi rijpen", + "kimchi_ripe_dongchimi": "Dongchimi rijpen", + "meat_ripe_normal": "Vlees rijpen", + "storage_meat": "Vlees en vis", + "storage_fridge_vegetables_fruit": "Groenten en fruit", + "storage_fresh_cereal": "Granen", + "storage_fridge_drink": "Dranken", + "storage_fresh_wine": "Wijn", + "storage_fresh_potato_banana": "Aardappelen en bananen" + } + }, "pantry_zone_mode": { "name": "Modus voorraadzone", "state": { @@ -678,6 +709,15 @@ "fridge_temperature": { "name": "Koelkasttemperatuur" }, + "kimchi_ripening_status": { + "name": "{instance_name} rijpingsstatus" + }, + "kimchi_ripening_remaining": { + "name": "{instance_name} resterende rijptijd" + }, + "kimchi_rack_count": { + "name": "{instance_name} aantal rekken" + }, "hood_filter_capacity": { "name": "Filtercapaciteit" }, diff --git a/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json new file mode 100644 index 0000000..e456a64 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "bottom_mode", + "bottom_rack_count", + "bottom_ripening_remaining", + "bottom_ripening_status", + "diagnosis_status", + "door_open", + "energy_kwh", + "firmware_update", + "middle_mode", + "middle_rack_count", + "middle_ripening_remaining", + "middle_ripening_status", + "power_energy_kwh", + "power_watts", + "top_mode", + "top_open", + "top_rack_count", + "top_ripening_remaining", + "top_ripening_status" + ] +} diff --git a/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json new file mode 100644 index 0000000..a1d4a28 --- /dev/null +++ b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json @@ -0,0 +1,274 @@ +{ + "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": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "5", + "x.com.samsung.da.cumulativePower": "1733313", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "61", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "x.com.samsung.da.modes": [ + "KIMCHIT_STORAGE_FREEZER_NORMAL", + "KIMCHIT_RIPE_REMAIN_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL", + "KIMCHIM_RIPE_REMAIN_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL", + "KIMCHIB_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[6]", + "KIMCHIM_BOX_COUNT_[4]", + "KIMCHIB_BOX_COUNT_[2]", + "KIMCHI_BOX_COUNT_[0]" + ], + "x.com.samsung.da.supportedOptions": [ + "KIMCHIT_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_NORMAL_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_COLD_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_WARM_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_COLD_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_WARM_[0]:[0]", + "KIMCHIT_OFF_[0]:[0]", + "KIMCHIT_KIMCHI_RIPE_LOW_TEMP_[5]:[17]", + "KIMCHIT_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_CRUNFCH_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_BUY_[0]:[0]", + "KIMCHIM_MEAT_RIPE_NORMAL_[3]:[0]", + "KIMCHIM_STORAGE_MEAT_[0]:[0]", + "KIMCHIM_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]", + "KIMCHIM_STORAGE_FRESH_CEREAL_[0]:[0]", + "KIMCHIM_OFF_[0]:[0]", + "KIMCHIM_KIMCHI_RIPE_LOW_TEMP_[5]:[17]", + "KIMCHIM_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_CRUNFCH_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_BUY_[0]:[0]", + "KIMCHIB_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]", + "KIMCHIB_STORAGE_FRIDGE_DRINK_[0]:[0]", + "KIMCHIB_STORAGE_FRESH_WINE_[0]:[0]", + "KIMCHIB_STORAGE_FRESH_POTATO_BANANA_[0]:[0]", + "KIMCHIB_OFF_[0]:[0]", + "KIMCHIB_KIMCHI_RIPE_KKAKDUGI_[4]:[5]", + "KIMCHIB_KIMCHI_RIPE_DONGCHIMI_[7]:[6]" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "modes": [ + "KIMCHIT_STORAGE_FREEZER_NORMAL", + "KIMCHIT_RIPE_REMAIN_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL", + "KIMCHIM_RIPE_REMAIN_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL", + "KIMCHIB_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[6]", + "KIMCHIM_BOX_COUNT_[4]", + "KIMCHIB_BOX_COUNT_[2]", + "KIMCHI_BOX_COUNT_[0]" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_REF_20K|00135941|10010221011411010103642021000000", + "x.com.samsung.da.description": "TP2X_REF_20K", + "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": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A220110", + "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": "20082109,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": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/kimchidoors/top/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/status/kimchi/top/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "STORAGE_FREEZER_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "6", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "STORAGE_FRIDGE_NORMAL", + "STORAGE_FRIDGE_COLD", + "STORAGE_FRIDGE_WARM", + "STORAGE_FREEZER_NORMAL", + "STORAGE_FREEZER_COLD", + "STORAGE_FREEZER_WARM", + "OFF", + "KIMCHI_RIPE_LOW_TEMP", + "KIMCHI_RIPE_NORMAL_TEMP" + ] + } + }, + { + "href": "/status/kimchi/middle/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "4", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "KIMCHI_STORAGE_CRUNFCH", + "KIMCHI_STORAGE_BUY", + "MEAT_RIPE_NORMAL", + "STORAGE_MEAT", + "STORAGE_FRIDGE_VEGETABLES_FRUIT", + "STORAGE_FRESH_CEREAL", + "OFF", + "KIMCHI_RIPE_LOW_TEMP", + "KIMCHI_RIPE_NORMAL_TEMP" + ] + } + }, + { + "href": "/status/kimchi/bottom/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "2", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "KIMCHI_STORAGE_CRUNFCH", + "KIMCHI_STORAGE_BUY", + "STORAGE_FRIDGE_VEGETABLES_FRUIT", + "STORAGE_FRIDGE_DRINK", + "STORAGE_FRESH_WINE", + "STORAGE_FRESH_POTATO_BANANA", + "OFF", + "KIMCHI_RIPE_KKAKDUGI", + "KIMCHI_RIPE_DONGCHIMI" + ] + } + } + ] +} diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 94f7d96..5ce2c1d 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -163,6 +163,26 @@ class TestFlexZone: assert 'CV_FDR_MEAT' not in modes assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes + def test_exists_only_when_a_current_value_resolves(self): + """Issue #26's kimchi-refrigerator family also populates /mode/vs/0's + modes/supportedOptions, but its supportedOptions tokens carry a + "_[n]:[n]" suffix modes never repeats, so no item ever overlaps -- + the entity used to bind anyway (supportedOptions is nonempty) and + get stuck on "unknown" forever.""" + no_overlap_rep = { + 'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'], + 'x.com.samsung.da.supportedOptions': [ + 'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'], + } + desc = fridge.FLEX_ZONE.entities[0] + assert desc.exists_fn(no_overlap_rep, {}) is False + + overlap_rep = { + 'x.com.samsung.da.modes': ['CV_FDR_MEAT'], + 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'], + } + assert desc.exists_fn(overlap_rep, {}) is True + class TestTp1xNativeDuplicateResources: """The US TP1X_REF_21K publishes two native mirrors in addition to the @@ -210,6 +230,37 @@ class TestPantryZone: assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} +class TestKimchiZone: + """Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) -- + top/middle/bottom each report an identically-shaped currentMode/ + supportMode resource under /status/kimchi//vs/0.""" + + def test_href_prefix(self): + assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/' + assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/' + + def test_write_derives_path_from_href(self): + desc = fridge.KIMCHI_ZONE.entities[0] + path, body = desc.write_fn( + 'KIMCHI_STORAGE_COLD', {}, href='/status/kimchi/middle/vs/0') + assert path == ['status', 'kimchi', 'middle', 'vs', '0'] + assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'} + + def test_write_without_href_is_rejected(self): + desc = fridge.KIMCHI_ZONE.entities[0] + assert desc.write_fn('KIMCHI_STORAGE_COLD', {}) is None + + def test_ripening_status_is_lowercased(self): + desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status') + assert desc.value_fn('Off') == 'off' + assert desc.value_fn(None) is None + + def test_door_reuses_open_state_helper(self): + desc = fridge.KIMCHI_DOOR_GENERIC.entities[0] + assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True + assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + + class TestArtik051AndTp2xFixturesHaveCompleteCoverage: """issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered the incomplete-capability-coverage repair; both must resolve to zero @@ -253,6 +304,33 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: state = flatten(bound, resources) assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + def test_tp2x_ref_20k_kimchi(self): + """A different physical unit sharing the same modelNum string (issue + #26, second reporter) -- a 3-compartment kimchi refrigerator instead + of the flex-zone fridge above. /kimchidoors/top/vs/0 and + /status/kimchi/{top,middle,bottom}/vs/0 were previously unbound.""" + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL' + assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL' + assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL' + assert state['top_open'] is False + assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported + assert 'flex_zone_mode' not in state # no resolvable overlap on this family + class TestArtik051DongleRefFixtureCoverage: """Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 1c27880..a13fb98 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -300,6 +300,23 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): ) +def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi(): + """A different physical unit reporting the same "TP2X_REF_20K" modelNum + string as the fixture above (issue #26's second reporter) -- a + 3-compartment kimchi refrigerator with no flex zone, doors/icemaker, or + freezer/cooler split, but its own /status/kimchi//vs/0 and + /kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC).""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text()) + state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', 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_ac_tp1x_da_ac_rac_01011(): """Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0 From cc3ce12aa453ca773ecc2fcb1f440b34247a142b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:10:01 +0000 Subject: [PATCH 3/3] Fix range hood fan power targeting and kimchi mode write validation - _speed_zero_is_off now keys off the hood resource's own settableMinFanSpeed/supportedFanSpeed fields instead of asking whether the device has any power resource at all, so a combi appliance's cavity /power/0 can no longer be toggled off by turning off just the vent fan. - async_turn_on() no longer resets an already-running fan to its lowest speed when called without a percentage. - _has_separate_power() reads through the O(1) resource cache instead of copying the full resource snapshot on every property access. - _kimchi_mode_write rejects values the compartment didn't advertise in supportMode instead of writing them blind. - kimchi_ripening_status no longer lowercases its value, since it has no enum catalog entry to translate the lowercased token back through. - Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0 aggregate fallback on the one fixture that reports both. Adds regression tests for the combi-appliance power targeting, the already-on turn_on no-op, kimchi mode write validation, and a kimchi select display/write casing round-trip; a translation-coverage guard for kimchi_zone_mode codes mirroring the existing AC preset one. --- custom_components/localthings/fan.py | 69 ++++++++++++------ .../localthings/registry/by_type/microwave.py | 2 +- .../registry/capabilities/fridge.py | 12 +++- tests/test_fridge_capabilities.py | 70 +++++++++++++++++-- tests/test_golden_regression.py | 2 +- tests/test_range_hood_fan.py | 65 +++++++++++++++++ tests/test_translations.py | 24 +++++++ 7 files changed, 212 insertions(+), 32 deletions(-) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index dbc73c1..9c9d623 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -37,6 +37,8 @@ POWER_HREF = '/power/0' POWER_VS_HREF = '/power/vs/0' _FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed' _SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed' +_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed' +_OFF_SPEED_CODE = '0' _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes' @@ -65,12 +67,20 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): """A hood fan combining sibling power and fan-speed resources. Some boards that reuse this capability (built-in microwave vent fans, - issues #137/#142) report no sibling `/power/0` or `/power/vs/0` resource - at all -- fan speed 0 is itself the off state there, with no separate - power toggle to write. `_has_separate_power` detects that shape and - switches every method below to drive off/on purely through the - fanSpeed field, including '0' in the ordered speed codes as the off - step instead of assuming every advertised code is an active speed. + issues #137/#142) report no sibling `/power/0` or `/power/vs/0` + resource at all -- fan speed 0 is itself the off state there, with no + separate power toggle to write. `_speed_zero_is_off` detects that + shape from the hood resource's own settableMinFanSpeed/ + supportedFanSpeed fields and switches every method below to drive + off/on purely through the fanSpeed field, including '0' in the + ordered speed codes as the off step instead of assuming every + advertised code is an active speed. + + This is deliberately not the same question as `_has_separate_power`, + which only proves *some* power resource exists on the device -- on a + combi appliance (e.g. an over-the-range microwave) that resource can + belong to the cavity, not the vent fan, and toggling it from here + would turn off the whole appliance instead of just the fan. """ _enable_turn_on_off_backwards_compatibility = False @@ -88,8 +98,19 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): return self.coordinator.resource(href) or {} def _has_separate_power(self) -> bool: - resources = self.coordinator.last_resources - return POWER_HREF in resources or POWER_VS_HREF in resources + return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF)) + + def _speed_zero_is_off(self) -> bool: + """Whether fan speed '0' is itself this hood's off step, with no + separate power resource to toggle. The board says so directly: + settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The + standalone hood's codes start at 14 and it carries a real /power + resource instead, so this is False there.""" + rep = self._rep(self._bound.href) + return ( + str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE + or _OFF_SPEED_CODE in self._all_speed_codes() + ) def _all_speed_codes(self) -> list[str]: rep = self._rep(self._bound.href) @@ -97,14 +118,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _active_speed_codes(self) -> list[str]: codes = self._all_speed_codes() - if self._has_separate_power(): - # Power is carried by the separate /power resource. fanSpeed - # retains the selected setting while power is off (as the - # lamp's `current` field does), so every advertised code is an - # active ordered speed. - return codes - # No separate power resource: '0' is the off step, not a speed. - return [code for code in codes if code != '0'] + if self._speed_zero_is_off(): + # No separate power resource: '0' is the off step, not a speed. + return [code for code in codes if code != _OFF_SPEED_CODE] + # Power is carried by the separate /power resource. fanSpeed + # retains the selected setting while power is off (as the + # lamp's `current` field does), so every advertised code is an + # active ordered speed. + return codes def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: """Target whichever power resource this hood actually exposes.""" @@ -114,9 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: - if not self._has_separate_power(): + if self._speed_zero_is_off(): current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) - return current not in ('', '0') + return current not in ('', _OFF_SPEED_CODE) rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -142,10 +163,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: - if not self._has_separate_power(): + if self._speed_zero_is_off(): if percentage is not None: await self.async_set_percentage(percentage) return + if self.is_on: + # Already running: no percentage given means "just turn on", + # not "reset to the lowest speed". + return codes = self._active_speed_codes() if codes: await self.coordinator.async_send_command(self._bound, ('speed', codes[0])) @@ -157,8 +182,8 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: - if not self._has_separate_power(): - await self.coordinator.async_send_command(self._bound, ('speed', '0')) + if self._speed_zero_is_off(): + await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE)) return await self.coordinator.async_send_command( self._bound, self._power_payload(False), @@ -171,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if self._has_separate_power() and not self.is_on: + if not self._speed_zero_is_off() and not self.is_on: await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index cbe85fa..7e76a9a 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -14,7 +14,7 @@ shape a standalone range hood reports it in -- reused directly from range_hood.py rather than duplicated. Unlike a standalone hood, this dump has no sibling `/power/0` or `/power/vs/0` resource; fan.py's LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that -case (see its `_has_separate_power` check). +case (see its `_speed_zero_is_off` check). """ from ..capabilities import common, ignored, microwave, oven, range_hood from ._base import DeviceRegistry, _build diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index e90666b..14e7436 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -675,7 +675,7 @@ DOOR_GENERIC = Capability( # --------------------------------------------------------------------------- def _kimchi_mode_write(p, rep, href=None): - if not href: + if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()): return None return [s for s in href.strip('/').split('/') if s], { 'x.com.samsung.da.currentMode': p, @@ -697,8 +697,7 @@ KIMCHI_ZONE = Capability( SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus', use_instance_name=True, icon='mdi:progress-clock', translation_key='kimchi_ripening_status', - entity_category='diagnostic', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), + entity_category='diagnostic'), SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime', use_instance_name=True, icon='mdi:timer-sand', translation_key='kimchi_ripening_remaining', @@ -721,6 +720,13 @@ KIMCHI_DOOR_GENERIC = Capability( strip_prefix_in_key=True, poll_tier='hot', entities=( + # Not deduped against DOORS_FALLBACK below: on the one reporter + # (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the + # /doors/vs/0 aggregate carries a single generic item (id "4", no + # /door/ siblings for DOORS_FALLBACK's match_fn to see) + # that doesn't share this compartment's "top" instance numbering -- + # a distinct main-cabinet door, not this kimchi drawer's own contact + # switch reported twice. BinarySensorDesc(key='open', rep_fn=_door_open_state, translation_key='instance_open', use_instance_name=True, device_class='door'), diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 5ce2c1d..3b6399e 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -241,25 +241,85 @@ class TestKimchiZone: def test_write_derives_path_from_href(self): desc = fridge.KIMCHI_ZONE.entities[0] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} path, body = desc.write_fn( - 'KIMCHI_STORAGE_COLD', {}, href='/status/kimchi/middle/vs/0') + 'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0') assert path == ['status', 'kimchi', 'middle', 'vs', '0'] assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'} def test_write_without_href_is_rejected(self): desc = fridge.KIMCHI_ZONE.entities[0] - assert desc.write_fn('KIMCHI_STORAGE_COLD', {}) is None + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None - def test_ripening_status_is_lowercased(self): + def test_write_rejects_value_outside_supportmode(self): + """A value the compartment never advertised is rejected rather than + written blind -- this write path is unconfirmed against real + hardware (module docstring above KIMCHI_ZONE), so a bad value here + is a food-safety-adjacent outcome, not just a cosmetic one.""" + desc = fridge.KIMCHI_ZONE.entities[0] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn( + 'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0', + ) is None + + def test_ripening_status_passes_through_device_value(self): + """No device_class='enum' catalog entry exists for this sensor, so + lowercasing it would only make the raw device token un-translatable + by HA -- pass the device's own casing straight through instead.""" desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status') - assert desc.value_fn('Off') == 'off' - assert desc.value_fn(None) is None + assert desc.value_fn('Off') == 'Off' def test_door_reuses_open_state_helper(self): desc = fridge.KIMCHI_DOOR_GENERIC.entities[0] assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + async def test_zone_mode_select_round_trips_through_display_casing(self): + """kimchi_zone_mode's displayed value (lowercase, catalog-translated) + and the raw device code it writes back can silently drift apart -- + this is the one place that casing conversion could break. Runs + through the real discovery/select pipeline against the tp2x_ref_20k + kimchi fixture rather than a hand-built descriptor, so it also + catches use_instance_name key derivation going wrong.""" + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from custom_components.localthings.registry.entities import SelectDesc + from custom_components.localthings.select import LocalThingsSelect + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + bound = discover( + resources, refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + ) + mode_bound = next( + b for b in bound + if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0' + ) + + class _FakeCoordinator: + device_serial = 'TEST-SERIAL' + + def __init__(self, resources, data): + self.last_resources = resources + self.data = data + self.commands = [] + + async def async_send_command(self, bound, value): + self.commands.append(value) + + coordinator = _FakeCoordinator(resources, flatten(bound, resources)) + entity = LocalThingsSelect(coordinator, mode_bound) + + assert entity.current_option == 'kimchi_storage_normal' + assert 'kimchi_storage_cold' in entity.options + + await entity.async_select_option('kimchi_storage_cold') + + assert coordinator.commands == ['KIMCHI_STORAGE_COLD'] + class TestArtik051AndTp2xFixturesHaveCompleteCoverage: """issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a13fb98..09650ff 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -615,7 +615,7 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): a standalone hood this board has no sibling `/power/0` or `/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats fan speed 0 as the off state instead of writing a separate power - resource -- see its `_has_separate_power` check. This board also has + resource -- see its `_speed_zero_is_off` check. This board also has no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation` field, unlike MW7300B, so `setpoint`/`current_temp_c` and `automatic_operation` are correctly absent here.""" diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 4da948b..a1fbf4d 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -98,12 +98,43 @@ async def test_power_write_falls_back_to_vendor_resource(): # itself the off state. # --------------------------------------------------------------------------- +def test_standalone_hood_has_separate_power(): + """Explicit converse of the microwave case below: guards the + discriminator itself, not just its downstream effects, so a future + change to it fails loudly here instead of only via behavioral drift.""" + entity = _entity(_load_device('range_hood')) + assert entity._has_separate_power() is True + assert entity._speed_zero_is_off() is False + + def test_microwave_vent_fan_has_no_separate_power_resource(): resources = _load_device('microwave_me7500d') assert '/power/0' not in resources assert '/power/vs/0' not in resources entity = _microwave_entity(resources) assert entity._has_separate_power() is False + assert entity._speed_zero_is_off() is True + + +async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off(): + """A combi over-the-range microwave can report a /power/0 resource for + the cavity while the vent fan still has no power resource of its own + (settableMinFanSpeed '0' -- same board shape as microwave_me7500d). + _speed_zero_is_off must key off the hood resource itself, not merely + "some power resource exists on this device", so turning the fan off + writes fan speed rather than the shared cavity power resource.""" + resources = _load_device('microwave_me7500d') + resources['/power/0'] = {'value': True} + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + assert entity._has_separate_power() is True + assert entity._speed_zero_is_off() is True + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('speed', '0') def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes(): @@ -153,3 +184,37 @@ async def test_microwave_vent_fan_set_percentage_writes_speed_only(): await entity.async_set_percentage(100) assert coordinator.commands == [(entity._bound, ('speed', '4'))] + + +async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on(percentage=75) + + assert coordinator.commands == [(entity._bound, ('speed', '3'))] + + +async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop(): + """A scene or automation calling fan.turn_on on an already-running vent + fan must not reset it to the lowest speed.""" + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands == [] + + +async def test_microwave_vent_fan_set_percentage_zero_turns_off(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_set_percentage(0) + + assert coordinator.commands[-1][1] == ('speed', '0') diff --git a/tests/test_translations.py b/tests/test_translations.py index 15b58a8..94c879a 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -210,3 +210,27 @@ def test_every_ac_convenient_mode_code_has_a_preset_label(): missing.append((path.name, code)) assert missing == [] + +def test_every_kimchi_zone_supportmode_code_has_a_state_label(): + """Same guard as the AC preset one above, for KIMCHI_ZONE's + kimchi_zone_mode select (fridge.py, issue #26): the write path resolved + from options_field is fully dynamic too, so an unlabelled supportMode + code across any /status/kimchi//vs/0 resource would silently + render as its raw device token instead of the translated state. + """ + state_labels = set( + _load("en")["entity"]["select"]["kimchi_zone_mode"]["state"] + ) + fixtures_dir = Path(__file__).parent / "fixtures" + missing = [] + for path in sorted(fixtures_dir.glob("*_device.json")): + dump = json.loads(path.read_text()) + for item in dump.get("device0", []): + href = item.get("href", "") + if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")): + continue + for code in item["rep"].get("x.com.samsung.da.supportMode", []): + if code.lower() not in state_labels: + missing.append((path.name, href, code)) + assert missing == [] +