Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b145ccdd | ||
|
|
5fbf58af30 | ||
|
|
9ae59889d4 | ||
|
|
35f76ce2df | ||
|
|
2a6bbc9dfb | ||
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 | ||
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
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
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.0",
|
||||||
"smartthings-local>=0.1.0"
|
"smartthings-local>=0.1.0"
|
||||||
],
|
],
|
||||||
"version": "0.1.0"
|
"version": "0.4.0"
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ OPERATIONAL_STATE = Capability(
|
|||||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||||
)),
|
)),
|
||||||
SensorDesc(key='progress_percentage',
|
SensorDesc(key='progress_percentage',
|
||||||
field='x.com.samsung.da.progressPercentage',
|
|
||||||
name='Progress percent', unit='%', state_class='measurement',
|
name='Progress percent', unit='%', state_class='measurement',
|
||||||
value_fn=_int),
|
rep_fn=lambda rep: (
|
||||||
|
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||||
|
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||||
|
)),
|
||||||
# Only show finish time when machine is actively running. Samsung
|
# Only show finish time when machine is actively running. Samsung
|
||||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||||
|
|||||||
@@ -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))),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ family). Washers never report `oneUiVersion` -- see
|
|||||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||||
detection this device type requires.
|
detection this device type requires.
|
||||||
"""
|
"""
|
||||||
|
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
|
||||||
@@ -43,12 +47,16 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_pairs(codes):
|
||||||
|
"""'1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||||
|
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||||
|
|
||||||
|
|
||||||
def _parse_edit_course_list(raw):
|
def _parse_edit_course_list(raw):
|
||||||
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||||
if not isinstance(raw, str) or '_' not in raw:
|
if not isinstance(raw, str) or '_' not in raw:
|
||||||
return []
|
return []
|
||||||
codes = raw.split('_', 1)[1]
|
return _hex_pairs(raw.split('_', 1)[1])
|
||||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
|
||||||
|
|
||||||
|
|
||||||
def _cycle_options(resources):
|
def _cycle_options(resources):
|
||||||
@@ -114,6 +122,95 @@ def _cycle_write(p, rep, href=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||||
|
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||||
|
# recommended cleans; WashingTimes_<N> is the count since the last one --
|
||||||
|
# their difference is exactly the "N cycles until due" figure the Samsung
|
||||||
|
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
|
||||||
|
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
|
||||||
|
# is the last-clean timestamp (verified against the same screenshot's "10
|
||||||
|
# days ago"); no explicit timezone field accompanies it on this resource,
|
||||||
|
# so it's treated as UTC, matching this integration's convention for other
|
||||||
|
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
|
||||||
|
def _drum_clean_cycles_remaining(rep):
|
||||||
|
opts = rep.get('x.com.samsung.da.options') or []
|
||||||
|
proposal = _option_value(opts, 'DrumCleanProposal')
|
||||||
|
washed = _option_value(opts, 'WashingTimes')
|
||||||
|
if proposal is None or washed is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return max(int(proposal) - int(washed), 0)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _drum_clean_last_cleaned(rep):
|
||||||
|
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||||
|
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||||
|
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
||||||
|
# detergent, concentration for softener -- matching the SmartThings app's
|
||||||
|
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
||||||
|
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
||||||
|
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
||||||
|
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||||
|
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||||
|
#
|
||||||
|
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
|
||||||
|
# water_hardness/washer_softener_concentration in strings.json) is an
|
||||||
|
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||||
|
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||||
|
# on-screen equivalent -- the app's Quantité picker only offers
|
||||||
|
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
||||||
|
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
||||||
|
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
||||||
|
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
||||||
|
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
||||||
|
# 1x/2x/3x for softener concentration does *not* cleanly match
|
||||||
|
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
||||||
|
# setting the user changed in the app between the dump (issue body) and the
|
||||||
|
# screenshots (a later comment), not a different code scheme, since it's
|
||||||
|
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||||
|
# device's dump contradicts this.
|
||||||
|
def _supported_level_options(resources, prefix):
|
||||||
|
rep = resources.get('/course/vs/0') or {}
|
||||||
|
raw = _option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
|
||||||
|
return _hex_pairs(raw) if raw else []
|
||||||
|
|
||||||
|
|
||||||
|
def _level_options(prefix):
|
||||||
|
return lambda resources: _supported_level_options(resources, prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _level_write(prefix):
|
||||||
|
def write(p, rep, href=None):
|
||||||
|
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||||
|
if not opts:
|
||||||
|
return None
|
||||||
|
return ['course', 'vs', '0'], {
|
||||||
|
'x.com.samsung.da.options': _replace_in_options(opts, prefix, p),
|
||||||
|
}
|
||||||
|
return write
|
||||||
|
|
||||||
|
|
||||||
|
def _dosing_low(prefix):
|
||||||
|
return lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||||
|
|
||||||
|
|
||||||
|
def _dosing_alarm_exists(prefix):
|
||||||
|
return lambda rep, resources: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||||
|
|
||||||
|
|
||||||
WASHER_COURSE = Capability(
|
WASHER_COURSE = Capability(
|
||||||
href='/course/vs/0',
|
href='/course/vs/0',
|
||||||
entities=(
|
entities=(
|
||||||
@@ -124,6 +221,62 @@ WASHER_COURSE = Capability(
|
|||||||
rep_fn=lambda rep: _option_value(
|
rep_fn=lambda rep: _option_value(
|
||||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||||
write_fn=_cycle_write),
|
write_fn=_cycle_write),
|
||||||
|
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||||
|
icon='mdi:washing-machine-alert', unit='cycles',
|
||||||
|
state_class='measurement',
|
||||||
|
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||||
|
rep_fn=_drum_clean_cycles_remaining),
|
||||||
|
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
|
||||||
|
icon='mdi:calendar-clock', device_class='timestamp',
|
||||||
|
entity_category='diagnostic',
|
||||||
|
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||||
|
rep_fn=_drum_clean_last_cleaned),
|
||||||
|
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
|
||||||
|
translation_key='washer_dosing_quantity',
|
||||||
|
entity_category='config',
|
||||||
|
options=_level_options('DetergentLevelCtrl'),
|
||||||
|
exists_fn=lambda rep, resources: bool(
|
||||||
|
_level_options('DetergentLevelCtrl')(resources)),
|
||||||
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
|
||||||
|
write_fn=_level_write('DetergentLevelCtrl')),
|
||||||
|
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||||
|
icon='mdi:water-opacity',
|
||||||
|
translation_key='washer_detergent_water_hardness',
|
||||||
|
entity_category='config',
|
||||||
|
options=_level_options('DetergentLevel2Ctrl'),
|
||||||
|
exists_fn=lambda rep, resources: bool(
|
||||||
|
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||||
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
|
||||||
|
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||||
|
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
|
||||||
|
translation_key='washer_dosing_quantity',
|
||||||
|
entity_category='config',
|
||||||
|
options=_level_options('SoftenerLevelCtrl'),
|
||||||
|
exists_fn=lambda rep, resources: bool(
|
||||||
|
_level_options('SoftenerLevelCtrl')(resources)),
|
||||||
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
|
||||||
|
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||||
|
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||||
|
icon='mdi:flask-plus-outline',
|
||||||
|
translation_key='washer_softener_concentration',
|
||||||
|
entity_category='config',
|
||||||
|
options=_level_options('SoftenerLevel2Ctrl'),
|
||||||
|
exists_fn=lambda rep, resources: bool(
|
||||||
|
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||||
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
|
||||||
|
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||||
|
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
||||||
|
icon='mdi:alert-circle-outline', device_class='problem',
|
||||||
|
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
|
||||||
|
rep_fn=_dosing_low('DetergentAlarm')),
|
||||||
|
BinarySensorDesc(key='softener_low', name='Softener low',
|
||||||
|
icon='mdi:alert-circle-outline', device_class='problem',
|
||||||
|
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
|
||||||
|
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,3 +401,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -78,6 +78,28 @@
|
|||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"washer_dosing_quantity": {
|
||||||
|
"state": {
|
||||||
|
"00": "None",
|
||||||
|
"01": "Low",
|
||||||
|
"02": "Medium",
|
||||||
|
"03": "High"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_detergent_water_hardness": {
|
||||||
|
"state": {
|
||||||
|
"01": "Soft",
|
||||||
|
"02": "Medium",
|
||||||
|
"03": "Hard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_softener_concentration": {
|
||||||
|
"state": {
|
||||||
|
"01": "1x",
|
||||||
|
"02": "2x",
|
||||||
|
"03": "3x"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
@@ -78,6 +78,28 @@
|
|||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"washer_dosing_quantity": {
|
||||||
|
"state": {
|
||||||
|
"00": "None",
|
||||||
|
"01": "Low",
|
||||||
|
"02": "Medium",
|
||||||
|
"03": "High"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_detergent_water_hardness": {
|
||||||
|
"state": {
|
||||||
|
"01": "Soft",
|
||||||
|
"02": "Medium",
|
||||||
|
"03": "Hard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_softener_concentration": {
|
||||||
|
"state": {
|
||||||
|
"01": "1x",
|
||||||
|
"02": "2x",
|
||||||
|
"03": "3x"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
+2
-2
@@ -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",
|
||||||
|
|||||||
Vendored
+4
-1
@@ -6,18 +6,21 @@
|
|||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
"detergent_low",
|
||||||
"diagnosis_status",
|
"diagnosis_status",
|
||||||
|
"drum_clean_cycles_remaining",
|
||||||
|
"drum_clean_last_cleaned",
|
||||||
"energy_kwh",
|
"energy_kwh",
|
||||||
"finish_time",
|
"finish_time",
|
||||||
"firmware_update",
|
"firmware_update",
|
||||||
"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",
|
||||||
"rinse_cycles",
|
"rinse_cycles",
|
||||||
|
"softener_low",
|
||||||
"spin_speed",
|
"spin_speed",
|
||||||
"wash_temperature",
|
"wash_temperature",
|
||||||
"water_liters"
|
"water_liters"
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""Tests for the diagnostics platform."""
|
"""Tests for the diagnostics platform."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from custom_components.localthings.const import DOMAIN
|
from custom_components.localthings.const import DOMAIN
|
||||||
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
||||||
from custom_components.localthings.registry.redact import REDACTED
|
from custom_components.localthings.registry.redact import REDACTED
|
||||||
|
|
||||||
|
_MANIFEST_VERSION = json.loads(
|
||||||
|
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
|
||||||
|
)['version']
|
||||||
|
|
||||||
|
|
||||||
async def test_diagnostics_shape_and_redaction(
|
async def test_diagnostics_shape_and_redaction(
|
||||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||||
@@ -19,7 +26,7 @@ async def test_diagnostics_shape_and_redaction(
|
|||||||
assert diagnostics["device_type"] == 'refrigerator'
|
assert diagnostics["device_type"] == 'refrigerator'
|
||||||
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
|
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
|
||||||
assert diagnostics["unbound_hrefs"] == []
|
assert diagnostics["unbound_hrefs"] == []
|
||||||
assert diagnostics["integration_version"] == '0.1.0'
|
assert diagnostics["integration_version"] == _MANIFEST_VERSION
|
||||||
assert diagnostics["smartthings_local_version"]
|
assert diagnostics["smartthings_local_version"]
|
||||||
|
|
||||||
resources = diagnostics["resources"]
|
resources = diagnostics["resources"]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,6 +8,22 @@ def test_machine_state_maps_samsung_to_ocf():
|
|||||||
assert ms.value_fn('Ready') == 'idle'
|
assert ms.value_fn('Ready') == 'idle'
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgressPercentage:
|
||||||
|
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
||||||
|
after a cycle ends instead of resetting it, so it must be gated on
|
||||||
|
active state the same way `progress`/`cycle_active`/`finish_time` are."""
|
||||||
|
|
||||||
|
def test_zeroed_when_not_active(self):
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
|
||||||
|
rep = {'x.com.samsung.da.state': 'Ready', 'x.com.samsung.da.progressPercentage': '1'}
|
||||||
|
assert desc.rep_fn(rep) == 0
|
||||||
|
|
||||||
|
def test_passes_through_when_active(self):
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
|
||||||
|
rep = {'x.com.samsung.da.state': 'Run', 'x.com.samsung.da.progressPercentage': '42'}
|
||||||
|
assert desc.rep_fn(rep) == 42
|
||||||
|
|
||||||
|
|
||||||
class TestDelayFieldFallback:
|
class TestDelayFieldFallback:
|
||||||
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
|||||||
@@ -112,6 +112,132 @@ class TestCycleOptions:
|
|||||||
assert washer._cycle_options(resources) == []
|
assert washer._cycle_options(resources) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrumClean:
|
||||||
|
def test_cycles_remaining(self):
|
||||||
|
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
|
||||||
|
app screenshot's 'Potreba cistenia po 37 cykloch'."""
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_cycles_remaining')
|
||||||
|
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
|
||||||
|
assert desc.rep_fn(rep) == 37
|
||||||
|
|
||||||
|
def test_cycles_remaining_never_negative(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_cycles_remaining')
|
||||||
|
rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']}
|
||||||
|
assert desc.rep_fn(rep) == 0
|
||||||
|
|
||||||
|
def test_cycles_remaining_missing_fields(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_cycles_remaining')
|
||||||
|
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
|
||||||
|
|
||||||
|
def test_cycles_remaining_exists_only_when_computable(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_cycles_remaining')
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||||
|
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
|
||||||
|
assert desc.exists_fn(rep, {}) is True
|
||||||
|
|
||||||
|
def test_last_cleaned(self):
|
||||||
|
"""DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime,
|
||||||
|
matching the same screenshot's '10 days ago' (as of 2026-07-11)."""
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_last_cleaned')
|
||||||
|
rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']}
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def test_last_cleaned_missing(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key == 'drum_clean_last_cleaned')
|
||||||
|
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# Raw options array from issue #9's diagnostic dump (trimmed to the fields
|
||||||
|
# relevant to detergent/softener dosing).
|
||||||
|
_DOSING_OPTIONS = [
|
||||||
|
'Course_1C',
|
||||||
|
'DetergentAlarm_Off',
|
||||||
|
'SoftenerAlarm_Off',
|
||||||
|
'DetergentLevelCtrl_3',
|
||||||
|
'SoftenerLevelCtrl_3',
|
||||||
|
'SupportedDetergentLevelCtrl_00010203',
|
||||||
|
'SupportedSoftenerLevelCtrl_00010203',
|
||||||
|
'DetergentLevel2Ctrl_2',
|
||||||
|
'SoftenerLevel2Ctrl_2',
|
||||||
|
'SupportedDetergentLevel2Ctrl_010203',
|
||||||
|
'SupportedSoftenerLevel2Ctrl_010203',
|
||||||
|
]
|
||||||
|
_DOSING_RESOURCES = {'/course/vs/0': {'x.com.samsung.da.options': _DOSING_OPTIONS}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetergentSoftenerDosing:
|
||||||
|
@staticmethod
|
||||||
|
def _desc(key):
|
||||||
|
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||||
|
|
||||||
|
def test_quantity_and_hardness_read(self):
|
||||||
|
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||||
|
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
|
||||||
|
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
|
||||||
|
assert self._desc('softener_quantity').rep_fn(rep) == '3'
|
||||||
|
assert self._desc('softener_concentration').rep_fn(rep) == '2'
|
||||||
|
|
||||||
|
def test_translation_keys(self):
|
||||||
|
"""detergent_quantity and softener_quantity share one translation_key
|
||||||
|
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
|
||||||
|
same shape as fridge.py's shared 'brightness_level' key); hardness
|
||||||
|
and concentration each have their own since their labels differ."""
|
||||||
|
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
|
||||||
|
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
|
||||||
|
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
|
||||||
|
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
|
||||||
|
|
||||||
|
def test_quantity_and_hardness_options_decode_supported_list(self):
|
||||||
|
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
|
||||||
|
assert self._desc('softener_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
|
||||||
|
assert self._desc('detergent_water_hardness').options(_DOSING_RESOURCES) == ['01', '02', '03']
|
||||||
|
assert self._desc('softener_concentration').options(_DOSING_RESOURCES) == ['01', '02', '03']
|
||||||
|
|
||||||
|
def test_exists_only_when_supported_list_present(self):
|
||||||
|
for key in ('detergent_quantity', 'detergent_water_hardness',
|
||||||
|
'softener_quantity', 'softener_concentration'):
|
||||||
|
desc = self._desc(key)
|
||||||
|
assert desc.exists_fn({}, {}) is False
|
||||||
|
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
|
||||||
|
|
||||||
|
def test_quantity_write(self):
|
||||||
|
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||||
|
path, body = self._desc('detergent_quantity').write_fn('01', rep)
|
||||||
|
assert path == ['course', 'vs', '0']
|
||||||
|
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
|
||||||
|
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
|
||||||
|
|
||||||
|
def test_hardness_write(self):
|
||||||
|
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||||
|
path, body = self._desc('softener_concentration').write_fn('03', rep)
|
||||||
|
assert path == ['course', 'vs', '0']
|
||||||
|
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
|
||||||
|
|
||||||
|
def test_low_reservoir_off_when_alarm_off(self):
|
||||||
|
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||||
|
assert self._desc('detergent_low').rep_fn(rep) is False
|
||||||
|
assert self._desc('softener_low').rep_fn(rep) is False
|
||||||
|
|
||||||
|
def test_low_reservoir_on_when_alarm_active(self):
|
||||||
|
opts = ['DetergentAlarm_On', 'SoftenerAlarm_On']
|
||||||
|
rep = {'x.com.samsung.da.options': opts}
|
||||||
|
assert self._desc('detergent_low').rep_fn(rep) is True
|
||||||
|
assert self._desc('softener_low').rep_fn(rep) is True
|
||||||
|
|
||||||
|
def test_low_reservoir_exists_only_when_alarm_field_present(self):
|
||||||
|
assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||||
|
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||||
|
assert self._desc('detergent_low').exists_fn(rep, {}) is True
|
||||||
|
|
||||||
|
|
||||||
class TestBuzzerSound:
|
class TestBuzzerSound:
|
||||||
def test_href(self):
|
def test_href(self):
|
||||||
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||||
@@ -195,3 +321,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
|
||||||
Reference in new issue
Block a user