Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf22f3b3fe | ||
|
|
211cf74296 | ||
|
|
29b9e36699 | ||
|
|
f4eca9a834 | ||
|
|
22c5295d18 | ||
|
|
2852a21c4a | ||
|
|
779c2f9881 | ||
|
|
1e70380ae3 | ||
|
|
219cb70b77 | ||
|
|
1b5e39ba46 | ||
|
|
2868938d37 |
No files matched your search
@@ -125,7 +125,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
|||||||
import cbor2
|
import cbor2
|
||||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
from .registry.batch import parse_device0_batch
|
from .registry.batch import parse_device0_batch
|
||||||
from .registry.by_type import for_device
|
from .registry.by_type import for_device, for_device_by_model
|
||||||
|
|
||||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||||
try:
|
try:
|
||||||
@@ -177,15 +177,20 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
|||||||
.get('swVersionInfo', {})
|
.get('swVersionInfo', {})
|
||||||
.get('oneUiVersion', '')
|
.get('oneUiVersion', '')
|
||||||
)
|
)
|
||||||
|
info_resource = resources.get('/information/vs/0', {})
|
||||||
|
recognized_registry = (
|
||||||
|
for_device(one_ui_version) if one_ui_version else None
|
||||||
|
) or for_device_by_model(
|
||||||
|
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||||
|
info_resource.get('x.com.samsung.da.description', ''),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"port": port,
|
"port": port,
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"leaf_cert_pem": fullchain_pem,
|
"leaf_cert_pem": fullchain_pem,
|
||||||
"leaf_key_pem": leaf_key_pem,
|
"leaf_key_pem": leaf_key_pem,
|
||||||
"one_ui_version": one_ui_version,
|
"one_ui_version": one_ui_version,
|
||||||
"device_type_recognized": bool(
|
"device_type_recognized": recognized_registry is not None,
|
||||||
one_ui_version and for_device(one_ui_version) is not None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
except CannotConnect:
|
except CannotConnect:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
|||||||
from smartthings_local.ocf.state_cache import StateCache
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
|
|
||||||
from .registry.batch import parse_device0_batch
|
from .registry.batch import parse_device0_batch
|
||||||
from .registry.by_type import for_device
|
from .registry.by_type import for_device, for_device_by_model
|
||||||
from .registry.discovery import discover, BoundEntity
|
from .registry.discovery import discover, BoundEntity
|
||||||
from .registry import CAPABILITIES
|
from .registry import CAPABILITIES
|
||||||
from .registry.adapter import flatten
|
from .registry.adapter import flatten
|
||||||
@@ -273,7 +273,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
.get('swVersionInfo', {})
|
.get('swVersionInfo', {})
|
||||||
.get('oneUiVersion', ''))
|
.get('oneUiVersion', ''))
|
||||||
self.one_ui_version = one_ui
|
self.one_ui_version = one_ui
|
||||||
|
info = resources.get('/information/vs/0', {})
|
||||||
reg = for_device(one_ui) if one_ui else None
|
reg = for_device(one_ui) if one_ui else None
|
||||||
|
if reg is None:
|
||||||
|
reg = for_device_by_model(
|
||||||
|
info.get('x.com.samsung.da.modelNum', ''),
|
||||||
|
info.get('x.com.samsung.da.description', ''),
|
||||||
|
)
|
||||||
unbound: list[str] = []
|
unbound: list[str] = []
|
||||||
if reg is not None:
|
if reg is not None:
|
||||||
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||||
@@ -287,7 +293,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.bound = bound
|
self.bound = bound
|
||||||
self._unbound_hrefs = unbound
|
self._unbound_hrefs = unbound
|
||||||
|
|
||||||
info = resources.get('/information/vs/0', {})
|
|
||||||
serial = info.get('x.com.samsung.da.serialNum', '')
|
serial = info.get('x.com.samsung.da.serialNum', '')
|
||||||
if not serial:
|
if not serial:
|
||||||
serial = self._entry.data[CONF_HOST]
|
serial = self._entry.data[CONF_HOST]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
|||||||
if rep is None:
|
if rep is None:
|
||||||
return False
|
return False
|
||||||
if bound.desc.exists_fn is not None:
|
if bound.desc.exists_fn is not None:
|
||||||
return bound.desc.exists_fn(rep)
|
return bound.desc.exists_fn(rep, coordinator.last_resources)
|
||||||
if bound.desc.field:
|
if bound.desc.field:
|
||||||
if not rep: # stub — resource known to exist, data not yet fetched
|
if not rep: # stub — resource known to exist, data not yet fetched
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
|
|||||||
out: dict[str, Any] = {}
|
out: dict[str, Any] = {}
|
||||||
for b in bound:
|
for b in bound:
|
||||||
rep = resources.get(b.href) or {}
|
rep = resources.get(b.href) or {}
|
||||||
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep):
|
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
|
||||||
continue
|
continue
|
||||||
if b.desc.rep_fn is not None:
|
if b.desc.rep_fn is not None:
|
||||||
out[_key(b)] = b.desc.rep_fn(rep)
|
out[_key(b)] = b.desc.rep_fn(rep)
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ._base import DeviceRegistry
|
from ._base import DeviceRegistry
|
||||||
from . import dishwasher, dryer, oven, refrigerator
|
from . import dishwasher, dryer, oven, refrigerator, washer
|
||||||
|
|
||||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device']
|
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||||
|
|
||||||
|
|
||||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||||
@@ -12,6 +12,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
|||||||
'dryer': dryer.REGISTRY,
|
'dryer': dryer.REGISTRY,
|
||||||
'oven': oven.REGISTRY,
|
'oven': oven.REGISTRY,
|
||||||
'refrigerator': refrigerator.REGISTRY,
|
'refrigerator': refrigerator.REGISTRY,
|
||||||
|
'washer': washer.REGISTRY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -55,3 +56,36 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
|
|||||||
if key.endswith(f'_{rkey}'):
|
if key.endswith(f'_{rkey}'):
|
||||||
return reg
|
return reg
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Consumer-model prefix (first two letters of the '_'-delimited token in
|
||||||
|
# `description` right before any '/board-info' suffix) -> registry key.
|
||||||
|
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
|
||||||
|
# internal board-family prefix there, and dishwasher's modelNum contains
|
||||||
|
# the substring 'WW', so a modelNum-only rule misroutes both.
|
||||||
|
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||||
|
'WW': 'washer',
|
||||||
|
'WD': 'washer',
|
||||||
|
'DV': 'dryer',
|
||||||
|
'DW': 'dishwasher',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||||
|
"""Fallback device-type detection for hardware that never reports
|
||||||
|
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
|
||||||
|
no swVersionInfo key at all).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_num: x.com.samsung.da.modelNum from /information/vs/0.
|
||||||
|
description: x.com.samsung.da.description from /information/vs/0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceRegistry if the consumer-model code or modelNum resolves to a
|
||||||
|
known type, None otherwise.
|
||||||
|
"""
|
||||||
|
token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1]
|
||||||
|
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
|
||||||
|
if key is None and '_REF_' in (model_num or ''):
|
||||||
|
key = 'refrigerator'
|
||||||
|
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Washer device registry."""
|
||||||
|
from ..capabilities import common, dishwasher, fridge, ignored, operational, washer
|
||||||
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
|
REGISTRY = DeviceRegistry(
|
||||||
|
name='washer',
|
||||||
|
capabilities=_build([
|
||||||
|
*ignored.IGNORED,
|
||||||
|
washer.POWER_GENERIC,
|
||||||
|
washer.POWER_VS_FALLBACK,
|
||||||
|
washer.KIDS_LOCK_GENERIC,
|
||||||
|
washer.KIDS_LOCK_VS_FALLBACK,
|
||||||
|
washer.REMOTE_CONTROL_GENERIC,
|
||||||
|
washer.REMOTE_CONTROL_VS_FALLBACK,
|
||||||
|
washer.WASHER_SETTINGS,
|
||||||
|
washer.WASHER_COURSE,
|
||||||
|
washer.BUZZER_SOUND,
|
||||||
|
washer.WASHER_JOB_BEGINNING_STATUS,
|
||||||
|
common.ALARMS,
|
||||||
|
common.ENERGY_METER,
|
||||||
|
common.WATER_METER,
|
||||||
|
operational.OPERATIONAL_STATE,
|
||||||
|
dishwasher.DIAGNOSIS,
|
||||||
|
fridge.FIRMWARE_UPDATE,
|
||||||
|
]),
|
||||||
|
)
|
||||||
@@ -31,22 +31,37 @@ DISHWASHER_SETTINGS = Capability(
|
|||||||
# /course/vs/0 — cycle selection and course options (RMW on options array)
|
# /course/vs/0 — cycle selection and course options (RMW on options array)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Course IDs in editCourseList byte order, matched to Samsung app display names.
|
# Course IDs are uppercase hex strings matching the Course_XX encoding in
|
||||||
# IDs are uppercase hex strings matching the Course_XX encoding in options[].
|
# options[]. Display names are not kept here -- they live in
|
||||||
_COURSE_ID_TO_NAME: dict[str, str] = {
|
# strings.json/translations under entity.select.dishwasher_cycle.state.<id,
|
||||||
'0E': 'AI Wash',
|
# lowercased>, same as every other device-enum select in this integration
|
||||||
'07': 'Pre blast',
|
# (see fridge.py's translation_key entities and select.py's _display()), so
|
||||||
'90': 'Self clean',
|
# they can be localized instead of hardcoded to English.
|
||||||
'86': 'Normal',
|
#
|
||||||
'83': 'Express 60',
|
# No static fallback list is kept here -- a hardcoded course table would
|
||||||
'84': 'Heavy',
|
# show options a given dishwasher model doesn't actually have (or hide ones
|
||||||
'8D': 'Pots and pans',
|
# it does). The only trustworthy per-device source is the live
|
||||||
'80': 'Delicate',
|
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
|
||||||
'8E': 'Plastic',
|
# absent (e.g. never populated until the app's course-edit screen has been
|
||||||
'8F': 'Baby Care',
|
# opened at least once), the cycle select isn't created at all -- see
|
||||||
}
|
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
|
||||||
_COURSE_NAME_TO_ID = {v: k for k, v in _COURSE_ID_TO_NAME.items()}
|
# entry was considered as a second fallback source, but its bytes beyond
|
||||||
_CYCLE_OPTIONS = tuple(_COURSE_ID_TO_NAME.values())
|
# the first don't correspond to any confirmed course code on hardware we
|
||||||
|
# have dumps for, so it isn't trustworthy either -- see washer.py's
|
||||||
|
# _cycle_options docstring for the byte-level evidence.)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_edit_course_list(raw):
|
||||||
|
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_options(resources):
|
||||||
|
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||||
|
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||||
|
|
||||||
|
|
||||||
def _option_value(options, prefix):
|
def _option_value(options, prefix):
|
||||||
@@ -63,14 +78,11 @@ def _replace_in_options(options, prefix, new_value):
|
|||||||
|
|
||||||
|
|
||||||
def _cycle_write(p, rep, href=None):
|
def _cycle_write(p, rep, href=None):
|
||||||
course_id = _COURSE_NAME_TO_ID.get(p)
|
|
||||||
if not course_id:
|
|
||||||
return None
|
|
||||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||||
if not opts:
|
if not opts:
|
||||||
return None
|
return None
|
||||||
return ['course', 'vs', '0'], {
|
return ['course', 'vs', '0'], {
|
||||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', course_id),
|
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,17 +112,18 @@ CYCLE_OPTIONS = Capability(
|
|||||||
href='/course/vs/0',
|
href='/course/vs/0',
|
||||||
entities=(
|
entities=(
|
||||||
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
|
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
|
||||||
options=_CYCLE_OPTIONS,
|
translation_key='dishwasher_cycle',
|
||||||
rep_fn=lambda rep: _COURSE_ID_TO_NAME.get(
|
options=_cycle_options,
|
||||||
_option_value(rep.get('x.com.samsung.da.options'), 'Course')
|
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||||
),
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||||
write_fn=_cycle_write),
|
write_fn=_cycle_write),
|
||||||
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
|
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
|
||||||
rep_fn=lambda rep: _option_value(
|
rep_fn=lambda rep: _option_value(
|
||||||
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
|
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
|
||||||
write_fn=_storm_wash_write),
|
write_fn=_storm_wash_write),
|
||||||
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
|
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
|
||||||
exists_fn=lambda rep: any(
|
exists_fn=lambda rep, resources: any(
|
||||||
isinstance(o, str) and o.startswith('AutoDoorRelease_')
|
isinstance(o, str) and o.startswith('AutoDoorRelease_')
|
||||||
for o in (rep.get('x.com.samsung.da.options') or [])
|
for o in (rep.get('x.com.samsung.da.options') or [])
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ ICEMAKER_GENERIC = Capability(
|
|||||||
translation_key='ice_type',
|
translation_key='ice_type',
|
||||||
entity_category='config',
|
entity_category='config',
|
||||||
options_field='x.com.samsung.da.iceType.supported',
|
options_field='x.com.samsung.da.iceType.supported',
|
||||||
exists_fn=lambda rep: bool(rep.get('x.com.samsung.da.iceType.supported')),
|
exists_fn=lambda rep, resources: bool(rep.get('x.com.samsung.da.iceType.supported')),
|
||||||
write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')),
|
write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -504,7 +504,7 @@ FLEX_ZONE = Capability(
|
|||||||
translation_key='flex_zone_mode',
|
translation_key='flex_zone_mode',
|
||||||
entity_category='config',
|
entity_category='config',
|
||||||
options_field='x.com.samsung.da.supportedOptions',
|
options_field='x.com.samsung.da.supportedOptions',
|
||||||
exists_fn=lambda rep: bool(
|
exists_fn=lambda rep, resources: bool(
|
||||||
rep.get('x.com.samsung.da.supportedOptions')),
|
rep.get('x.com.samsung.da.supportedOptions')),
|
||||||
value_fn=lambda modes: next(
|
value_fn=lambda modes: next(
|
||||||
(m for m in (modes or [])
|
(m for m in (modes or [])
|
||||||
|
|||||||
@@ -61,8 +61,12 @@ IGNORED: list[Capability] = [
|
|||||||
# Redundant with capabilities already declared elsewhere.
|
# Redundant with capabilities already declared elsewhere.
|
||||||
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
||||||
Capability(href='/speakersound/vs/0'),
|
Capability(href='/speakersound/vs/0'),
|
||||||
# /wm/editcourse/vs/0 is the raw encoded course-list byte string already
|
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
|
||||||
# decoded and exposed via /course/vs/0 (dishwasher.CYCLE_OPTIONS).
|
# editCourseList is read directly out of the resource snapshot by
|
||||||
|
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
|
||||||
|
# (options=_cycle_options) to build that device's actual supported
|
||||||
|
# course list, rather than exposing this href's raw byte string
|
||||||
|
# through its own entity.
|
||||||
Capability(href='/wm/editcourse/vs/0'),
|
Capability(href='/wm/editcourse/vs/0'),
|
||||||
|
|
||||||
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
# Bixby audio feedback (chime + volume played when Bixby starts/stops
|
||||||
@@ -94,4 +98,33 @@ IGNORED: list[Capability] = [
|
|||||||
Capability(href='/energy/planner/vs/0'),
|
Capability(href='/energy/planner/vs/0'),
|
||||||
# Temperature-unit display preference, redundant with HA's own units.
|
# Temperature-unit display preference, redundant with HA's own units.
|
||||||
Capability(href='/wm/submode/vs/0'),
|
Capability(href='/wm/submode/vs/0'),
|
||||||
|
|
||||||
|
# Read-only re-encoding of the course already exposed by
|
||||||
|
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
||||||
|
# is literally "Table_02_Course_<same hex code>").
|
||||||
|
Capability(href='/st/washercourse/vs/0'),
|
||||||
|
# Empty on every washer dump seen so far.
|
||||||
|
Capability(href='/wm/welcomemsg/vs/0'),
|
||||||
|
# User-saved custom course slots (F1-FA). No controllable/observable
|
||||||
|
# state without a multi-slot editor; revisit if that becomes valuable.
|
||||||
|
Capability(href='/wm/personalcourse/vs/0'),
|
||||||
|
# OCF-native energy resource is empty ({}) on washer hardware seen so
|
||||||
|
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
|
||||||
|
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
|
||||||
|
# real source for this control.
|
||||||
|
Capability(href='/energy/consumption/0'),
|
||||||
|
# Empty ({}) on every washer dump seen so far -- nothing to expose.
|
||||||
|
Capability(href='/cycleinterface/vs/0'),
|
||||||
|
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
|
||||||
|
# dependency this integration doesn't support locally.
|
||||||
|
Capability(href='/drlc/0'),
|
||||||
|
# OCF-native duplicate of /operational/state/vs/0, which is already
|
||||||
|
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
|
||||||
|
# capability with start/pause/stop buttons and a delay-start control)
|
||||||
|
# used by washer, dishwasher, dryer, and oven. This generic href only
|
||||||
|
# carries read-only overlapping data (current job state, remaining
|
||||||
|
# time, progress percentage) with no write path -- not worth building a
|
||||||
|
# parallel write-capable capability around an unverified generic OCF
|
||||||
|
# write contract.
|
||||||
|
Capability(href='/operational/state/0'),
|
||||||
]
|
]
|
||||||
@@ -47,6 +47,16 @@ def _format_delay(hours):
|
|||||||
return f'{h}:{m:02d}:00'
|
return f'{h}:{m:02d}:00'
|
||||||
|
|
||||||
|
|
||||||
|
def _delay_field(rep):
|
||||||
|
"""Washer hardware reports the delay-until-start duration under
|
||||||
|
'delayEndTime' instead of 'delayStartTime' (both hold a duration, not a
|
||||||
|
wall-clock time -- see _delay_hours). Write back whichever key the
|
||||||
|
device itself is using; default to delayStartTime for hardware that
|
||||||
|
reports neither yet (matches prior behavior)."""
|
||||||
|
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||||
|
else 'x.com.samsung.da.delayStartTime')
|
||||||
|
|
||||||
|
|
||||||
def _finish_time(remaining_str):
|
def _finish_time(remaining_str):
|
||||||
if not remaining_str:
|
if not remaining_str:
|
||||||
return None
|
return None
|
||||||
@@ -96,14 +106,15 @@ OPERATIONAL_STATE = Capability(
|
|||||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
||||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
||||||
)),
|
)),
|
||||||
NumberDesc(key='delay_start_hours', field='x.com.samsung.da.delayStartTime',
|
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
|
||||||
name='Delay start', icon='mdi:timer-plus-outline',
|
|
||||||
device_class='duration', unit='h',
|
device_class='duration', unit='h',
|
||||||
native_min=0, native_max=24, step=1,
|
native_min=0, native_max=24, step=1,
|
||||||
value_fn=_delay_hours,
|
rep_fn=lambda rep: _delay_hours(
|
||||||
|
rep.get('x.com.samsung.da.delayStartTime')
|
||||||
|
or rep.get('x.com.samsung.da.delayEndTime')),
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
['operational', 'state', 'vs', '0'],
|
['operational', 'state', 'vs', '0'],
|
||||||
{'x.com.samsung.da.delayStartTime': _format_delay(p)})),
|
{_delay_field(rep): _format_delay(p)})),
|
||||||
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
|
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
|
||||||
icon='mdi:play',
|
icon='mdi:play',
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Capabilities specific to washer appliances (Samsung DA_WM_TP1-class
|
||||||
|
front-load washers).
|
||||||
|
|
||||||
|
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
|
||||||
|
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 ..capability import Capability
|
||||||
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
||||||
|
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||||
|
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||||
|
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||||
|
# positionally against a Slovak-UI user's screenshots of their app's course
|
||||||
|
# list (same order, same count -- see issue #2) and cross-checked against
|
||||||
|
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
|
||||||
|
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
|
||||||
|
# cycle the manual marks "applicable models only" and that does not appear
|
||||||
|
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
|
||||||
|
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
|
||||||
|
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
|
||||||
|
# you'd expect to be pinned (default cycle + maintenance cycle),
|
||||||
|
# corroborating the positional match.
|
||||||
|
#
|
||||||
|
# No static fallback list of those codes is kept here, deliberately: other
|
||||||
|
# washer models have a different actual course set (a second dump's active
|
||||||
|
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
||||||
|
# Load' -- both "applicable models only" per the manual -- would have yet
|
||||||
|
# another set), so hardcoding one device's list would show/hide the wrong
|
||||||
|
# options on a different model. _cycle_options() below reads only the live
|
||||||
|
# x.com.samsung.da.editCourseList; if a device doesn't populate that
|
||||||
|
# resource, the cycle select isn't created at all (see WASHER_COURSE's
|
||||||
|
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as
|
||||||
|
# a fallback source (its first byte reliably equals the currently-selected
|
||||||
|
# Course_XX on both dumps we have), but the bytes after that don't
|
||||||
|
# correspond to any confirmed course code on either device -- e.g. dump 1's
|
||||||
|
# MostUsed_1C8410923FA67F00000000000000 decodes to
|
||||||
|
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
|
||||||
|
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_options(resources):
|
||||||
|
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||||
|
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
|
||||||
|
#
|
||||||
|
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
||||||
|
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
|
||||||
|
# path for different device families. Only one of the two ever binds for a
|
||||||
|
# given device, since dryer and washer are separate by_type registries.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WASHER_SETTINGS = Capability(
|
||||||
|
href='/washer/vs/0',
|
||||||
|
entities=(
|
||||||
|
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||||
|
name='Wash temperature', icon='mdi:thermometer-water',
|
||||||
|
entity_category='config',
|
||||||
|
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||||
|
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||||
|
name='Spin speed', icon='mdi:sync',
|
||||||
|
entity_category='config',
|
||||||
|
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||||
|
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||||
|
name='Rinse cycles', icon='mdi:water-sync',
|
||||||
|
entity_category='config',
|
||||||
|
options_field='x.com.samsung.da.supportedRinseCycles',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /course/vs/0 -- selected course, read/write (RMW on the options array,
|
||||||
|
# same shape as dishwasher.CYCLE_OPTIONS._cycle_write).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _option_value(options, prefix):
|
||||||
|
for o in (options or []):
|
||||||
|
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||||
|
return o.split('_', 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_in_options(options, prefix, new_value):
|
||||||
|
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||||
|
for o in options]
|
||||||
|
|
||||||
|
|
||||||
|
def _cycle_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, 'Course', p),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
WASHER_COURSE = Capability(
|
||||||
|
href='/course/vs/0',
|
||||||
|
entities=(
|
||||||
|
SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine',
|
||||||
|
translation_key='washer_cycle',
|
||||||
|
options=_cycle_options,
|
||||||
|
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||||
|
rep_fn=lambda rep: _option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||||
|
write_fn=_cycle_write),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
|
||||||
|
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike
|
||||||
|
# most other washer hrefs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BUZZER_SOUND = Capability(
|
||||||
|
href='/buzzersound/vs/0',
|
||||||
|
entities=(
|
||||||
|
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||||
|
name='Buzzer sound', icon='mdi:volume-high',
|
||||||
|
entity_category='config',
|
||||||
|
options_field='supportedBuzzerSound',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||||
|
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||||
|
name='Finish sound', icon='mdi:bell-ring',
|
||||||
|
entity_category='config',
|
||||||
|
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||||
|
options_field='supportedFinishSound',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but
|
||||||
|
# a different field name (currentStatus, not jobBeginingStatus).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
WASHER_JOB_BEGINNING_STATUS = Capability(
|
||||||
|
href='/wm/jobbeginingstatus/vs/0',
|
||||||
|
poll_tier='warm',
|
||||||
|
entities=(
|
||||||
|
SensorDesc(key='job_beginning_status',
|
||||||
|
field='x.com.samsung.da.currentStatus',
|
||||||
|
name='Job beginning status',
|
||||||
|
entity_category='diagnostic'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control.
|
||||||
|
#
|
||||||
|
# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF
|
||||||
|
# href (/power/0, plain boolean 'value') is preferred when present; the
|
||||||
|
# vendor '-vs' href (richer historically, but for these three controls just
|
||||||
|
# a string-encoded duplicate) only binds when the generic href is absent
|
||||||
|
# from this device's resource set, via match_fn. Scoped to washer.py, not
|
||||||
|
# common.py -- no other device type has been confirmed to expose the
|
||||||
|
# generic hrefs, so this must not change behavior for dishwasher/dryer/
|
||||||
|
# oven/refrigerator.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
POWER_GENERIC = Capability(
|
||||||
|
href='/power/0',
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(key='power_switch', field='value',
|
||||||
|
name='Power',
|
||||||
|
value_fn=lambda v: bool(v),
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['power', '0'], {'value': p == 'On'})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
POWER_VS_FALLBACK = Capability(
|
||||||
|
href='/power/vs/0',
|
||||||
|
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||||
|
name='Power',
|
||||||
|
value_fn=lambda v: v == 'On',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['power', 'vs', '0'],
|
||||||
|
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
KIDS_LOCK_GENERIC = Capability(
|
||||||
|
href='/kidslock/0',
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(key='child_lock', field='value',
|
||||||
|
name='Child lock', device_class='lock',
|
||||||
|
value_fn=lambda v: bool(v),
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['kidslock', '0'], {'value': p == 'On'})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||||
|
href='/kidslock/vs/0',
|
||||||
|
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||||
|
entities=(
|
||||||
|
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||||
|
name='Child lock', device_class='lock',
|
||||||
|
value_fn=lambda v: v != 'Ready',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['kidslock', 'vs', '0'],
|
||||||
|
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
REMOTE_CONTROL_GENERIC = Capability(
|
||||||
|
href='/remotectrl/0',
|
||||||
|
entities=(
|
||||||
|
BinarySensorDesc(key='remote_control', field='value',
|
||||||
|
name='Smart Control', device_class='connectivity',
|
||||||
|
value_fn=lambda v: bool(v)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||||
|
href='/remotectrl/vs/0',
|
||||||
|
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||||
|
entities=(
|
||||||
|
BinarySensorDesc(key='remote_control',
|
||||||
|
field='x.com.samsung.da.remoteControlEnabled',
|
||||||
|
name='Smart Control', device_class='connectivity',
|
||||||
|
value_fn=lambda v: str(v).lower() == 'true'),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -27,7 +27,10 @@ class SamsungEntityDescription:
|
|||||||
enabled_default: bool = True
|
enabled_default: bool = True
|
||||||
value_fn: Callable[[Any], Any] = _identity
|
value_fn: Callable[[Any], Any] = _identity
|
||||||
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
||||||
exists_fn: Optional[Callable[[dict], bool]] = None
|
# (rep, resources): rep is this entity's own href's representation;
|
||||||
|
# resources is the coordinator's full href->rep snapshot, for gating
|
||||||
|
# presence on a sibling resource (e.g. washer._cycle_options's source).
|
||||||
|
exists_fn: Optional[Callable[[dict, dict], bool]] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
@@ -45,7 +48,10 @@ class BinarySensorDesc(SamsungEntityDescription):
|
|||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class SelectDesc(SamsungEntityDescription):
|
class SelectDesc(SamsungEntityDescription):
|
||||||
options: Any = () # tuple[str,...] | Callable[[dict], list[str]]
|
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
|
||||||
|
# callable form receives the coordinator's full href->rep resource
|
||||||
|
# snapshot (not just this entity's own href) and returns raw device
|
||||||
|
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||||
options_field: Optional[str] = None # resource field that contains the live options list
|
options_field: Optional[str] = None # resource field that contains the live options list
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,18 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
|||||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||||
super().__init__(coordinator, bound)
|
super().__init__(coordinator, bound)
|
||||||
desc: SelectDesc = bound.desc
|
desc: SelectDesc = bound.desc
|
||||||
if not desc.options_field:
|
if not desc.options_field and not callable(desc.options):
|
||||||
self._attr_options = [_display(o, desc) for o in desc.options]
|
self._attr_options = [_display(o, desc) for o in desc.options]
|
||||||
|
|
||||||
def _raw_options(self) -> list[str]:
|
def _raw_options(self) -> list[str]:
|
||||||
desc: SelectDesc = self._bound.desc
|
desc: SelectDesc = self._bound.desc
|
||||||
|
if callable(desc.options):
|
||||||
|
# Per-device option list computed from the full resource
|
||||||
|
# snapshot (not just this entity's own href) -- e.g. a course
|
||||||
|
# list decoded from a sibling resource. There is no static
|
||||||
|
# fallback: when that resource isn't populated the callable
|
||||||
|
# returns [] and the entity's exists_fn suppresses it entirely.
|
||||||
|
return list(desc.options(self.coordinator.last_resources) or [])
|
||||||
if desc.options_field:
|
if desc.options_field:
|
||||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||||
return list(rep.get(desc.options_field) or [])
|
return list(rep.get(desc.options_field) or [])
|
||||||
@@ -75,7 +82,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
|||||||
@property
|
@property
|
||||||
def options(self) -> list[str]:
|
def options(self) -> list[str]:
|
||||||
desc: SelectDesc = self._bound.desc
|
desc: SelectDesc = self._bound.desc
|
||||||
if desc.options_field:
|
if desc.options_field or callable(desc.options):
|
||||||
return [_display(o, desc) for o in self._raw_options()]
|
return [_display(o, desc) for o in self._raw_options()]
|
||||||
return self._attr_options
|
return self._attr_options
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,47 @@
|
|||||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||||
"cv_ttype_rf9000a_beverage": "Beverage"
|
"cv_ttype_rf9000a_beverage": "Beverage"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dishwasher_cycle": {
|
||||||
|
"state": {
|
||||||
|
"0e": "AI Wash",
|
||||||
|
"07": "Pre blast",
|
||||||
|
"90": "Self clean",
|
||||||
|
"86": "Normal",
|
||||||
|
"83": "Express 60",
|
||||||
|
"84": "Heavy",
|
||||||
|
"8d": "Pots and pans",
|
||||||
|
"80": "Delicate",
|
||||||
|
"8e": "Plastic",
|
||||||
|
"8f": "Baby Care"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_cycle": {
|
||||||
|
"state": {
|
||||||
|
"1c": "Eco 40-60",
|
||||||
|
"1d": "Super Speed",
|
||||||
|
"21": "Colours",
|
||||||
|
"1b": "Cotton",
|
||||||
|
"1e": "15' Quick Wash",
|
||||||
|
"29": "Drum Clean+",
|
||||||
|
"24": "Towels",
|
||||||
|
"33": "Bedding",
|
||||||
|
"28": "Drain/Spin",
|
||||||
|
"26": "Delicates",
|
||||||
|
"27": "Rinse+Spin",
|
||||||
|
"22": "Wool",
|
||||||
|
"20": "Hygiene Steam",
|
||||||
|
"23": "Outdoor",
|
||||||
|
"25": "Synthetics",
|
||||||
|
"32": "Shirts",
|
||||||
|
"2f": "Activewear",
|
||||||
|
"2e": "Baby Care",
|
||||||
|
"30": "Cloudy Day",
|
||||||
|
"66": "Denim",
|
||||||
|
"2d": "Silent Wash",
|
||||||
|
"8f": "Intense Cold",
|
||||||
|
"96": "Less Microfiber"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
@@ -37,6 +37,47 @@
|
|||||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||||
"cv_ttype_rf9000a_beverage": "Beverage"
|
"cv_ttype_rf9000a_beverage": "Beverage"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dishwasher_cycle": {
|
||||||
|
"state": {
|
||||||
|
"0e": "AI Wash",
|
||||||
|
"07": "Pre blast",
|
||||||
|
"90": "Self clean",
|
||||||
|
"86": "Normal",
|
||||||
|
"83": "Express 60",
|
||||||
|
"84": "Heavy",
|
||||||
|
"8d": "Pots and pans",
|
||||||
|
"80": "Delicate",
|
||||||
|
"8e": "Plastic",
|
||||||
|
"8f": "Baby Care"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"washer_cycle": {
|
||||||
|
"state": {
|
||||||
|
"1c": "Eco 40-60",
|
||||||
|
"1d": "Super Speed",
|
||||||
|
"21": "Colours",
|
||||||
|
"1b": "Cotton",
|
||||||
|
"1e": "15' Quick Wash",
|
||||||
|
"29": "Drum Clean+",
|
||||||
|
"24": "Towels",
|
||||||
|
"33": "Bedding",
|
||||||
|
"28": "Drain/Spin",
|
||||||
|
"26": "Delicates",
|
||||||
|
"27": "Rinse+Spin",
|
||||||
|
"22": "Wool",
|
||||||
|
"20": "Hygiene Steam",
|
||||||
|
"23": "Outdoor",
|
||||||
|
"25": "Synthetics",
|
||||||
|
"32": "Shirts",
|
||||||
|
"2f": "Activewear",
|
||||||
|
"2e": "Baby Care",
|
||||||
|
"30": "Cloudy Day",
|
||||||
|
"66": "Denim",
|
||||||
|
"2d": "Silent Wash",
|
||||||
|
"8f": "Intense Cold",
|
||||||
|
"96": "Less Microfiber"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ def dishwasher_resources() -> dict[str, dict]:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fridge_resources() -> dict[str, dict]:
|
def fridge_resources() -> dict[str, dict]:
|
||||||
return _load_device('refrigerator')
|
return _load_device('refrigerator')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def washer_resources() -> dict[str, dict]:
|
||||||
|
return _load_device('washer')
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"state_keys": [
|
||||||
|
"alarm_code",
|
||||||
|
"buzzer_sound",
|
||||||
|
"child_lock",
|
||||||
|
"cycle",
|
||||||
|
"cycle_active",
|
||||||
|
"delay_start_hours",
|
||||||
|
"diagnosis_status",
|
||||||
|
"energy_kwh",
|
||||||
|
"finish_time",
|
||||||
|
"firmware_update",
|
||||||
|
"job_beginning_status",
|
||||||
|
"machine_state",
|
||||||
|
"power_switch",
|
||||||
|
"power_watts",
|
||||||
|
"progress",
|
||||||
|
"progress_percentage",
|
||||||
|
"remote_control",
|
||||||
|
"rinse_cycles",
|
||||||
|
"spin_speed",
|
||||||
|
"wash_temperature",
|
||||||
|
"water_liters"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+473
@@ -0,0 +1,473 @@
|
|||||||
|
{
|
||||||
|
"device0": [
|
||||||
|
{
|
||||||
|
"rt": [
|
||||||
|
"x.com.samsung.devcol",
|
||||||
|
"oic.wk.col"
|
||||||
|
],
|
||||||
|
"if": [
|
||||||
|
"oic.if.baseline",
|
||||||
|
"oic.if.ll",
|
||||||
|
"oic.if.b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/realtimenotiforclient/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.timeforshortnoti": "0",
|
||||||
|
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/alarms/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/diagnosis/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.instantaneousPower": "-500",
|
||||||
|
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||||
|
"x.com.samsung.da.cumulativePower": "18800",
|
||||||
|
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||||
|
"x.com.samsung.da.cumulativeDate": "1783774800",
|
||||||
|
"x.com.samsung.da.cumulativeDateUTC": "1783767600",
|
||||||
|
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/course/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.supportedModes": [
|
||||||
|
"HOMECARE_WIZARD_V2"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.options": [
|
||||||
|
"DeviceType_0167",
|
||||||
|
"UpdateAllow_NotAllowed",
|
||||||
|
"Course_1C",
|
||||||
|
"AiOption_On",
|
||||||
|
"LaundryOutTime_0",
|
||||||
|
"SteamWash_Disable",
|
||||||
|
"SeamlessControl_Disable",
|
||||||
|
"KidsLockBypass_On",
|
||||||
|
"WashingTimes_3",
|
||||||
|
"DrumCleanProposal_40",
|
||||||
|
"DetergentOnce_1",
|
||||||
|
"DetergentLeft_0",
|
||||||
|
"DetergentBase_5",
|
||||||
|
"DetergentAlarm_Off",
|
||||||
|
"DetergentType_2",
|
||||||
|
"DetergentTotal_0",
|
||||||
|
"SoftenerOnce_0",
|
||||||
|
"SoftenerLeft_0",
|
||||||
|
"SoftenerBase_0",
|
||||||
|
"SoftenerAlarm_Off",
|
||||||
|
"SoftenerType_0",
|
||||||
|
"SoftenerTotal_0",
|
||||||
|
"SpecialFunction_4",
|
||||||
|
"AvailableDelayTime_163",
|
||||||
|
"BubbleSoak_Off",
|
||||||
|
"LaundryPlannerUserSetTime_0",
|
||||||
|
"ProgressTimeSet_4201048201A4A200D4",
|
||||||
|
"SendToDevice_Off",
|
||||||
|
"GMT_04",
|
||||||
|
"PreWashSetting_Off",
|
||||||
|
"IntensiveSetting_Off",
|
||||||
|
"BubbleSoakSet_00F000F00000F000F0F0F0F00000F000F0F0F0F0000000",
|
||||||
|
"EnergyLevelSet_050304010402010402040402050302020202050303050101",
|
||||||
|
"MostUsed_1C8410923FA67F00000000000000",
|
||||||
|
"TextureLevel_None",
|
||||||
|
"SavingModeCondition_010102151B1D1C1E8F202122232425261C2D30323396662E2F030149035E2830",
|
||||||
|
"SavingMode_Off",
|
||||||
|
"DetergentUserSet_0000",
|
||||||
|
"SoftenerUserSet_0000",
|
||||||
|
"DetergentSetting_01",
|
||||||
|
"DetergentSettingList_01",
|
||||||
|
"SoftenerCustomSetting_01",
|
||||||
|
"SoftenerCustomSettingList_0102",
|
||||||
|
"DetergentDefaultCourseList_030422232F2604012E",
|
||||||
|
"LaundryManageFinish_Off",
|
||||||
|
"UserLocation_Indoor",
|
||||||
|
"GeoFenceAlarm",
|
||||||
|
"UsagesDB_ok",
|
||||||
|
"EnergyKW_396",
|
||||||
|
"DrumCleanLog_2026-07-01T20:18:07",
|
||||||
|
"TimeSync_NotSupported"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.supportedOptions": [
|
||||||
|
"31C8410923FA67F1B847E923FA67F1E811E933FA33F1D841E923FA67F96841E920FA37F8F8102923FA57F25843E933FA57F26831E920FA20733857E933FA67F24841E930FA30F32833E923FA37F20857E943FA67F22841E920FA30F23831E930FA57F2F831E933FA30F21841E943FA57F66831E933FA30F2E867E943FA67F2D841E923FA30F30843E923FA67F2985209204A520278000913FA67F2880009000A67E"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.power": "On"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/0",
|
||||||
|
"rep": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/cycleinterface/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.kidsLock": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.state": "Ready",
|
||||||
|
"x.com.samsung.da.remainingTime": "02:43:00",
|
||||||
|
"x.com.samsung.da.progressPercentage": "1",
|
||||||
|
"x.com.samsung.da.progress": "None",
|
||||||
|
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||||
|
"x.com.samsung.da.supportedProgress": [
|
||||||
|
"None",
|
||||||
|
"Weightsensing",
|
||||||
|
"Wash",
|
||||||
|
"Rinse",
|
||||||
|
"Spin",
|
||||||
|
"Finish"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/0",
|
||||||
|
"rep": {
|
||||||
|
"currentMachineState": "**REDACTED**",
|
||||||
|
"machineStates": "**REDACTED**",
|
||||||
|
"jobStates": [
|
||||||
|
"None",
|
||||||
|
"Weightsensing",
|
||||||
|
"Wash",
|
||||||
|
"Rinse",
|
||||||
|
"Spin",
|
||||||
|
"Finish"
|
||||||
|
],
|
||||||
|
"currentJobState": "None",
|
||||||
|
"remainingTime": "02:43:00",
|
||||||
|
"progressPercentage": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.modelNum": "TEST-MODEL",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D",
|
||||||
|
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||||
|
"x.com.samsung.da.diagLogType": [
|
||||||
|
"errCode",
|
||||||
|
"dump"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.diagDumpType": "file",
|
||||||
|
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||||
|
"x.com.samsung.da.diagMnid": "0AJT",
|
||||||
|
"x.com.samsung.da.diagSetupid": "WFC",
|
||||||
|
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||||
|
"x.com.samsung.da.diagTsId": "DA01",
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000",
|
||||||
|
"x.com.samsung.da.type": "Software",
|
||||||
|
"x.com.samsung.da.number": "02986A260118(A182)",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "Firmware_1_DB_20375141240308040FFFFF203724412507152904FFFF(01672037514120372441_30000000)(FileDown:0)(Type:0)",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "03751A24030804,03724A25071529",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "2",
|
||||||
|
"x.com.samsung.da.description": "Firmware_2_DB_2036284224030602042FFFFFFFFFFFFFFFFFFFFFFFFE(016720362842FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "03628B24030602,FFFFFFFFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/file/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.timeoffset": "+02:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/washer/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.waterTemperature": "40",
|
||||||
|
"x.com.samsung.da.supportedWaterTemperature": [
|
||||||
|
"None",
|
||||||
|
"Cold",
|
||||||
|
"20",
|
||||||
|
"30",
|
||||||
|
"40",
|
||||||
|
"60",
|
||||||
|
"90"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.spinLevel": "1400",
|
||||||
|
"x.com.samsung.da.supportedSpinLevel": [
|
||||||
|
"RinseHold",
|
||||||
|
"NoSpin",
|
||||||
|
"400",
|
||||||
|
"800",
|
||||||
|
"1000",
|
||||||
|
"1200",
|
||||||
|
"1400"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.rinseCycles": "2",
|
||||||
|
"x.com.samsung.da.supportedRinseCycles": [
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.currentAvailableTemperature": "00",
|
||||||
|
"x.com.samsung.da.currentAvailableRinse": "3F",
|
||||||
|
"x.com.samsung.da.currentAvailableSpin": "7F",
|
||||||
|
"x.com.samsung.da.currentAvailableSubOptions": "00000000000000000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/st/washercourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.st.washerMode": "Table_02_Course_1C",
|
||||||
|
"x.com.samsung.da.st.courseTable": "Table_02"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/water/consumption/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.cumulativeWater": "1966500"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/setting/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.supportedSetLanguage": [
|
||||||
|
"ko_KR",
|
||||||
|
"en_US"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.setLanguage": "sk_SK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/editcourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.editCourseList": "EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96",
|
||||||
|
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1C29"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/setinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||||
|
"x.com.samsung.da.aiCourse": "true",
|
||||||
|
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||||
|
"x.com.samsung.da.modelCode": "M(None),W(WW90DG6U25LEU4)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/jobbeginingstatus/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.currentStatus": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/welcomemsg/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/personalcourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.courses": [
|
||||||
|
"F1_00",
|
||||||
|
"F2_00",
|
||||||
|
"F3_00",
|
||||||
|
"F4_00",
|
||||||
|
"F5_00",
|
||||||
|
"F6_00",
|
||||||
|
"F7_00",
|
||||||
|
"F8_00",
|
||||||
|
"F9_00",
|
||||||
|
"FA_00"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.maxCourseNum": "10",
|
||||||
|
"x.com.samsung.da.labelScanCourse": "FB_00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/otninformation/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.target": "",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "false",
|
||||||
|
"x.com.samsung.da.newVersionNo": "00000000",
|
||||||
|
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||||
|
"otnStatus": "None",
|
||||||
|
"flashingProgress": "",
|
||||||
|
"otnTarget": "main",
|
||||||
|
"otnCompleteDate": "2026-03-22",
|
||||||
|
"otnList": [
|
||||||
|
{
|
||||||
|
"type": "WIFI",
|
||||||
|
"modelId": "DA_WM_TP1_21_COMMON",
|
||||||
|
"versions": [
|
||||||
|
"30260118"
|
||||||
|
],
|
||||||
|
"visVersion": "260118"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Micom",
|
||||||
|
"modelId": "01672037514120372441",
|
||||||
|
"versions": [
|
||||||
|
"24030804",
|
||||||
|
"25071529"
|
||||||
|
],
|
||||||
|
"visVersion": "250715"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Micom",
|
||||||
|
"modelId": "016720362842FFFFFFFF",
|
||||||
|
"versions": [
|
||||||
|
"24030602",
|
||||||
|
"FFFFFFFF"
|
||||||
|
],
|
||||||
|
"visVersion": "240306"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/buzzersound/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"supportedBuzzerSound": [
|
||||||
|
"Off",
|
||||||
|
"On"
|
||||||
|
],
|
||||||
|
"setBuzzerSound": "On"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/configuration/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.region": "0000000000",
|
||||||
|
"x.com.samsung.da.countryCode": "SK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/drlc/0",
|
||||||
|
"rep": {
|
||||||
|
"DRLevel": 0,
|
||||||
|
"start": "0000-00-00T00:00:00Z",
|
||||||
|
"duration": 0,
|
||||||
|
"override": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/drlc/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.drlcLevel": "0",
|
||||||
|
"x.com.samsung.da.durationminutes": "0",
|
||||||
|
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
|
||||||
|
"x.com.samsung.da.override": "Off",
|
||||||
|
"x.com.samsung.da.realSaving": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/timezone/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"timezoneid": "Europe/Bratislava",
|
||||||
|
"offset": "+02:00",
|
||||||
|
"DST": "ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/connectionconfig/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"autoReconnectionMinVersion": "1.0",
|
||||||
|
"autoReconnection": "true",
|
||||||
|
"autoReconnectionProtocolType": [
|
||||||
|
"helper_hotspot",
|
||||||
|
"ble_ocf"
|
||||||
|
],
|
||||||
|
"supportedWiFiAuthType": [
|
||||||
|
"OPEN",
|
||||||
|
"WEP",
|
||||||
|
"WPA-PSK",
|
||||||
|
"WPA2-PSK",
|
||||||
|
"SAE"
|
||||||
|
],
|
||||||
|
"supportedWiFiCryptoType": [
|
||||||
|
"TKIP",
|
||||||
|
"AES",
|
||||||
|
"WEP-64",
|
||||||
|
"WEP-128"
|
||||||
|
],
|
||||||
|
"supportedWiFiFreq": [
|
||||||
|
"2.4G"
|
||||||
|
],
|
||||||
|
"calmConnectionCare": {
|
||||||
|
"version": "1.0",
|
||||||
|
"role": [
|
||||||
|
"things"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wirelessinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"macaddressWiFi": "**REDACTED**",
|
||||||
|
"macaddressBLE": "**REDACTED**"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/quickcontrol/info/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"supportedVersion": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -134,3 +134,38 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
|
|||||||
)
|
)
|
||||||
assert result['type'] == FlowResultType.ABORT
|
assert result['type'] == FlowResultType.ABORT
|
||||||
assert result['reason'] == 'already_configured'
|
assert result['reason'] == 'already_configured'
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_marks_washer_as_recognized(monkeypatch):
|
||||||
|
"""A washer's probe response (no oneUiVersion) must still resolve via
|
||||||
|
the modelNum/description fallback so setup doesn't warn about an
|
||||||
|
unrecognized device type."""
|
||||||
|
from custom_components.localthings import config_flow
|
||||||
|
|
||||||
|
device0 = [
|
||||||
|
{'rt': ['x.com.samsung.devcol']},
|
||||||
|
{'href': '/information/vs/0', 'rep': {
|
||||||
|
'x.com.samsung.da.modelNum':
|
||||||
|
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
|
||||||
|
'x.com.samsung.da.description':
|
||||||
|
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
|
||||||
|
'x.com.samsung.da.serialNum': 'TEST-SERIAL',
|
||||||
|
}},
|
||||||
|
{'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}},
|
||||||
|
]
|
||||||
|
from custom_components.localthings.registry.batch import parse_device0_batch
|
||||||
|
resources = parse_device0_batch(device0)
|
||||||
|
|
||||||
|
info_resource = resources.get('/information/vs/0', {})
|
||||||
|
one_ui_version = (
|
||||||
|
resources.get('/otninformation/vs/0', {}).get('swVersionInfo', {}).get('oneUiVersion', '')
|
||||||
|
)
|
||||||
|
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
||||||
|
recognized = bool(
|
||||||
|
(one_ui_version and for_device(one_ui_version) is not None)
|
||||||
|
or for_device_by_model(
|
||||||
|
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||||
|
info_resource.get('x.com.samsung.da.description', ''),
|
||||||
|
) is not None
|
||||||
|
)
|
||||||
|
assert recognized is True
|
||||||
@@ -113,6 +113,27 @@ async def test_coverage_gap_issue_absent_for_fully_covered_real_fixture(
|
|||||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_discovery_detects_washer_via_model_fallback(
|
||||||
|
hass: HomeAssistant, mock_entry
|
||||||
|
) -> None:
|
||||||
|
"""Washer hardware reports no oneUiVersion; device type must resolve
|
||||||
|
via for_device_by_model instead of falling back to generic CAPABILITIES."""
|
||||||
|
resources = {
|
||||||
|
'/information/vs/0': {
|
||||||
|
'x.com.samsung.da.modelNum':
|
||||||
|
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
|
||||||
|
'x.com.samsung.da.description':
|
||||||
|
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
|
||||||
|
'x.com.samsung.da.serialNum': 'TEST-SERIAL',
|
||||||
|
},
|
||||||
|
'/otninformation/vs/0': {'otnStatus': 'None'},
|
||||||
|
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
|
||||||
|
}
|
||||||
|
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||||
|
coordinator._run_discovery(resources)
|
||||||
|
assert coordinator.device_type_name == 'washer'
|
||||||
|
|
||||||
|
|
||||||
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
|
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
|
||||||
hass: HomeAssistant, mock_entry
|
hass: HomeAssistant, mock_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -70,3 +70,95 @@ class TestDeviceRegistries:
|
|||||||
for cap in caps:
|
for cap in caps:
|
||||||
assert cap.rt_filter is not None or cap.match_fn is not None, \
|
assert cap.rt_filter is not None or cap.match_fn is not None, \
|
||||||
f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"
|
f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWasherRegistry:
|
||||||
|
def test_washer_registry_registered(self):
|
||||||
|
from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY
|
||||||
|
assert 'washer' in _REGISTRY_BY_KEY
|
||||||
|
assert _REGISTRY_BY_KEY['washer'].name == 'washer'
|
||||||
|
|
||||||
|
def test_washer_registry_has_no_dup_hrefs(self):
|
||||||
|
from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY
|
||||||
|
registry = _REGISTRY_BY_KEY['washer']
|
||||||
|
for href, caps in registry.capabilities.items():
|
||||||
|
if len(caps) > 1:
|
||||||
|
for cap in caps:
|
||||||
|
assert cap.rt_filter is not None or cap.match_fn is not None, \
|
||||||
|
f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"
|
||||||
|
|
||||||
|
def test_washer_registry_covers_known_hrefs(self):
|
||||||
|
from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY
|
||||||
|
registry = _REGISTRY_BY_KEY['washer']
|
||||||
|
for href in (
|
||||||
|
'/power/0', '/power/vs/0', '/kidslock/0', '/kidslock/vs/0',
|
||||||
|
'/remotectrl/0', '/remotectrl/vs/0', '/alarms/vs/0',
|
||||||
|
'/energy/consumption/vs/0', '/water/consumption/vs/0',
|
||||||
|
'/operational/state/vs/0', '/washer/vs/0', '/course/vs/0',
|
||||||
|
'/buzzersound/vs/0', '/wm/jobbeginingstatus/vs/0',
|
||||||
|
'/diagnosis/vs/0', '/otninformation/vs/0',
|
||||||
|
):
|
||||||
|
assert href in registry.capabilities, f"{href} missing from washer registry"
|
||||||
|
|
||||||
|
|
||||||
|
class TestForDeviceByModel:
|
||||||
|
"""Fallback device-type detection for hardware without oneUiVersion."""
|
||||||
|
|
||||||
|
def test_washer_ww_prefix(self):
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
|
||||||
|
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
|
||||||
|
)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg.name == 'washer'
|
||||||
|
|
||||||
|
def test_washer_wd_prefix(self):
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
|
||||||
|
'DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D',
|
||||||
|
)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg.name == 'washer'
|
||||||
|
|
||||||
|
def test_dryer_not_misdetected_as_washer(self):
|
||||||
|
"""Dryer shares the DA_WM_ board prefix with washer -- must not
|
||||||
|
be misrouted despite the shared prefix."""
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'DA_WM_TP2_20_COMMON_DV5000T', 'DA_WM_TP2_20_COMMON_DV5000T',
|
||||||
|
)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg.name == 'dryer'
|
||||||
|
|
||||||
|
def test_dishwasher_not_misdetected_as_washer(self):
|
||||||
|
"""Dishwasher's modelNum contains the substring 'WW' -- must not
|
||||||
|
be misrouted by a naive substring match."""
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'ADW-WW-RTL-24-AILITE|90000541|400002010019130059C1000500E10000',
|
||||||
|
'ADW-WW-RTL-24-AILITE_DW9000F/DD91-00002A_0002',
|
||||||
|
)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg.name == 'dishwasher'
|
||||||
|
|
||||||
|
def test_refrigerator_via_modelnum_ref_token(self):
|
||||||
|
"""Refrigerator's description has no consumer-model suffix; falls
|
||||||
|
back to the '_REF_' token in modelNum."""
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'TP1X_REF_21K|00176141|00000850031813294103010041030000',
|
||||||
|
'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')
|
||||||
|
assert reg is None
|
||||||
|
|
||||||
|
def test_empty_inputs_return_none(self):
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
assert for_device_by_model('', '') is None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for dishwasher-specific capabilities."""
|
||||||
|
from custom_components.localthings.registry.capabilities import dishwasher
|
||||||
|
|
||||||
|
|
||||||
|
class TestCycleOptions:
|
||||||
|
def test_parses_edit_course_list(self):
|
||||||
|
raw = 'EditCourseList_0E07908683848D808E8F'
|
||||||
|
assert dishwasher._parse_edit_course_list(raw) == [
|
||||||
|
'0E', '07', '90', '86', '83', '84', '8D', '80', '8E', '8F',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||||
|
assert dishwasher._parse_edit_course_list(None) == []
|
||||||
|
assert dishwasher._parse_edit_course_list('') == []
|
||||||
|
assert dishwasher._parse_edit_course_list('no underscore') == []
|
||||||
|
|
||||||
|
def test_cycle_options_reads_live_edit_course_list(self):
|
||||||
|
resources = {
|
||||||
|
'/wm/editcourse/vs/0': {
|
||||||
|
'x.com.samsung.da.editCourseList': 'EditCourseList_0E9086',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert dishwasher._cycle_options(resources) == ['0E', '90', '86']
|
||||||
|
|
||||||
|
def test_cycle_options_empty_when_resource_absent(self):
|
||||||
|
assert dishwasher._cycle_options({}) == []
|
||||||
|
|
||||||
|
def test_cycle_options_empty_when_resource_empty(self):
|
||||||
|
resources = {'/wm/editcourse/vs/0': {}}
|
||||||
|
assert dishwasher._cycle_options(resources) == []
|
||||||
|
|
||||||
|
def test_cycle_desc_uses_cycle_options_callable(self):
|
||||||
|
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||||
|
assert desc.options is dishwasher._cycle_options
|
||||||
|
assert desc.translation_key == 'dishwasher_cycle'
|
||||||
|
|
||||||
|
def test_exists_only_when_edit_course_list_is_live(self):
|
||||||
|
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||||
|
assert desc.exists_fn({}, {}) is False
|
||||||
|
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
|
||||||
|
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_0E'}}
|
||||||
|
assert desc.exists_fn({}, live) is True
|
||||||
|
|
||||||
|
def test_cycle_write_uses_raw_code_directly(self):
|
||||||
|
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||||
|
rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']}
|
||||||
|
path, body = desc.write_fn('90', rep)
|
||||||
|
assert path == ['course', 'vs', '0']
|
||||||
|
assert body == {
|
||||||
|
'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'],
|
||||||
|
}
|
||||||
@@ -7,12 +7,18 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
|
|||||||
|
|
||||||
|
|
||||||
def _new_state_keys(name, resources):
|
def _new_state_keys(name, resources):
|
||||||
from custom_components.localthings.registry.by_type import for_device
|
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
||||||
from custom_components.localthings.registry.discovery import discover
|
from custom_components.localthings.registry.discovery import discover
|
||||||
from custom_components.localthings.registry.adapter import flatten
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
otn = resources.get('/otninformation/vs/0', {})
|
otn = resources.get('/otninformation/vs/0', {})
|
||||||
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
|
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
|
||||||
|
info = resources.get('/information/vs/0', {})
|
||||||
reg = for_device(one_ui) if one_ui else None
|
reg = for_device(one_ui) if one_ui else None
|
||||||
|
if reg is None:
|
||||||
|
reg = for_device_by_model(
|
||||||
|
info.get('x.com.samsung.da.modelNum', ''),
|
||||||
|
info.get('x.com.samsung.da.description', ''),
|
||||||
|
)
|
||||||
if reg is None:
|
if reg is None:
|
||||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||||
caps, pats = CAPABILITIES, []
|
caps, pats = CAPABILITIES, []
|
||||||
@@ -39,6 +45,18 @@ def test_registry_reproduces_golden_state_keys(name, ip, request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_reproduces_golden_state_keys_for_washer():
|
||||||
|
from tests.conftest import _load_device
|
||||||
|
resources = _load_device('washer')
|
||||||
|
golden = json.loads((GOLDEN / 'washer.json').read_text())
|
||||||
|
state_keys = _new_state_keys('washer', resources)
|
||||||
|
assert set(state_keys) == set(golden['state_keys']), (
|
||||||
|
f"state_keys mismatch:\n"
|
||||||
|
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||||
|
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_resources_from_batch_preferred_over_flat():
|
def test_resources_from_batch_preferred_over_flat():
|
||||||
from tests.conftest import _resources_from_dump
|
from tests.conftest import _resources_from_dump
|
||||||
dump = {
|
dump = {
|
||||||
|
|||||||
@@ -6,3 +6,35 @@ def test_machine_state_maps_samsung_to_ocf():
|
|||||||
assert ms.value_fn('Run') == 'active'
|
assert ms.value_fn('Run') == 'active'
|
||||||
assert ms.value_fn('Pause') == 'pause'
|
assert ms.value_fn('Pause') == 'pause'
|
||||||
assert ms.value_fn('Ready') == 'idle'
|
assert ms.value_fn('Ready') == 'idle'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelayFieldFallback:
|
||||||
|
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||||
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||||
|
rep = {'x.com.samsung.da.delayEndTime': '02:30:00'}
|
||||||
|
assert desc.rep_fn(rep) == 2.5
|
||||||
|
|
||||||
|
def test_prefers_delay_start_time_when_both_present(self):
|
||||||
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||||
|
rep = {
|
||||||
|
'x.com.samsung.da.delayStartTime': '01:00:00',
|
||||||
|
'x.com.samsung.da.delayEndTime': '02:00:00',
|
||||||
|
}
|
||||||
|
assert desc.rep_fn(rep) == 1.0
|
||||||
|
|
||||||
|
def test_write_targets_delay_end_time_when_that_is_what_device_reports(self):
|
||||||
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||||
|
rep = {'x.com.samsung.da.delayEndTime': '00:00:00'}
|
||||||
|
path, body = desc.write_fn(1.5, rep)
|
||||||
|
assert path == ['operational', 'state', 'vs', '0']
|
||||||
|
assert body == {'x.com.samsung.da.delayEndTime': '1:30:00'}
|
||||||
|
|
||||||
|
def test_write_targets_delay_start_time_by_default(self):
|
||||||
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||||
|
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
|
||||||
|
path, body = desc.write_fn(1.5, rep)
|
||||||
|
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
|
||||||
@@ -57,3 +57,19 @@ def test_dishwasher_fixture_has_no_remaining_gaps():
|
|||||||
resources = _load('dishwasher_device.json')
|
resources = _load('dishwasher_device.json')
|
||||||
unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY))
|
unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY))
|
||||||
assert unbound == set()
|
assert unbound == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWasherIgnoredHrefs:
|
||||||
|
def test_washer_hrefs_are_ignored(self):
|
||||||
|
from custom_components.localthings.registry.capabilities.ignored import IGNORED
|
||||||
|
ignored_hrefs = {cap.href for cap in IGNORED}
|
||||||
|
for href in (
|
||||||
|
'/st/washercourse/vs/0',
|
||||||
|
'/wm/welcomemsg/vs/0',
|
||||||
|
'/wm/personalcourse/vs/0',
|
||||||
|
'/energy/consumption/0',
|
||||||
|
'/cycleinterface/vs/0',
|
||||||
|
'/drlc/0',
|
||||||
|
'/operational/state/0',
|
||||||
|
):
|
||||||
|
assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for LocalThingsSelect's option-list resolution
|
||||||
|
(custom_components/localthings/select.py) -- the static tuple, options_field,
|
||||||
|
and callable forms of SelectDesc.options.
|
||||||
|
"""
|
||||||
|
from custom_components.localthings.registry.capability import Capability
|
||||||
|
from custom_components.localthings.registry.discovery import BoundEntity
|
||||||
|
from custom_components.localthings.registry.entities import SelectDesc
|
||||||
|
from custom_components.localthings.select import LocalThingsSelect
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCoordinator:
|
||||||
|
device_serial = 'TEST-SERIAL'
|
||||||
|
|
||||||
|
def __init__(self, last_resources):
|
||||||
|
self.last_resources = last_resources
|
||||||
|
|
||||||
|
|
||||||
|
def _make_select(desc, href, last_resources):
|
||||||
|
capability = Capability(href=href, entities=(desc,))
|
||||||
|
bound = BoundEntity(href=href, capability=capability, desc=desc)
|
||||||
|
return LocalThingsSelect(_FakeCoordinator(last_resources), bound)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_options_unaffected():
|
||||||
|
desc = SelectDesc(key='x', options=('A', 'B'))
|
||||||
|
entity = _make_select(desc, '/x/vs/0', {})
|
||||||
|
assert entity.options == ['A', 'B']
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_field_unaffected():
|
||||||
|
desc = SelectDesc(key='x', options_field='supported')
|
||||||
|
entity = _make_select(desc, '/x/vs/0', {'/x/vs/0': {'supported': ['Lo', 'Hi']}})
|
||||||
|
assert entity.options == ['Lo', 'Hi']
|
||||||
|
|
||||||
|
|
||||||
|
def test_callable_options_receives_full_resource_snapshot():
|
||||||
|
"""A callable options is handed the coordinator's full href->rep
|
||||||
|
snapshot, not just this entity's own href's rep -- needed for course
|
||||||
|
lists decoded from a sibling resource (see washer._cycle_options)."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def _options_fn(resources):
|
||||||
|
calls.append(resources)
|
||||||
|
return list(resources.get('/other/vs/0', {}).get('codes', []))
|
||||||
|
|
||||||
|
desc = SelectDesc(key='cycle', translation_key='fake_cycle', options=_options_fn)
|
||||||
|
resources = {
|
||||||
|
'/x/vs/0': {},
|
||||||
|
'/other/vs/0': {'codes': ['1C', '1D']},
|
||||||
|
}
|
||||||
|
entity = _make_select(desc, '/x/vs/0', resources)
|
||||||
|
assert entity.options == ['1c', '1d']
|
||||||
|
assert calls == [resources]
|
||||||
|
|
||||||
|
|
||||||
|
def test_callable_options_empty_result():
|
||||||
|
desc = SelectDesc(key='cycle', options=lambda resources: [])
|
||||||
|
entity = _make_select(desc, '/x/vs/0', {})
|
||||||
|
assert entity.options == []
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Tests for washer-specific capabilities."""
|
||||||
|
from custom_components.localthings.registry.capabilities import washer
|
||||||
|
|
||||||
|
|
||||||
|
def _rep_by_href(cap, href):
|
||||||
|
assert cap.href == href
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
|
class TestWasherSettings:
|
||||||
|
def test_href(self):
|
||||||
|
assert washer.WASHER_SETTINGS.href == '/washer/vs/0'
|
||||||
|
|
||||||
|
def test_wash_temperature_read(self):
|
||||||
|
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature')
|
||||||
|
assert desc.field == 'x.com.samsung.da.waterTemperature'
|
||||||
|
assert desc.options_field == 'x.com.samsung.da.supportedWaterTemperature'
|
||||||
|
|
||||||
|
def test_wash_temperature_write(self):
|
||||||
|
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature')
|
||||||
|
path, body = desc.write_fn('60', {})
|
||||||
|
assert path == ['washer', 'vs', '0']
|
||||||
|
assert body == {'x.com.samsung.da.waterTemperature': '60'}
|
||||||
|
|
||||||
|
def test_spin_speed_write(self):
|
||||||
|
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'spin_speed')
|
||||||
|
path, body = desc.write_fn('1400', {})
|
||||||
|
assert path == ['washer', 'vs', '0']
|
||||||
|
assert body == {'x.com.samsung.da.spinLevel': '1400'}
|
||||||
|
|
||||||
|
def test_rinse_cycles_write(self):
|
||||||
|
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'rinse_cycles')
|
||||||
|
path, body = desc.write_fn('3', {})
|
||||||
|
assert path == ['washer', 'vs', '0']
|
||||||
|
assert body == {'x.com.samsung.da.rinseCycles': '3'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestWasherCourse:
|
||||||
|
def test_href(self):
|
||||||
|
assert washer.WASHER_COURSE.href == '/course/vs/0'
|
||||||
|
|
||||||
|
def test_translation_key(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
assert desc.translation_key == 'washer_cycle'
|
||||||
|
|
||||||
|
def test_reads_raw_course_code_from_options_array(self):
|
||||||
|
"""rep_fn returns the raw device code; display names come from
|
||||||
|
strings.json via translation_key, not from Python (see select.py's
|
||||||
|
_display())."""
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
|
||||||
|
assert desc.rep_fn(rep) == '1C'
|
||||||
|
|
||||||
|
def test_missing_course_option_returns_none(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
|
||||||
|
|
||||||
|
def test_cycle_desc_uses_cycle_options_callable(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
assert desc.options is washer._cycle_options
|
||||||
|
|
||||||
|
def test_exists_only_when_edit_course_list_is_live(self):
|
||||||
|
"""No hardcoded course table is kept -- the selector only appears
|
||||||
|
when a device actually populates editCourseList (see
|
||||||
|
_cycle_options's docstring for why MostUsed_ isn't used either)."""
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
assert desc.exists_fn({}, {}) is False
|
||||||
|
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
|
||||||
|
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_1C'}}
|
||||||
|
assert desc.exists_fn({}, live) is True
|
||||||
|
|
||||||
|
def test_cycle_write(self):
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||||
|
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
|
||||||
|
path, body = desc.write_fn('1D', rep)
|
||||||
|
assert path == ['course', 'vs', '0']
|
||||||
|
assert body == {
|
||||||
|
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCycleOptions:
|
||||||
|
def test_parses_edit_course_list(self):
|
||||||
|
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
||||||
|
assert washer._parse_edit_course_list(raw) == [
|
||||||
|
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
||||||
|
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||||
|
assert washer._parse_edit_course_list(None) == []
|
||||||
|
assert washer._parse_edit_course_list('') == []
|
||||||
|
assert washer._parse_edit_course_list('no underscore') == []
|
||||||
|
|
||||||
|
def test_cycle_options_reads_live_edit_course_list(self):
|
||||||
|
"""A different washer model's own course list -- including a code
|
||||||
|
('65') never seen on the primary dump -- is used as-is; there is
|
||||||
|
no hardcoded table to fall back to or reconcile against."""
|
||||||
|
resources = {
|
||||||
|
'/wm/editcourse/vs/0': {
|
||||||
|
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert washer._cycle_options(resources) == ['65', '1C']
|
||||||
|
|
||||||
|
def test_cycle_options_empty_when_resource_absent(self):
|
||||||
|
assert washer._cycle_options({}) == []
|
||||||
|
|
||||||
|
def test_cycle_options_empty_when_resource_empty(self):
|
||||||
|
"""The second known washer dump has /wm/editcourse/vs/0 == {}."""
|
||||||
|
resources = {'/wm/editcourse/vs/0': {}}
|
||||||
|
assert washer._cycle_options(resources) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuzzerSound:
|
||||||
|
def test_href(self):
|
||||||
|
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||||
|
|
||||||
|
def test_buzzer_sound_write(self):
|
||||||
|
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
|
||||||
|
path, body = desc.write_fn('Volume_High', {})
|
||||||
|
assert path == ['buzzersound', 'vs', '0']
|
||||||
|
assert body == {'setBuzzerSound': 'Volume_High'}
|
||||||
|
|
||||||
|
def test_finish_sound_exists_only_when_supported(self):
|
||||||
|
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
||||||
|
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
|
||||||
|
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
|
||||||
|
|
||||||
|
def test_finish_sound_write(self):
|
||||||
|
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
||||||
|
path, body = desc.write_fn('FinishSound_2', {})
|
||||||
|
assert path == ['buzzersound', 'vs', '0']
|
||||||
|
assert body == {'setFinishSound': 'FinishSound_2'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobBeginningStatus:
|
||||||
|
def test_href_and_field(self):
|
||||||
|
assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
|
||||||
|
desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0]
|
||||||
|
assert desc.field == 'x.com.samsung.da.currentStatus'
|
||||||
|
|
||||||
|
|
||||||
|
class TestPowerFallback:
|
||||||
|
def test_generic_href(self):
|
||||||
|
assert washer.POWER_GENERIC.href == '/power/0'
|
||||||
|
|
||||||
|
def test_generic_read_write(self):
|
||||||
|
desc = washer.POWER_GENERIC.entities[0]
|
||||||
|
assert desc.value_fn(True) is True
|
||||||
|
assert desc.value_fn(False) is False
|
||||||
|
path, body = desc.write_fn('On', {})
|
||||||
|
assert path == ['power', '0']
|
||||||
|
assert body == {'value': True}
|
||||||
|
path, body = desc.write_fn('Off', {})
|
||||||
|
assert body == {'value': False}
|
||||||
|
|
||||||
|
def test_vs_fallback_binds_only_when_generic_absent(self):
|
||||||
|
assert washer.POWER_VS_FALLBACK.href == '/power/vs/0'
|
||||||
|
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True
|
||||||
|
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/0': {}, '/power/vs/0': {}}) is False
|
||||||
|
|
||||||
|
def test_vs_fallback_read_write(self):
|
||||||
|
desc = washer.POWER_VS_FALLBACK.entities[0]
|
||||||
|
assert desc.value_fn('On') is True
|
||||||
|
assert desc.value_fn('Off') is False
|
||||||
|
path, body = desc.write_fn('On', {})
|
||||||
|
assert path == ['power', 'vs', '0']
|
||||||
|
assert body == {'x.com.samsung.da.power': 'On'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestKidsLockFallback:
|
||||||
|
def test_generic_read_write(self):
|
||||||
|
assert washer.KIDS_LOCK_GENERIC.href == '/kidslock/0'
|
||||||
|
desc = washer.KIDS_LOCK_GENERIC.entities[0]
|
||||||
|
assert desc.value_fn(True) is True
|
||||||
|
path, body = desc.write_fn('On', {})
|
||||||
|
assert path == ['kidslock', '0']
|
||||||
|
assert body == {'value': True}
|
||||||
|
|
||||||
|
def test_vs_fallback_gated(self):
|
||||||
|
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
|
||||||
|
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn(
|
||||||
|
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteControlFallback:
|
||||||
|
def test_generic_read(self):
|
||||||
|
assert washer.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0'
|
||||||
|
desc = washer.REMOTE_CONTROL_GENERIC.entities[0]
|
||||||
|
assert desc.value_fn(True) is True
|
||||||
|
assert desc.value_fn(False) is False
|
||||||
|
|
||||||
|
def test_vs_fallback_gated(self):
|
||||||
|
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
|
||||||
Reference in new issue
Block a user