diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9b7ebed..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' @@ -62,7 +64,24 @@ 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. `_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 _attr_supported_features = ( @@ -78,15 +97,35 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _rep(self, href: str) -> dict: return self.coordinator.resource(href) or {} + def _has_separate_power(self) -> bool: + 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) 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._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.""" @@ -96,6 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: + if self._speed_zero_is_off(): + current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) + return current not in ('', _OFF_SPEED_CODE) rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -121,6 +163,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: + 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])) + return await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) @@ -128,6 +182,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: + 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), ) @@ -139,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if 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 c038e49..7e76a9a 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 `_speed_zero_is_off` 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/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..14e7436 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,105 @@ 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 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, + } + + +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'), + 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=( + # 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'), + ), +) + # --------------------------------------------------------------------------- # Aggregate-resource fallbacks # 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/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/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/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/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/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..3b6399e 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,97 @@ 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] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + path, body = desc.write_fn( + '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] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None + + 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' + + 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 the incomplete-capability-coverage repair; both must resolve to zero @@ -253,6 +364,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 c13b45c..09650ff 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 @@ -589,6 +606,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 `_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.""" + 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..a1fbf4d 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,131 @@ 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_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(): + """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'))] + + +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 == [] +