Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b145ccdd | ||
|
|
5fbf58af30 | ||
|
|
9ae59889d4 | ||
|
|
35f76ce2df | ||
|
|
2a6bbc9dfb | ||
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 | ||
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
@@ -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
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.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:
|
||||
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'),
|
||||
|
||||
@@ -93,9 +93,11 @@ OPERATIONAL_STATE = Capability(
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
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
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,13 @@ family). Washers never report `oneUiVersion` -- see
|
||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||
detection this device type requires.
|
||||
"""
|
||||
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
|
||||
@@ -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):
|
||||
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
return _hex_pairs(raw.split('_', 1)[1])
|
||||
|
||||
|
||||
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(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
@@ -124,6 +221,62 @@ WASHER_COURSE = Capability(
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -78,6 +78,28 @@
|
||||
"8f": "Intense Cold",
|
||||
"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": {
|
||||
|
||||
@@ -78,6 +78,28 @@
|
||||
"8f": "Intense Cold",
|
||||
"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": {
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Vendored
+4
-1
@@ -6,18 +6,21 @@
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature",
|
||||
"water_liters"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Tests for the diagnostics platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.localthings.const import DOMAIN
|
||||
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
||||
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(
|
||||
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["one_ui_version"] == '7.0 Refrigerator'
|
||||
assert diagnostics["unbound_hrefs"] == []
|
||||
assert diagnostics["integration_version"] == '0.1.0'
|
||||
assert diagnostics["integration_version"] == _MANIFEST_VERSION
|
||||
assert diagnostics["smartthings_local_version"]
|
||||
|
||||
resources = diagnostics["resources"]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
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:
|
||||
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
|
||||
@@ -112,6 +112,132 @@ class TestCycleOptions:
|
||||
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:
|
||||
def test_href(self):
|
||||
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/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