diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index dd4d624..a7a9c6a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -115,6 +115,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): def last_resources(self) -> dict: return self._cache.snapshot() + def resource(self, href: str) -> dict: + """A single href's current rep. Cheaper than `last_resources.get(href)` + for callers that only need one href — `last_resources` copies every + tracked href's rep to build the snapshot dict, while this is a + direct O(1) cache lookup.""" + return self._cache.get(href) or {} + @property def observe_mode(self) -> str: return self._observe.mode diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index f61fbf0..db47bc2 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.2.1" + "version": "0.3.0" } diff --git a/custom_components/localthings/number.py b/custom_components/localthings/number.py index b001374..2d8358b 100644 --- a/custom_components/localthings/number.py +++ b/custom_components/localthings/number.py @@ -42,12 +42,18 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity): if desc.step is not None: self._attr_native_step = desc.step + @property + def native_unit_of_measurement(self): + desc: NumberDesc = self._bound.desc + if desc.unit_fn is not None: + return desc.unit_fn(self.coordinator.resource(self._bound.href)) + return self._attr_native_unit_of_measurement + def _range_from_resource(self) -> list | None: desc: NumberDesc = self._bound.desc if not desc.range_field: return None - rep = self.coordinator.last_resources.get(self._bound.href) or {} - r = rep.get(desc.range_field) + r = self.coordinator.resource(self._bound.href).get(desc.range_field) return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None @property diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 89de9de..2e05db1 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -30,6 +30,7 @@ REGISTRY = DeviceRegistry( fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, + fridge.REFRIGERATION_FALLBACK, ]), pattern_capabilities=[ fridge.TEMP_CURRENT_GENERIC, diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index 22f2f9a..9b5fcf7 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -17,7 +17,7 @@ REGISTRY = DeviceRegistry( washer.BUZZER_SOUND, washer.WASHER_JOB_BEGINNING_STATUS, common.ALARMS, - common.ENERGY_METER, + washer.WASHER_ENERGY_METER, common.WATER_METER, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index f5e9905..806822b 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -21,16 +21,29 @@ def _num(v): return None -def _clamp_power(v): +def clamp_power(v): n = _num(v) return 0.0 if (n is not None and n < 0) else n -def _wh_to_kwh(v): +def wh_to_kwh(v): n = _num(v) return round(n / 1000.0, 2) if n is not None else None +def normalize_temp_unit(raw, default='°F'): + """'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to + `default` for any other/missing value. Shared by fridge.py and oven.py, + both of which read a per-device unit off a `/temperature*` resource + instead of assuming one (see fridge.py's module docstring, issue #7).""" + raw = (raw or '').strip().upper() + if raw.startswith('C'): + return '°C' + if raw.startswith('F'): + return '°F' + return default + + def _ml_to_l(v): n = _num(v) return round(n / 1000.0, 1) if n is not None else None @@ -90,10 +103,10 @@ ENERGY_METER = Capability( entities=( SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', name='Power', device_class='power', state_class='measurement', - unit='W', value_fn=_clamp_power), + unit='W', value_fn=clamp_power), SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', name='Energy', device_class='energy', - state_class='total_increasing', unit='kWh', value_fn=_wh_to_kwh), + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 3119f2f..1f056e7 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -2,9 +2,13 @@ Resources verified against the dump at local-tools/dumps/10.0.0.254.json. -Temperature fields in all /temperature/* resources are in Fahrenheit on this -model. Setpoints are NumberDesc with direct-write write_fn — generic caps -derive the CoAP PUT path from href at write time. +Temperature unit is read live from each resource, not assumed: the RF9000B +dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"), +but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the +exact same resources — the device tells you which one it is, it's just never +been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are +NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path +from href at write time. Multi-instance note: the two door resources (/door/cooler/0 and /door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and @@ -18,6 +22,7 @@ from ..entities import ( BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc, ) +from .common import normalize_temp_unit # Display names for the beverage zone, flex zone, ice type, and # ice-making-status enums below live in strings.json / translations/en.json, @@ -33,6 +38,12 @@ def _int(v): return None +def _temp_unit(rep): + """'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to + °F (this module's original assumption) if the device omits the field.""" + return normalize_temp_unit(rep.get('units')) + + # --------------------------------------------------------------------------- # Temperature (generic — covers /temperature/current/* and /temperature/desired/*) # --------------------------------------------------------------------------- @@ -45,7 +56,7 @@ TEMP_CURRENT_GENERIC = Capability( entities=( SensorDesc(key='temperature', field='temperature', name=None, icon='mdi:thermometer', - device_class='temperature', unit='°F', + device_class='temperature', unit_fn=_temp_unit, state_class='measurement'), ), ) @@ -57,7 +68,7 @@ TEMP_SETPOINT_GENERIC = Capability( poll_tier='warm', entities=( NumberDesc(key='setpoint', field='temperature', - name=None, device_class='temperature', unit='°F', + name=None, device_class='temperature', unit_fn=_temp_unit, native_min=-20.0, native_max=50.0, range_field='range', entity_category='config', write_fn=lambda p, rep, href=None: ( @@ -172,12 +183,16 @@ STATUS_LOCK = Capability( ) # --------------------------------------------------------------------------- -# Defrost delay +# Defrost delay / active-defrost status # -# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just -# reports whether the defrost cycle is currently blocked as a result (a -# derived status, not an independent control) — exposed as a read-only -# diagnostic binary sensor. +# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled +# defrost. /defrost/block/vs/0 is an unrelated, independently-varying +# status: despite its "block" naming (originally assumed to mean "defrost +# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the +# defrost cycle is *actively running* right now, seen with defrost_delay +# off -- i.e. "block" refers to the evaporator/coil block being defrosted, +# not a blocking/prevention state. Exposed as a read-only diagnostic +# binary sensor. # --------------------------------------------------------------------------- DEFROST_DELAY = Capability( @@ -198,8 +213,8 @@ DEFROST_BLOCK_STATUS = Capability( href='/defrost/block/vs/0', poll_tier='warm', entities=( - BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes', - name='Defrost blocked', icon='mdi:snowflake-off', + BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes', + name='Defrost active', icon='mdi:snowflake-melt', entity_category='diagnostic', value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'), ), @@ -590,6 +605,13 @@ def _temp_item_value(items, keyword): return None +def _temp_item_unit(items, keyword): + for item in (items or []): + if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower(): + return normalize_temp_unit(item.get('x.com.samsung.da.unit')) + return '°F' + + TEMPERATURES_FALLBACK = Capability( href='/temperatures/vs/0', match_fn=lambda rep, resources: not _any_temperature_generic(resources), @@ -597,11 +619,15 @@ TEMPERATURES_FALLBACK = Capability( entities=( SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items', name='Freezer temperature', icon='mdi:thermometer', - device_class='temperature', unit='°F', state_class='measurement', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: _temp_item_unit( + rep.get('x.com.samsung.da.items'), 'Freezer'), value_fn=lambda items: _temp_item_value(items, 'Freezer')), SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items', name='Fridge temperature', icon='mdi:thermometer', - device_class='temperature', unit='°F', state_class='measurement', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: _temp_item_unit( + rep.get('x.com.samsung.da.items'), 'Fridge'), value_fn=lambda items: _temp_item_value(items, 'Fridge')), ), ) @@ -628,3 +654,34 @@ ICEMAKER_STATUS_FALLBACK = Capability( {'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})), ), ) + +# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out +# in this section: its three fields duplicate two *different* richer +# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and +# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a +# single capability-level match_fn can't express it. Gated per-entity +# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when +# REFRIGERATION's href is present; defrost_active only when +# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so +# these are read-only, unlike REFRIGERATION's switches. +REFRIGERATION_FALLBACK = Capability( + href='/refrigeration/0', + poll_tier='warm', + entities=( + BinarySensorDesc(key='defrost_active', field='defrost', + name='Defrost active', icon='mdi:snowflake-melt', + entity_category='diagnostic', + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources), + BinarySensorDesc(key='rapid_fridge', field='rapidCool', + name='Rapid fridge', icon='mdi:fridge-industrial', + entity_category='config', + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), + BinarySensorDesc(key='rapid_freezing', field='rapidFreeze', + name='Rapid freezing', icon='mdi:snowflake', + entity_category='config', + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), + ), +) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 3985fa5..7048042 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -89,6 +89,11 @@ IGNORED: list[Capability] = [ # supportedAiLevel on hardware seen so far, so there's no real choice # to expose. Revisit if a device surfaces more than one supported level. Capability(href='/energy/ailevel/vs/0'), + # OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF') + # has ever been seen in `modes` (issue #7's dump), same "no real choice + # to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a + # device surfaces it toggled on. + Capability(href='/mode/0'), # Opaque integer with no supportedModes/options list to interpret it # against — meaning unclear from the raw resource alone. Capability(href='/runningmode/vs/0'), diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 4fc07af..2780335 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -29,6 +29,7 @@ from ..entities import ( BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) +from .common import normalize_temp_unit # --------------------------------------------------------------------------- # Constants @@ -255,13 +256,26 @@ OVEN_CAVITY = Capability( ), ) +def _oven_temp_unit(rep): + """Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the + same aggregate `/temperatures/vs/0` items[] resource type, which on + fridge hardware carries a per-item `x.com.samsung.da.unit` field + ('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7). + No live oven dump has surfaced a non-Celsius reading yet, so this keeps + the verified '°C' default when the field is absent, but reads it live + if a device ever reports otherwise.""" + items = rep.get('x.com.samsung.da.items') or [] + unit = items[0].get('x.com.samsung.da.unit') if items else None + return normalize_temp_unit(unit, default='°C') + + OVEN_SETPOINT = Capability( href='/temperatures/vs/0', poll_tier='hot', entities=( # NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0] NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items', - name='Setpoint', device_class='temperature', unit='°C', + name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit, native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C), step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', value_fn=lambda items: _int( @@ -269,7 +283,7 @@ OVEN_SETPOINT = Capability( write_fn=_oven_setpoint_write), SensorDesc(key='current_temp_c', field='x.com.samsung.da.items', name='Temperature', device_class='temperature', - state_class='measurement', unit='°C', + state_class='measurement', unit_fn=_oven_temp_unit, value_fn=lambda items: _int( (items[0].get('x.com.samsung.da.current') if items else None))), ), diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 38b63c2..f5e2f16 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -11,6 +11,8 @@ from datetime import datetime, timezone from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import clamp_power, wh_to_kwh + # --------------------------------------------------------------------------- # Course_XX hex codes. The 23 codes named in strings.json/translations # under entity.select.washer_cycle.state. were captured @@ -292,3 +294,40 @@ REMOTE_CONTROL_VS_FALLBACK = Capability( value_fn=lambda v: str(v).lower() == 'true'), ), ) + +# --------------------------------------------------------------------------- +# /energy/consumption/vs/0 -- washer-specific override of common.ENERGY_METER +# (issue #6). +# +# instantaneousPower is a dead field on every TP1-class washer dump collected +# so far (7 dumps, 3 physical devices): always the literal sentinel '-500', +# unchanged between off/idle-on/mid-cycle states and across different +# courses. common.clamp_power floors that to a misleading "0 W" that reads +# as a real (if idle) measurement rather than "unsupported". Gate the entity +# out entirely when the sentinel is seen, but only then -- if some washer +# model ever reports a real value, this still shows it. +# +# cumulativePower is absent outright on at least one washer model (issue #6), +# unlike every other washer dump. entity.py's generic field-presence gate +# already excludes the entity in that case; the exists_fn here just makes +# that explicit at the capability level instead of relying on the fallback. +# --------------------------------------------------------------------------- + +_DEAD_INSTANTANEOUS_POWER = '-500' + + +WASHER_ENERGY_METER = Capability( + href='/energy/consumption/vs/0', + entities=( + SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', + name='Power', device_class='power', state_class='measurement', + unit='W', value_fn=clamp_power, + exists_fn=lambda rep, resources: ( + rep.get('x.com.samsung.da.instantaneousPower') + != _DEAD_INSTANTANEOUS_POWER)), + SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', + name='Energy', device_class='energy', + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep), + ), +) diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 81a62a2..0a25b0c 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -38,6 +38,7 @@ class SensorDesc(SamsungEntityDescription): device_class: Optional[str] = None state_class: Optional[str] = None unit: Optional[str] = None + unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set options: Optional[tuple] = None # required by HA when device_class == 'enum' @@ -72,6 +73,7 @@ class ButtonDesc(SamsungEntityDescription): class NumberDesc(SamsungEntityDescription): device_class: Optional[str] = None unit: Optional[str] = None + unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set native_min: Optional[float] = None native_max: Optional[float] = None step: Optional[float] = None diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index 2752758..75f575a 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity): if desc.options: self._attr_options = list(desc.options) + @property + def native_unit_of_measurement(self): + desc: SensorDesc = self._bound.desc + if desc.unit_fn is not None: + return desc.unit_fn(self.coordinator.resource(self._bound.href)) + return self._attr_native_unit_of_measurement + @property def native_value(self): return (self.coordinator.data or {}).get(self._state_key) diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index cd6eef6..96e4ac2 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -10,7 +10,7 @@ "cooler_setpoint", "cooler_temperature", "day_brightness", - "defrost_blocked", + "defrost_active", "defrost_delay", "door_alert", "door_cooler_open", @@ -50,7 +50,7 @@ "samsung_refrigerator_cooler_setpoint", "samsung_refrigerator_cooler_temperature", "samsung_refrigerator_day_brightness", - "samsung_refrigerator_defrost_blocked", + "samsung_refrigerator_defrost_active", "samsung_refrigerator_defrost_delay", "samsung_refrigerator_door_alert", "samsung_refrigerator_door_cooler_open", diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index da05537..d889cc0 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -15,7 +15,6 @@ "job_beginning_status", "machine_state", "power_switch", - "power_watts", "progress", "progress_percentage", "remote_control", diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 8c2ea64..4b50ad4 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -154,6 +154,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'refrigerator' + def test_refrigerator_rl_series_via_ref_token(self): + """Issue #7: RL38C6B0CWW/EG (a bottom-freezer RL-series fridge, not + the RF9000-style french-door this module was originally verified + against) reports description/modelNum 'TP1X_REF_21K' -- same + internal platform code as any other TP1X-based fridge, so the + existing '_REF_' fallback already resolves it correctly.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_REF_21K|00156941|00050126001611304100000031010000', + 'TP1X_REF_21K', + ) + assert reg is not None + assert reg.name == 'refrigerator' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py new file mode 100644 index 0000000..a45e7f7 --- /dev/null +++ b/tests/test_fridge_capabilities.py @@ -0,0 +1,104 @@ +"""Tests for fridge-specific capabilities.""" +from custom_components.localthings.registry.capabilities import fridge + + +class TestTempCurrentGeneric: + """Issue #7: unit must be read live from the device, not assumed to be + Fahrenheit -- a TP1X_REF_21K fridge (RL38C6B0CWW/EG) reports the same + resource in Celsius.""" + + def test_unit_reads_celsius(self): + desc = fridge.TEMP_CURRENT_GENERIC.entities[0] + assert desc.unit_fn({'temperature': 3.0, 'units': 'C'}) == '°C' + + def test_unit_reads_fahrenheit(self): + desc = fridge.TEMP_CURRENT_GENERIC.entities[0] + assert desc.unit_fn({'temperature': 5.0, 'units': 'F'}) == '°F' + + def test_unit_defaults_to_fahrenheit_when_missing(self): + desc = fridge.TEMP_CURRENT_GENERIC.entities[0] + assert desc.unit_fn({'temperature': 5.0}) == '°F' + + +class TestTempSetpointGeneric: + def test_unit_reads_celsius(self): + desc = fridge.TEMP_SETPOINT_GENERIC.entities[0] + assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C' + + +class TestTemperaturesFallback: + """Aggregate /temperatures/vs/0 -- per-item unit from + x.com.samsung.da.unit ('Celsius'/'Fahrenheit').""" + + def test_freezer_unit_celsius(self): + desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities + if e.key == 'freezer_temperature') + rep = {'x.com.samsung.da.items': [ + {'x.com.samsung.da.description': 'Freezer', 'x.com.samsung.da.current': '-19', + 'x.com.samsung.da.unit': 'Celsius'}, + {'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '3', + 'x.com.samsung.da.unit': 'Celsius'}, + ]} + assert desc.unit_fn(rep) == '°C' + assert desc.value_fn(rep['x.com.samsung.da.items']) == -19 + + def test_fridge_unit_fahrenheit(self): + desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities + if e.key == 'fridge_temperature') + rep = {'x.com.samsung.da.items': [ + {'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '37', + 'x.com.samsung.da.unit': 'Fahrenheit'}, + ]} + assert desc.unit_fn(rep) == '°F' + + def test_unit_defaults_to_fahrenheit_when_item_missing(self): + desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities + if e.key == 'freezer_temperature') + assert desc.unit_fn({'x.com.samsung.da.items': []}) == '°F' + + +class TestDefrostBlockStatus: + """DEFROST_BLOCK_ON means the defrost cycle is actively running, not + that defrost is being withheld -- confirmed against live dumps showing + it ON while defrost_delay is off.""" + + def test_key_and_name_reflect_active_defrosting(self): + desc = fridge.DEFROST_BLOCK_STATUS.entities[0] + assert desc.key == 'defrost_active' + + def test_value_fn(self): + desc = fridge.DEFROST_BLOCK_STATUS.entities[0] + assert desc.value_fn(['DEFROST_BLOCK_ON']) is True + assert desc.value_fn(['DEFROST_BLOCK_OFF']) is False + + +class TestRefrigerationFallback: + """/refrigeration/0 duplicates two of REFRIGERATION's (/refrigeration/vs/0) + fields under different names (rapidFreeze/rapidCool vs + rapidFreezing/rapidFridge) but also carries one genuinely new field + (defrost) that has no vs-href equivalent.""" + + def test_href(self): + assert fridge.REFRIGERATION_FALLBACK.href == '/refrigeration/0' + + def test_defrost_active_is_fallback_of_defrost_block_status(self): + # Duplicates DEFROST_BLOCK_STATUS's defrost_active + # (/defrost/block/vs/0) -- only a true fallback when that richer + # href is absent (issue #7's device: /refrigeration/0 only). + desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities + if e.key == 'defrost_active') + assert desc.value_fn(True) is True + assert desc.value_fn(False) is False + assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True + assert desc.exists_fn( + {}, {'/refrigeration/0': {}, '/defrost/block/vs/0': {}}) is False + + def test_rapid_switches_hidden_when_vs_href_present(self): + for key in ('rapid_fridge', 'rapid_freezing'): + desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) + assert desc.exists_fn({}, {'/refrigeration/vs/0': {}, '/refrigeration/0': {}}) is False + + def test_rapid_switches_shown_when_vs_href_absent(self): + for key in ('rapid_fridge', 'rapid_freezing'): + desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) + assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index beff592..2d26883 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -238,3 +238,29 @@ class TestRemoteControlFallback: assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn( {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False + + +class TestWasherEnergyMeter: + """Issue #6: instantaneousPower is a dead sentinel ('-500', unchanged + across off/idle-on/running-eco/running-fabrics states and across 3 + physical devices) on every TP1-class washer dump collected so far; + cumulativePower is outright absent on at least one washer model.""" + + def test_href(self): + assert washer.WASHER_ENERGY_METER.href == '/energy/consumption/vs/0' + + def test_power_watts_hidden_for_dead_sentinel(self): + desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts') + assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + + def test_power_watts_shown_for_real_value(self): + desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts') + assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True + + def test_energy_kwh_hidden_when_cumulative_power_absent(self): + desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh') + assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + + def test_energy_kwh_shown_when_present(self): + desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh') + assert desc.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True