Compare commits

...
3 Commits
Author SHA1 Message Date
Marc Billow aee19fd953 Merge branch 'fix-washer-fridge-oven-issues'
* fix-washer-fridge-oven-issues:
  chore: bump manifest version to 0.3.0
  fix(washer,fridge,oven): correct energy/temperature-unit bugs from issues #6/#7
2026-07-15 16:02:05 -05:00
Marc Billow 37bd063c0a chore: bump manifest version to 0.3.0 2026-07-15 15:59:24 -05:00
Marc Billow e875266405 fix(washer,fridge,oven): correct energy/temperature-unit bugs from issues #6/#7
Washer (#6): instantaneousPower is a dead sentinel ('-500') on every
TP1-class washer dump collected so far, and cumulativePower is absent
outright on at least one model. WASHER_ENERGY_METER now hides both
sensors instead of showing a misleading "0 W"/perpetual "unavailable".

Fridge (#7): temperature sensors/setpoints hardcoded '°F', ignoring the
unit each device actually reports per-reading -- fixed via a new
unit_fn hook read live from the resource. Also corrects
DEFROST_BLOCK_STATUS's polarity (DEFROST_BLOCK_ON means actively
defrosting, not "blocked", confirmed against live dumps) and adds
REFRIGERATION_FALLBACK for /refrigeration/0, closing the last unbound
href surfaced by issue #7's diagnostic dump.

Oven: applies the same live-unit-reading fix defensively to
OVEN_SETPOINT, which shares the same aggregate resource shape.
2026-07-15 11:41:21 -05:00
17 changed files with 321 additions and 27 deletions

No files matched your search

@@ -115,6 +115,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def last_resources(self) -> dict: def last_resources(self) -> dict:
return self._cache.snapshot() 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 @property
def observe_mode(self) -> str: def observe_mode(self) -> str:
return self._observe.mode return self._observe.mode
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0", "pyOpenSSL>=23.0",
"smartthings-local>=0.1.0" "smartthings-local>=0.1.0"
], ],
"version": "0.2.1" "version": "0.3.0"
} }
+8 -2
View File
@@ -42,12 +42,18 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
if desc.step is not None: if desc.step is not None:
self._attr_native_step = desc.step 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: def _range_from_resource(self) -> list | None:
desc: NumberDesc = self._bound.desc desc: NumberDesc = self._bound.desc
if not desc.range_field: if not desc.range_field:
return None return None
rep = self.coordinator.last_resources.get(self._bound.href) or {} r = self.coordinator.resource(self._bound.href).get(desc.range_field)
r = rep.get(desc.range_field)
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
@property @property
@@ -30,6 +30,7 @@ REGISTRY = DeviceRegistry(
fridge.DOORS_FALLBACK, fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK, fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK,
fridge.REFRIGERATION_FALLBACK,
]), ]),
pattern_capabilities=[ pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC, fridge.TEMP_CURRENT_GENERIC,
@@ -17,7 +17,7 @@ REGISTRY = DeviceRegistry(
washer.BUZZER_SOUND, washer.BUZZER_SOUND,
washer.WASHER_JOB_BEGINNING_STATUS, washer.WASHER_JOB_BEGINNING_STATUS,
common.ALARMS, common.ALARMS,
common.ENERGY_METER, washer.WASHER_ENERGY_METER,
common.WATER_METER, common.WATER_METER,
operational.OPERATIONAL_STATE, operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS, dishwasher.DIAGNOSIS,
@@ -21,16 +21,29 @@ def _num(v):
return None return None
def _clamp_power(v): def clamp_power(v):
n = _num(v) n = _num(v)
return 0.0 if (n is not None and n < 0) else n 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) n = _num(v)
return round(n / 1000.0, 2) if n is not None else None 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): def _ml_to_l(v):
n = _num(v) n = _num(v)
return round(n / 1000.0, 1) if n is not None else None return round(n / 1000.0, 1) if n is not None else None
@@ -90,10 +103,10 @@ ENERGY_METER = Capability(
entities=( entities=(
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
name='Power', device_class='power', state_class='measurement', 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', SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
name='Energy', device_class='energy', 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),
), ),
) )
@@ -2,9 +2,13 @@
Resources verified against the dump at local-tools/dumps/10.0.0.254.json. 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 Temperature unit is read live from each resource, not assumed: the RF9000B
model. Setpoints are NumberDesc with direct-write write_fn — generic caps dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
derive the CoAP PUT path from href at write time. 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 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 /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, BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc, TimeDesc, SwitchDesc, TimeDesc,
) )
from .common import normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and # Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in strings.json / translations/en.json, # ice-making-status enums below live in strings.json / translations/en.json,
@@ -33,6 +38,12 @@ def _int(v):
return None 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/*) # Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -45,7 +56,7 @@ TEMP_CURRENT_GENERIC = Capability(
entities=( entities=(
SensorDesc(key='temperature', field='temperature', SensorDesc(key='temperature', field='temperature',
name=None, icon='mdi:thermometer', name=None, icon='mdi:thermometer',
device_class='temperature', unit='°F', device_class='temperature', unit_fn=_temp_unit,
state_class='measurement'), state_class='measurement'),
), ),
) )
@@ -57,7 +68,7 @@ TEMP_SETPOINT_GENERIC = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
NumberDesc(key='setpoint', field='temperature', 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, native_min=-20.0, native_max=50.0,
range_field='range', entity_category='config', range_field='range', entity_category='config',
write_fn=lambda p, rep, href=None: ( 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 # /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
# reports whether the defrost cycle is currently blocked as a result (a # defrost. /defrost/block/vs/0 is an unrelated, independently-varying
# derived status, not an independent control) — exposed as a read-only # status: despite its "block" naming (originally assumed to mean "defrost
# diagnostic binary sensor. # 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( DEFROST_DELAY = Capability(
@@ -198,8 +213,8 @@ DEFROST_BLOCK_STATUS = Capability(
href='/defrost/block/vs/0', href='/defrost/block/vs/0',
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes', BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
name='Defrost blocked', icon='mdi:snowflake-off', name='Defrost active', icon='mdi:snowflake-melt',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'), value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
), ),
@@ -590,6 +605,13 @@ def _temp_item_value(items, keyword):
return None 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( TEMPERATURES_FALLBACK = Capability(
href='/temperatures/vs/0', href='/temperatures/vs/0',
match_fn=lambda rep, resources: not _any_temperature_generic(resources), match_fn=lambda rep, resources: not _any_temperature_generic(resources),
@@ -597,11 +619,15 @@ TEMPERATURES_FALLBACK = Capability(
entities=( entities=(
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items', SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
name='Freezer temperature', icon='mdi:thermometer', 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')), value_fn=lambda items: _temp_item_value(items, 'Freezer')),
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items', SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
name='Fridge temperature', icon='mdi:thermometer', 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')), 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'})), {'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),
),
)
@@ -89,6 +89,11 @@ IGNORED: list[Capability] = [
# supportedAiLevel on hardware seen so far, so there's no real choice # supportedAiLevel on hardware seen so far, so there's no real choice
# to expose. Revisit if a device surfaces more than one supported level. # to expose. Revisit if a device surfaces more than one supported level.
Capability(href='/energy/ailevel/vs/0'), 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 # Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone. # against — meaning unclear from the raw resource alone.
Capability(href='/runningmode/vs/0'), Capability(href='/runningmode/vs/0'),
@@ -29,6 +29,7 @@ from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc, SwitchDesc,
) )
from .common import normalize_temp_unit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # 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( OVEN_SETPOINT = Capability(
href='/temperatures/vs/0', href='/temperatures/vs/0',
poll_tier='hot', poll_tier='hot',
entities=( entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0] # NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items', 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), native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
value_fn=lambda items: _int( value_fn=lambda items: _int(
@@ -269,7 +283,7 @@ OVEN_SETPOINT = Capability(
write_fn=_oven_setpoint_write), write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items', SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
name='Temperature', device_class='temperature', name='Temperature', device_class='temperature',
state_class='measurement', unit='°C', state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int( value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))), (items[0].get('x.com.samsung.da.current') if items else None))),
), ),
@@ -11,6 +11,8 @@ from datetime import datetime, timezone
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc 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 # Course_XX hex codes. The 23 codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured # under entity.select.washer_cycle.state.<id, lowercased> were captured
@@ -292,3 +294,40 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
value_fn=lambda v: str(v).lower() == 'true'), 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),
),
)
@@ -38,6 +38,7 @@ class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None device_class: Optional[str] = None
state_class: Optional[str] = None state_class: Optional[str] = None
unit: 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' options: Optional[tuple] = None # required by HA when device_class == 'enum'
@@ -72,6 +73,7 @@ class ButtonDesc(SamsungEntityDescription):
class NumberDesc(SamsungEntityDescription): class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None device_class: Optional[str] = None
unit: 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_min: Optional[float] = None
native_max: Optional[float] = None native_max: Optional[float] = None
step: Optional[float] = None step: Optional[float] = None
+7
View File
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
if desc.options: if desc.options:
self._attr_options = list(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 @property
def native_value(self): def native_value(self):
return (self.coordinator.data or {}).get(self._state_key) return (self.coordinator.data or {}).get(self._state_key)
+2 -2
View File
@@ -10,7 +10,7 @@
"cooler_setpoint", "cooler_setpoint",
"cooler_temperature", "cooler_temperature",
"day_brightness", "day_brightness",
"defrost_blocked", "defrost_active",
"defrost_delay", "defrost_delay",
"door_alert", "door_alert",
"door_cooler_open", "door_cooler_open",
@@ -50,7 +50,7 @@
"samsung_refrigerator_cooler_setpoint", "samsung_refrigerator_cooler_setpoint",
"samsung_refrigerator_cooler_temperature", "samsung_refrigerator_cooler_temperature",
"samsung_refrigerator_day_brightness", "samsung_refrigerator_day_brightness",
"samsung_refrigerator_defrost_blocked", "samsung_refrigerator_defrost_active",
"samsung_refrigerator_defrost_delay", "samsung_refrigerator_defrost_delay",
"samsung_refrigerator_door_alert", "samsung_refrigerator_door_alert",
"samsung_refrigerator_door_cooler_open", "samsung_refrigerator_door_cooler_open",
-1
View File
@@ -15,7 +15,6 @@
"job_beginning_status", "job_beginning_status",
"machine_state", "machine_state",
"power_switch", "power_switch",
"power_watts",
"progress", "progress",
"progress_percentage", "progress_percentage",
"remote_control", "remote_control",
+14
View File
@@ -154,6 +154,20 @@ class TestForDeviceByModel:
assert reg is not None assert reg is not None
assert reg.name == 'refrigerator' 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): def test_unknown_model_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
+104
View File
@@ -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
+26
View File
@@ -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/vs/0': {}}) is True
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn( assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn(
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False {}, {'/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