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
@@ -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
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.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:
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
@@ -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,
@@ -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,
@@ -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),
),
)
@@ -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),
),
)
@@ -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'),
@@ -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))),
),
@@ -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.<id, lowercased> 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),
),
)
@@ -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
+7
View File
@@ -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)
+2 -2
View File
@@ -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",
-1
View File
@@ -15,7 +15,6 @@
"job_beginning_status",
"machine_state",
"power_switch",
"power_watts",
"progress",
"progress_percentage",
"remote_control",
+14
View File
@@ -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')
+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/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