diff --git a/README.md b/README.md index c433033..0c9c89e 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,16 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi |---|---| | Air conditioner | `by_type/airconditioner.py` | | Air purifier | `by_type/air_purifier.py` | +| Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | -| Cooktop (read-only burner status) | `by_type/cooktop.py` | +| Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | | Dishwasher | `by_type/dishwasher.py` | | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | +| Water purifier | `by_type/water_purifier.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 664f170..37a8595 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -197,6 +197,18 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: return _order_candidates(live) +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check here (and the equivalent one in coordinator.py's + `_run_discovery`) doesn't catch it, and two such units get the same + config-entry unique_id / entity unique_ids and collide (issue #83). + """ + return serial.strip().lower().startswith('nothing') + + def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: """Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict.""" import cbor2 @@ -261,7 +273,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: .get('/information/vs/0', {}) .get('x.com.samsung.da.serialNum', '') ) - if not serial: + if not serial or _is_placeholder_serial(serial): serial = f"{host}:{port}" one_ui_version = ( resources diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 58635ff..333df3a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -53,6 +53,22 @@ class _NoOpDescriptor: _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling +def _is_placeholder_serial(serial: str) -> bool: + """True for a non-empty serialNum that isn't actually a real identity. + + The ARTIK051_DONGLE_REF firmware family reports the literal string + 'Nothing(SVC)' for every unit -- non-empty, so the plain `if not + serial` check below doesn't catch it, and `device_serial` feeds both + the HA device-registry identifier and every entity's unique_id + (entity.py), so two such units on the same install silently collide + and the second one's entities get dropped (issue #83). Mirrors the + identical helper in config_flow.py's `_probe_and_validate` -- kept + separate rather than imported to avoid pulling the config-flow module + into the runtime coordinator's import graph for a two-line check. + """ + return serial.strip().lower().startswith('nothing') + + class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages one Samsung appliance: session, discovery, polling.""" @@ -316,7 +332,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._unbound_hrefs = unbound serial = info.get('x.com.samsung.da.serialNum', '') - if not serial: + if not serial or _is_placeholder_serial(serial): serial = self._entry.data[CONF_HOST] self.device_serial = serial diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 93d43a1..b5ff66e 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,8 +3,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, - range as _range, range_hood, refrigerator, washer, + air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, + oven, range as _range, range_hood, refrigerator, washer, water_purifier, ) __all__ = [ @@ -19,6 +19,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'airconditioner': airconditioner.REGISTRY, 'air_conditioner': airconditioner.REGISTRY, 'cooktop': cooktop.REGISTRY, + 'dehumidifier': dehumidifier.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, @@ -27,6 +28,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, + 'water_purifier': water_purifier.REGISTRY, } @@ -103,6 +105,26 @@ def _model_num_segments(model_num: str) -> list[str]: return prefix.split('_') +def _consumer_model_key(description: str) -> Optional[str]: + """Registry key from the consumer-model token in `description`, or None. + + Usually that token is the last '_'-delimited segment before any + '/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4'). + But issue #79's dryer pairs two model numbers in one description -- + '..._DVE50A8800_8600/DC92-...' -- so the true consumer token + ('DVE50A8800') sits one segment *before* the actual last segment + ('8600', a bare second model number with no recognizable prefix). Scan + segments from the end and take the first one that resolves, rather + than assuming the last segment is always it. + """ + segments = (description or '').split('/', 1)[0].split('_') + for segment in reversed(segments): + key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper()) + if key is not None: + return key + return None + + 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 @@ -116,8 +138,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi 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()) + key = _consumer_model_key(description) if key is None and 'REF' in _model_num_segments(model_num): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and @@ -138,6 +159,22 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # resource (see airconditioner.py's _AC_IGNORED). if key is None and '-CAWW-' in (model_num or '').upper(): key = 'airconditioner' + # Dehumidifiers (e.g. TP1X_DA_AC_DHM_01001_0000, issue #88) share the + # DA_AC_ board family with the room-AC models above but carry the + # '_DHM_' (DeHuMidifier) token instead of '_RAC_'/'_PRAC_'. Distinct + # registry: target humidity, not temperature, is the primary control, + # and there's no climate composite. + if key is None and '_DHM_' in (model_num or ''): + key = 'dehumidifier' + # Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87) + # report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner) + # token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface + # as the room-AC models above (mode/convenient/wind/temperature/power/ + # filter/humidity all confirmed against the issue #87 dump binding + # cleanly against the existing airconditioner registry once routed + # here) -- no WAC-specific resources needed. + if key is None and '_WAC_' in (model_num or ''): + key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. if key is None and '_TVTL_' in (model_num or ''): @@ -145,6 +182,10 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' + # Water purifiers (e.g. TP2X_WATERPURIFIER_20K, issue #90) report no + # oneUiVersion and carry the 'WATERPURIFIER' board-family token. + if key is None and 'WATERPURIFIER' in model_identity: + key = 'water_purifier' if key is None and model_identity.startswith('AHD-'): key = 'range_hood' # Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) -- diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index 08084d9..91fc189 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -1,11 +1,21 @@ -"""Cooktop device registry.""" +"""Gas cooktop device registry (NA9300K-class, PR #23). + +Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label +doesn't collide with the unrelated induction_cooktop family (issue #86, +by_type/induction_cooktop.py) -- two different OCF surfaces that happen to +share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup +key is unchanged: it's relied on by real devices reporting +oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule +(for_device_by_model), and the resource-signature fallback +(for_device_by_resources) alike. +""" from ..capabilities import common, cooktop, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( - name='cooktop', + name='gas_cooktop', capabilities=_build([ *ignored.IGNORED, cooktop.COOKTOP_POWER, diff --git a/custom_components/localthings/registry/by_type/dehumidifier.py b/custom_components/localthings/registry/by_type/dehumidifier.py new file mode 100644 index 0000000..9c3207e --- /dev/null +++ b/custom_components/localthings/registry/by_type/dehumidifier.py @@ -0,0 +1,23 @@ +"""Dehumidifier device registry (Samsung TP1X_DA_AC_DHM-class, issue #88). + +Shares the DA_AC_ board family with airconditioner.py (power, air filter, +auto-clean, mute-once all use the identical resource shapes), so those three +Capability objects are reused directly rather than duplicated. +""" +from ..capabilities import airconditioner, common, dehumidifier, ignored +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='dehumidifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + dehumidifier.MODE, + dehumidifier.HUMIDITY, + airconditioner.AUTO_CLEAN, + airconditioner.AIR_FILTER, + airconditioner.MUTE_ONCE, + *dehumidifier.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py new file mode 100644 index 0000000..e1ac333 --- /dev/null +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -0,0 +1,18 @@ +"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90). +""" +from ..capabilities import common, ignored, water_purifier +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='water_purifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + common.WATER_FILTER, + water_purifier.DISPENSE, + water_purifier.STATUS, + water_purifier.FAVORITE_CAPACITY, + water_purifier.LOCK, + *water_purifier.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py new file mode 100644 index 0000000..4b58047 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -0,0 +1,85 @@ +"""Capabilities for the Samsung dehumidifier family (TP1X_DA_AC_DHM-class, +issue #88, model AY18CG7500GED). + +Same DA_AC_ board family as the room-AC models in airconditioner.py (shared +power/energy/filter/auto-clean/mute-once resource shapes), but target +humidity -- not temperature -- is this device's primary control, and there +is no climate composite: power, mode, and humidity are exposed as separate +entities rather than folded into one card. +""" +from ..capability import Capability +from ..entities import NumberDesc, SelectDesc, SensorDesc +from .common import int_or_none + + +def _first_mode(rep): + """Representative scalar for the operating-mode select. `modes` is a + single-element list on every dump seen so far, mirroring + airconditioner._first_mode's handling of the same field shape.""" + modes = rep.get('x.com.samsung.da.modes') + if isinstance(modes, (list, tuple)): + return modes[0] if modes else None + return modes + + +MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='operating_mode', rep_fn=_first_mode, + icon='mdi:tune-variant', + options_field='x.com.samsung.da.supportedModes', + write_fn=lambda p, rep, href=None: ( + ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})), + ), +) + +# Target humidity is this device's primary control (the issue-#88 dump's +# equivalent of a thermostat setpoint). No min/max range field is present in +# any dump seen so far -- native_min/native_max are deliberately left unset +# so the number entity falls back to HA's own 0-100 default, the natural +# bound for a percentage field, rather than a bound guessed from one unit's +# spec sheet (see the adding-device-support skill's "never hard-code the one +# dump's values" section). Step comes live from the device's own `increment` +# field. +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.humidity', + device_class='humidity', unit='%', state_class='measurement', + value_fn=int_or_none), + NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity', + device_class='humidity', unit='%', + icon='mdi:water-percent', + entity_category='config', + value_fn=int_or_none, + step_fn=lambda rep: int_or_none(rep.get('increment')) or 1, + write_fn=lambda p, rep, href=None: ( + ['humidity', 'vs', '0'], + {'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})), + ), +) + +# --------------------------------------------------------------------------- +# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state +# or no documented write contract, following the same 'don't guess' rule as +# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in +# the global ignored.IGNORED since some of these hrefs collide with other +# families' schemas. +# --------------------------------------------------------------------------- +_DHM_IGNORED = [ + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM) + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/personality/presence/vs/0', # presence-personalization plumbing (empty item value) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/sensors/vs/0', # empty {} on this dump + '/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert) + # Only supportedModes ([Off, Sleep]) is present -- no live "current + # value" field on this dump to confirm the read/write contract, so per + # the 'don't guess' rule this is left unmodeled rather than assumed. + '/mode/convenient/vs/0', +] + +COVERAGE = [Capability(href=h) for h in _DHM_IGNORED] diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index f1fcb70..8d5224c 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -42,7 +42,10 @@ DRYER_SETTINGS = Capability( # under entity.select.dryer_cycle (Table_03, DV5000-class, captured # 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 # editCourseList but aren't identified yet -- they render as the raw code -# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected +# until named. Codes '01' Normal and '06' Time dry were confirmed on a +# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical +# appliance and reading back the raw code from the entity's state (issue +# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected # course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 # is ignored for washers. DRYER_COURSE = Capability( diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 615f058..a006c16 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -45,6 +45,14 @@ from .laundry import ( # (issue #22). Combo units carry their own course set, so these codes # don't imply anything about '1F' on a plain washer. # +# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a +# WF50A8600AV/US, verified directly rather than by inference: the reporter +# selected each cycle on the physical appliance and read back the resulting +# raw code from the cycle_select entity's state (issue #80). '54' shares a +# display name with the existing '24' Towels -- a different code on a +# different course table legitimately landing on the same label, not a typo +# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above). +# # 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 diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py new file mode 100644 index 0000000..f27e484 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -0,0 +1,165 @@ +"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class, +issue #90, model TP2X_WATERPURIFIER_20K). + +Resources verified against the issue #90 diagnostics dump. +""" +from datetime import datetime, timezone + +from ..capability import Capability +from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import int_or_none + + +def _parse_iso_utc(raw): + """Bare ISO datetime with no timezone field alongside it -- treated as + UTC, matching this integration's convention for other bare ISO datetime + fields (see washer.py's drum-clean-log comment).""" + if not raw: + return None + try: + return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) + except ValueError: + return None + + +DISPENSE = Capability( + href='/setting/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType', + icon='mdi:cup-water', + options_field='x.com.samsung.da.supportedTypes', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredType': p})), + # Only a handful of discrete temperatures are selectable (not a + # continuous range) -- a select over the live-reported set, not a + # number with invented bounds. + SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.supportedHotTemperatures', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.tempDesiredHotWater': p})), + # Bounds and step come live from the device's own + # desiredCapacityRange/capacityResolution fields, not a hardcoded + # constant -- see the adding-device-support skill's "never hard-code + # the one dump's values" section. No unit is set: capacityUnit reads + # "C" on this dump, which can't be right for a volume field, so per + # the 'don't guess' rule the unit is left unset rather than assumed + # to be mL. + NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity', + icon='mdi:cup-water', + value_fn=int_or_none, + range_field='x.com.samsung.da.desiredCapacityRange', + step_fn=lambda rep: int_or_none( + rep.get('x.com.samsung.da.capacityResolution')) or 1, + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})), + BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus', + icon='mdi:cup-water', + value_fn=lambda v: v == 'On'), + ), +) + +STATUS = Capability( + href='/status/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status', + icon='mdi:water-pump', + entity_category='diagnostic'), + BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus', + device_class='door', + entity_category='diagnostic', + value_fn=lambda v: v == 'Open'), + SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod', + icon='mdi:calendar-sync', + entity_category='diagnostic'), + SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime', + icon='mdi:timer-outline', + entity_category='diagnostic'), + SensorDesc(key='sterilize_last_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizeLastTime'))), + SensorDesc(key='sterilize_plan_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizePlanTime'))), + SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime', + icon='mdi:timer-sand', + entity_category='diagnostic'), + ), +) + +FAVORITE_CAPACITY = Capability( + href='/favorite/capacity/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity', + icon='mdi:star-outline', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})), + SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity', + icon='mdi:cup-water', + entity_category='config', + options_field='x.com.samsung.da.capacityList', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.defaultCapacity': p})), + ), +) + +LOCK = Capability( + href='/status/lock/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})), + ), +) + +# --------------------------------------------------------------------------- +# Water-purifier-scoped coverage: hrefs with no user-actionable state or no +# confirmed contract, following the 'don't guess' rule. +# --------------------------------------------------------------------------- +_WP_IGNORED = [ + # supportedModes carries a single opaque wizard-workflow token + # ('HOMECARE_WIZARD_V2') and modes reports a completely different, + # unrelated value ('WATERFILTER_DISABLE') not even present in + # supportedModes -- internal plumbing, not a real user-facing mode + # select. OCF-standard /mode/0 mirrors the same vendor resource but is + # already covered by the global ignored.IGNORED (fridge's OCF-native + # vacation-mode flag shares that href). + '/mode/vs/0', + # Static support-flags blob (automation.supported.modes/options) -- no + # live "current automation setting" field to expose. + '/automation/waterpurifier/vs/0', +] + +COVERAGE = [Capability(href=h) for h in _WP_IGNORED] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index ab38169..ada50d7 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -34,6 +34,9 @@ "door_open": { "name": "Door" }, + "filter_door_status": { + "name": "Filter door" + }, "firmware_update": { "name": "Firmware update available" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodic air sensing" }, + "pouring": { + "name": "Pouring" + }, "power_state": { "name": "Power state" }, @@ -114,6 +120,9 @@ "delay_start_hours": { "name": "Delay start" }, + "dispense_capacity": { + "name": "Dispense capacity" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -122,6 +131,9 @@ }, "sound_volume": { "name": "Sound volume" + }, + "target_humidity": { + "name": "Target humidity" } }, "select": { @@ -193,6 +205,9 @@ "8f": "Baby Care" } }, + "dispense_type": { + "name": "Dispense type" + }, "door_alert": { "name": "Door alarm", "state": { @@ -205,6 +220,8 @@ "dryer_cycle_table_03": { "name": "Cycle", "state": { + "01": "Normal", + "06": "Time dry", "16": "Cotton", "18": "Synthetics", "19": "Delicates", @@ -221,6 +238,9 @@ "1f": "Mixed load" } }, + "favorite_capacity": { + "name": "Favorite capacity" + }, "finish_sound": { "name": "Finish sound", "state": { @@ -252,6 +272,9 @@ "extra_high": "Extra high" } }, + "hot_water_temperature": { + "name": "Hot water temperature" + }, "ice_type": { "name": "{instance_name} type", "state": { @@ -290,6 +313,9 @@ "air_fry": "Air fry" } }, + "operating_mode": { + "name": "Operating mode" + }, "pantry_zone_mode": { "name": "Pantry zone mode", "state": { @@ -397,10 +423,13 @@ "38": "Cotton Dry", "39": "Synthetics Dry", "3a": "Drum Clean", + "52": "Eco Cold", "53": "Heavy Duty", + "54": "Towels", "55": "Activewear", "57": "Delicate", "5e": "Rinse+Spin", + "60": "Self Clean+", "65": "Colors", "66": "Denim", "7c": "Whites", @@ -513,6 +542,9 @@ "fan_speed_level": { "name": "Fan speed level" }, + "filter_clean_remain_time": { + "name": "Filter clean remaining time" + }, "filter_progress": { "name": "Filter progress" }, @@ -537,6 +569,9 @@ "hood_filter_capacity": { "name": "Filter capacity" }, + "humidity": { + "name": "Humidity" + }, "hood_filter_usage": { "name": "Filter usage" }, @@ -606,6 +641,18 @@ "selfcheck_status": { "name": "Self-check status" }, + "sterilize_last_time": { + "name": "Sterilize last time" + }, + "sterilize_period": { + "name": "Sterilize period" + }, + "sterilize_plan_time": { + "name": "Sterilize plan time" + }, + "sterilize_run_time": { + "name": "Sterilize run time" + }, "super_fine_dust": { "name": "Super fine dust" }, @@ -615,6 +662,9 @@ "water_liters": { "name": "Water consumption" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machine state", "state": { @@ -661,6 +711,9 @@ "bubble_soak": { "name": "Bubble soak" }, + "buzz_lock": { + "name": "Buzzer lock" + }, "cabinet_light_dim": { "name": "Brighten gradually" }, @@ -670,6 +723,9 @@ "child_lock": { "name": "Child lock" }, + "coldwater_lock": { + "name": "Cold water lock" + }, "defrost_delay": { "name": "Defrost delay" }, @@ -679,9 +735,15 @@ "fast_preheat": { "name": "Fast preheat" }, + "favorite_capacity_enabled": { + "name": "Favorite capacity" + }, "fridge_sound": { "name": "Sound" }, + "hotwater_lock": { + "name": "Hot water lock" + }, "ice_maker_enabled": { "name": "Ice maker" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 4f3fef8..e6e3813 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -34,6 +34,9 @@ "door_open": { "name": "Deur" }, + "filter_door_status": { + "name": "Filterdeur" + }, "firmware_update": { "name": "Firmware-update beschikbaar" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodieke luchtmeting" }, + "pouring": { + "name": "Schenken" + }, "power_state": { "name": "Voedingsstatus" }, @@ -114,6 +120,9 @@ "delay_start_hours": { "name": "Uitgestelde start" }, + "dispense_capacity": { + "name": "Schenkhoeveelheid" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -122,6 +131,9 @@ }, "sound_volume": { "name": "Geluidsvolume" + }, + "target_humidity": { + "name": "Doelvochtigheid" } }, "select": { @@ -193,6 +205,9 @@ "8f": "Babyverzorging" } }, + "dispense_type": { + "name": "Type dispenser" + }, "door_alert": { "name": "Deuralarm", "state": { @@ -205,6 +220,8 @@ "dryer_cycle_table_03": { "name": "Programma", "state": { + "01": "Normaal", + "06": "Tijdprogramma", "16": "Katoen", "18": "Synthetisch", "19": "Fijne was", @@ -221,6 +238,9 @@ "1f": "Gemengde was" } }, + "favorite_capacity": { + "name": "Favoriete capaciteit" + }, "finish_sound": { "name": "Eindgeluid", "state": { @@ -252,6 +272,9 @@ "extra_high": "Extra hoog" } }, + "hot_water_temperature": { + "name": "Temperatuur warm water" + }, "ice_type": { "name": "Type {instance_name}", "state": { @@ -290,6 +313,9 @@ "air_fry": "Airfryen" } }, + "operating_mode": { + "name": "Bedrijfsmodus" + }, "pantry_zone_mode": { "name": "Modus voorraadzone", "state": { @@ -397,10 +423,13 @@ "38": "Katoen drogen", "39": "Synthetisch drogen", "3a": "Trommel reinigen", + "52": "Eco koud", "53": "Intensief", + "54": "Handdoeken", "55": "Sportkleding", "57": "Fijn", "5e": "Spoelen+centrifugeren", + "60": "Zelfreinigend+", "65": "Bonte was", "66": "Spijkergoed", "7c": "Witte was", @@ -513,6 +542,9 @@ "fan_speed_level": { "name": "Ventilatorniveau" }, + "filter_clean_remain_time": { + "name": "Resterende tijd filterreiniging" + }, "filter_progress": { "name": "Filterverbruik" }, @@ -537,6 +569,9 @@ "hood_filter_capacity": { "name": "Filtercapaciteit" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "hood_filter_usage": { "name": "Filterverbruik" }, @@ -606,6 +641,18 @@ "selfcheck_status": { "name": "Status zelfcontrole" }, + "sterilize_last_time": { + "name": "Laatste sterilisatie" + }, + "sterilize_period": { + "name": "Sterilisatie-interval" + }, + "sterilize_plan_time": { + "name": "Geplande sterilisatie" + }, + "sterilize_run_time": { + "name": "Sterilisatieduur" + }, "super_fine_dust": { "name": "Ultrafijnstof" }, @@ -615,6 +662,9 @@ "water_liters": { "name": "Waterverbruik" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machinestatus", "state": { @@ -661,6 +711,9 @@ "bubble_soak": { "name": "Bubble Soak" }, + "buzz_lock": { + "name": "Zoemerslot" + }, "cabinet_light_dim": { "name": "Geleidelijk feller" }, @@ -670,6 +723,9 @@ "child_lock": { "name": "Kinderslot" }, + "coldwater_lock": { + "name": "Koudwaterslot" + }, "defrost_delay": { "name": "Ontdooien uitstellen" }, @@ -679,9 +735,15 @@ "fast_preheat": { "name": "Snel voorverwarmen" }, + "favorite_capacity_enabled": { + "name": "Favoriete capaciteit" + }, "fridge_sound": { "name": "Geluid" }, + "hotwater_lock": { + "name": "Heetwaterslot" + }, "ice_maker_enabled": { "name": "IJsmaker" }, diff --git a/tests/fixtures/airconditioner_window_ac_device.json b/tests/fixtures/airconditioner_window_ac_device.json new file mode 100644 index 0000000..c02ae37 --- /dev/null +++ b/tests/fixtures/airconditioner_window_ac_device.json @@ -0,0 +1,523 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": "0", + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8D0D00B4012C3F21064804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2647000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "AI_RAC_KOREA_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "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": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 244.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "244.000000", + "x.com.samsung.da.cumulativePower": "11001", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "20", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "60" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_WAC_01001_0000|10254341|60050000001711014E00402000002000", + "x.com.samsung.da.description": "TP1X_DA_AC_WAC_01001_0000", + "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": "AW0", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02545A260601", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02543A24061800,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "02580A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02553A23031600,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Quiet", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "OnTimer_0", + "OffTimer_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_230", + "Volume_Mute", + "OutdoorTemp_97", + "CoolCapa_0", + "StopAutoClean_Idle", + "Autoclean_Off", + "OptionCode_51320", + "ExtendOptionCode_230093", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_93", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "Off", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AWA-KR-TP1-23-AW7000", + "versions": [ + "15260601" + ], + "visVersion": "260601" + }, + { + "type": "Micom", + "modelId": "042010254341FFFFFFFF", + "versions": [ + "24061800", + "FFFFFFFF" + ], + "visVersion": "240618" + }, + { + "type": "Micom", + "modelId": "042010258041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042010255341FFFFFFFF", + "versions": [ + "23031600", + "FFFFFFFF" + ], + "visVersion": "230316" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "8DFFFFFFFF121E121E0000FFFFFFFFFFFF01000000011F0000000000000000001F8C00", + "x.com.samsung.da.id": "WAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 30.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "30.0", + "x.com.samsung.da.current": "30.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4", + "5" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/dehumidifier_device.json b/tests/fixtures/dehumidifier_device.json new file mode 100644 index 0000000..1b138e5 --- /dev/null +++ b/tests/fixtures/dehumidifier_device.json @@ -0,0 +1,386 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "224", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "224", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "224", + "300", + "500", + "700" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "100", + "cleaningTime": "30", + "minCleaningTime": "10", + "maxCleaningTime": "60", + "cleaningTimeUnit": "10", + "x.com.samsung.da.supportedStatus": [ + "Start", + "TimedClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "TimedClean", + "Off" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "WaterTankFull_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "259.000000", + "x.com.samsung.da.cumulativePower": "183099", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Smart", + "Max", + "High", + "Medium", + "Quiet", + "ClothesDrying" + ], + "x.com.samsung.da.modes": [ + "Smart" + ], + "x.com.samsung.da.options": [ + "Volume_Mute", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000", + "x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000", + "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": "AY0", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02544A260401", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02538A24032700,FFFFFFFFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_Heat" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "47", + "x.com.samsung.da.desiredHumidity": "50", + "increment": "5", + "x.com.samsung.da.fivepercentHumidity": "46" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "20001E46070A5A00000000000000", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "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": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "ADH-WW-TP1-23-AY7100", + "versions": [ + "18260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "042110253841FFFFFFFF", + "versions": [ + "24032700", + "FFFFFFFF" + ], + "visVersion": "240327" + }, + { + "type": "Micom", + "modelId": "042110259041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042110255241FFFFFFFF", + "versions": [ + "23020200", + "FFFFFFFF" + ], + "visVersion": "230202" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "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": "/welcome/humidity/vs/0", + "rep": { + "requestId": "0000", + "operatingStatus": "None" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/dryer_dve50a8600_device.json b/tests/fixtures/dryer_dve50a8600_device.json new file mode 100644 index 0000000..cfc135b --- /dev/null +++ b/tests/fixtures/dryer_dve50a8600_device.json @@ -0,0 +1,391 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "US" + } + }, + { + "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": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0152", + "UpdateAllow_NotAllowed", + "Course_01", + "AiOption_On", + "MixedLoadBell_Disable", + "MixedLoadBellNoti_Nothing", + "LaundryOutTime_0", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_3", + "DetergentTotal_0", + "SpecialFunction_20", + "AvailableDelayTime_39", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_B20870B400B4", + "WrinklePreventRunning_Off", + "DryTime_0", + "SendToDevice_Off", + "GMT_F6", + "DryTimeSet_0A00000000500A005A00000000000000001E0A005A00000000000000001414005A000000000000000000000000000000000000000000000000000000000000000000000000140A005A280A005A", + "EnergyLevelSet_0504040504040205020204040305020203030103", + "MostUsed_068A3ED000", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "MixedLoadBellSet_02FF02FFFF02FFFFFF0202FF02FFFF02FFFFFF", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "2018410D33E06833ED0002F8520D33E178520D3083E8520D000078102D33E338520D520358520D000348520D0000E8520D33E058520D33E308410D308318410D33E098410D3080F8204D308328308D33E2E8102D308368000D0000D8102D000" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "On" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "2323800", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784991600", + "x.com.samsung.da.cumulativeDateUTC": "1785009600" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-05:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080", + "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": "310", + "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|20286441|300000010015110002A3031700000000", + "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_20286441210514150FFFFF202835412105131904FFFF(01522028644120283541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "02864A21051415,02835A21051319", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Drying", + "Cooling", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "01:15:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "01:15: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", + "Drying", + "Cooling", + "Finish" + ] + } + }, + { + "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-04", + "otnList": [ + { + "type": "WIFI", + "modelId": "DA_WM_TP1_21_COMMON", + "versions": [ + "30260118" + ], + "visVersion": "260118" + }, + { + "type": "Micom", + "modelId": "01522028644120283541", + "versions": [ + "21051415", + "21051319" + ], + "visVersion": "210514" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "en_US", + "ko_KR", + "fr_CA", + "es_MX", + "pt_BR" + ], + "x.com.samsung.da.setLanguage": "en_US" + } + }, + { + "href": "/st/dryercourse/vs/0", + "rep": { + "x.com.samsung.da.st.dryerMode": "Table_03_Course_01", + "x.com.samsung.da.st.courseTable": "Table_03" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Chicago", + "offset": "-05:00", + "DST": "ON" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off", + "x.com.samsung.da.waterTemperature": "Medium", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "ExtraLow", + "Low", + "MediumLow", + "Medium", + "High" + ], + "x.com.samsung.da.dryLevel": "Normal", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Damp", + "Less", + "Normal", + "More", + "Very" + ], + "x.com.samsung.da.dryerType": "Electricity" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_0106", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_0101" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "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" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false", + "x.com.samsung.da.modelCode": "M(None),W(DVE50A8600V/A3)" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json new file mode 100644 index 0000000..5b4b5e4 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/golden/dehumidifier.json b/tests/fixtures/golden/dehumidifier.json new file mode 100644 index 0000000..cb1a015 --- /dev/null +++ b/tests/fixtures/golden/dehumidifier.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "operating_mode", + "power_switch", + "power_watts", + "target_humidity" + ] +} diff --git a/tests/fixtures/golden/dryer_dve50a8600.json b/tests/fixtures/golden/dryer_dve50a8600.json new file mode 100644 index 0000000..da10e04 --- /dev/null +++ b/tests/fixtures/golden/dryer_dve50a8600.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis", + "dry_level", + "dry_time", + "dryer_type", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/fixtures/golden/water_purifier.json b/tests/fixtures/golden/water_purifier.json new file mode 100644 index 0000000..3854bc9 --- /dev/null +++ b/tests/fixtures/golden/water_purifier.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "buzz_lock", + "coldwater_lock", + "dispense_capacity", + "dispense_type", + "favorite_capacity", + "favorite_capacity_enabled", + "filter_clean_remain_time", + "filter_door_status", + "filter_status", + "filter_usage", + "firmware_update", + "hot_water_temperature", + "hotwater_lock", + "pouring", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "sterilize_last_time", + "sterilize_period", + "sterilize_plan_time", + "sterilize_run_time", + "waterpurifier_status" + ] +} diff --git a/tests/fixtures/water_purifier_device.json b/tests/fixtures/water_purifier_device.json new file mode 100644 index 0000000..ecfaed2 --- /dev/null +++ b/tests/fixtures/water_purifier_device.json @@ -0,0 +1,234 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ], + "x.com.samsung.da.supportedActions": [ + "Start" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000", + "x.com.samsung.da.description": "TP2X_WATERPURIFIER_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "540", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A240615", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24061111, 24091218, 21072807, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-WPWW-TP2-20-COMMON", + "versions": [ + "20240615" + ], + "visVersion": "240615" + }, + { + "type": "Micom", + "modelId": "03800013234100132441", + "versions": [ + "24061111", + "24091218" + ], + "visVersion": "240912" + }, + { + "type": "Micom", + "modelId": "038000132541FFFFFFFF", + "versions": [ + "21072807", + "FFFFFFFF" + ], + "visVersion": "210728" + } + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "28", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.lastResetDate": "2026-04-14T14:33:15", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.hotwaterLock": "Unlocked", + "x.com.samsung.da.coldwaterLock": "Unlocked", + "x.com.samsung.da.buzzLock": "Unlocked" + } + }, + { + "href": "/setting/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.desiredType": "coldwater", + "x.com.samsung.da.tempDesiredHotWater": "85", + "x.com.samsung.da.tempUnit": "C", + "x.com.samsung.da.desiredCapacity": "100", + "x.com.samsung.da.capacityUnit": "C", + "x.com.samsung.da.capacityResolution": "10", + "x.com.samsung.da.desiredCapacityRange": [ + "50", + "2000" + ], + "x.com.samsung.da.supportedTypes": [ + "ambientwater", + "coldwater", + "hotwater" + ], + "x.com.samsung.da.supportedHotTemperatures": [ + "40", + "75", + "85" + ], + "x.com.samsung.da.triggerTime": "1785063153", + "x.com.samsung.da.pourStatus": "Off" + } + }, + { + "href": "/status/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.filterDoorStatus": "Close", + "x.com.samsung.da.sterilizeRunTime": "0", + "x.com.samsung.da.sterilizeStartTime": "2026-07-27T18:00:00", + "x.com.samsung.da.sterilizePeriod": "3", + "x.com.samsung.da.sterilizePlanTime": "2026-07-27T18:00:00", + "x.com.samsung.da.sterilizeLastTime": "2026-07-24T18:09:25", + "x.com.samsung.da.sterilizeMaxTime": "10", + "x.com.samsung.da.filterCleanRemainTime": "0", + "x.com.samsung.da.filterCleanMaxTime": "15" + } + }, + { + "href": "/favorite/capacity/vs/0", + "rep": { + "x.com.samsung.da.switchCapacity": "On", + "x.com.samsung.da.defaultCapacity": "100", + "x.com.samsung.da.maxCapacity": "6", + "x.com.samsung.da.capacityList": [ + "100", + "150", + "200", + "250", + "550", + "9999" + ] + } + }, + { + "href": "/automation/waterpurifier/vs/0", + "rep": { + "automation.supported.modes": [ + "0" + ], + "automation.supported.options": [ + "capacity" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + } + ] +} diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 0f4fee5..20f4270 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -508,3 +508,19 @@ async def test_options_flow_finish_preserves_existing_options( assert result['type'] == FlowResultType.CREATE_ENTRY assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True + + +def test_is_placeholder_serial_catches_nothing_svc(): + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty, + so it must be caught by name, not by the plain `if not serial` check.""" + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('Nothing(SVC)') is True + assert _is_placeholder_serial('nothing(svc)') is True + assert _is_placeholder_serial(' Nothing(SVC) ') is True + + +def test_is_placeholder_serial_accepts_real_serials(): + from custom_components.localthings.config_flow import _is_placeholder_serial + assert _is_placeholder_serial('0A1B2C3D4E5F') is False + assert _is_placeholder_serial('') is False diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 718baca..cac35f6 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -137,6 +137,29 @@ def test_run_discovery_detects_washer_via_model_fallback( assert coordinator.device_type_name == 'washer' +def test_run_discovery_falls_back_to_host_for_placeholder_serial( + hass: HomeAssistant, mock_entry +) -> None: + """Issue #83: the ARTIK051_DONGLE_REF firmware family reports the + literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is, + two such units get the same device_serial (which feeds both the HA + device-registry identifier and every entity's unique_id), so the + second one's entities silently collide and get dropped. It must be + treated the same as an empty serial and fall back to the host.""" + resources = { + '/information/vs/0': { + 'x.com.samsung.da.modelNum': + 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', + 'x.com.samsung.da.description': 'ARTIK_REF_17K', + 'x.com.samsung.da.serialNum': 'Nothing(SVC)', + }, + '/otninformation/vs/0': {}, + } + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(resources) + assert coordinator.device_serial == mock_entry.data[CONF_HOST] + + def test_run_discovery_detects_cooktop_via_resource_signature( hass: HomeAssistant, mock_entry ) -> None: @@ -146,7 +169,7 @@ def test_run_discovery_detects_cooktop_via_resource_signature( coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(_load_device('cooktop')) - assert coordinator.device_type_name == 'cooktop' + assert coordinator.device_type_name == 'gas_cooktop' assert coordinator._unbound_hrefs == [] diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 4323937..240636f 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -42,9 +42,12 @@ class TestForDevice: assert registry.name == 'refrigerator' def test_for_device_returns_cooktop_registry(self): + """The registry's own .name is 'gas_cooktop' (disambiguated from + induction_cooktop), but the lookup key devices route through stays + 'cooktop' -- oneUiVersion "Cooktop" still resolves here.""" registry = for_device('7.0 Cooktop') assert registry is not None - assert registry.name == 'cooktop' + assert registry.name == 'gas_cooktop' def test_for_device_returns_range_hood_registry(self): registry = for_device('7.0 Range Hood') @@ -130,6 +133,27 @@ class TestModelNumSegments: assert _model_num_segments(None) == [''] +class TestConsumerModelKey: + def test_finds_key_in_last_segment(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C') == 'washer' + + def test_finds_key_before_a_trailing_unrecognized_segment(self): + """Issue #79: 'DVE50A8800_8600' pairs two model numbers -- the real + consumer token is the second-to-last segment, not the last.""" + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key( + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080') == 'dryer' + + def test_ignores_everything_after_first_slash(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C/DW9000_board') == 'washer' + + def test_none_when_no_segment_matches(self): + from custom_components.localthings.registry.by_type import _consumer_model_key + assert _consumer_model_key('ARTIK051_DONGLE_REF') is None + + class TestForDeviceByModel: """Fallback device-type detection for hardware without oneUiVersion.""" @@ -161,6 +185,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'dryer' + def test_dryer_dve50a8600_paired_model_numbers_in_description(self): + """Issue #79: description pairs two model numbers + ('..._DVE50A8800_8600/DC92-...'), so the 'DV' consumer token is one + segment before the literal last segment ('8600', which has no + recognizable prefix on its own). The old last-segment-only check + fell through to 'unknown' here.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000', + 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080', + ) + 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.""" @@ -223,6 +261,45 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_dehumidifier_via_dhm_token(self): + """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board + family with the room-AC models but reports no oneUiVersion and + carries the '_DHM_' (DeHuMidifier) token instead of + '_RAC_'/'_PRAC_'/'_WAC_'; falls back to the '_DHM_' token in + modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000', + 'TP1X_DA_AC_DHM_01001_0000', + ) + assert reg is not None + assert reg.name == 'dehumidifier' + + def test_water_purifier_via_waterpurifier_token(self): + """Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no + oneUiVersion and no consumer-prefix match; falls back to the + 'WATERPURIFIER' token shared by modelNum and description.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000', + 'TP2X_WATERPURIFIER_20K', + ) + assert reg is not None + assert reg.name == 'water_purifier' + + def test_airconditioner_via_wac_token(self): + """Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no + oneUiVersion and a modelNum carrying the '_WAC_' (Window Air + Conditioner) token instead of '_RAC_'/'_PRAC_'; falls back to the + '_WAC_' token in modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000', + 'AW06C7155EWAZ', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_cooktop_via_legacy_model_description(self): """Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP.""" from custom_components.localthings.registry.by_type import for_device_by_model @@ -231,7 +308,7 @@ class TestForDeviceByModel: 'ARTIK051_GLOBAL_COOKTOP', ) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_range_hood_via_ahd_model(self): from custom_components.localthings.registry.by_type import for_device_by_model @@ -292,7 +369,7 @@ class TestForDeviceByResources: reg = for_device_by_resources(_load_device('cooktop')) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_unrelated_mode_options_are_not_cooktop(self): from custom_components.localthings.registry.by_type import for_device_by_resources diff --git a/tests/test_dehumidifier_capabilities.py b/tests/test_dehumidifier_capabilities.py new file mode 100644 index 0000000..3d8e67f --- /dev/null +++ b/tests/test_dehumidifier_capabilities.py @@ -0,0 +1,118 @@ +"""Tests for Samsung dehumidifier support (issue #88, AY18CG7500GED). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the target-humidity/operating-mode write contracts. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import NumberDesc, SelectDesc + +from tests.conftest import _load_device + + +def _dehumidifier(): + resources = _load_device('dehumidifier') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound(): + reg, resources = _dehumidifier() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_dehumidifier_registry(): + reg, _ = _dehumidifier() + assert reg is not None and reg.name == 'dehumidifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #88 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _dehumidifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('humidity', 'target_humidity', 'operating_mode', 'power_switch', + 'auto_clean', 'air_filter_status', 'mute_once'): + assert key in state, key + + +def test_humidity_sensor_reads_current_value(): + state = _state() + assert state['humidity'] == 47 + + +def test_target_humidity_number_reads_desired_value(): + state = _state() + assert state['target_humidity'] == 50 + + +def test_target_humidity_write_contract(): + desc = _desc('target_humidity') + assert isinstance(desc, NumberDesc) + path, body = desc.write_fn('55', {}) + assert path == ['humidity', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredHumidity': '55'} + + +def test_target_humidity_step_reads_live_increment(): + """Step comes from the device's own `increment` field rather than a + hardcoded constant -- see the adding-device-support skill's 'never + hard-code the one dump's values' section.""" + desc = _desc('target_humidity') + assert desc.step_fn({'increment': '5'}) == 5 + assert desc.step_fn({'increment': '10'}) == 10 + # No live field: falls back to a sane default rather than raising. + assert desc.step_fn({}) == 1 + + +def test_target_humidity_has_no_hardcoded_bounds(): + """No range field is present in any dump seen so far -- native_min/max + are deliberately left unset (falls back to HA's own 0-100 default for a + percentage field) rather than a bound guessed from a spec sheet.""" + desc = _desc('target_humidity') + assert desc.native_min is None + assert desc.native_max is None + assert desc.native_min_fn is None + assert desc.native_max_fn is None + + +def test_operating_mode_select_options_come_from_live_supported_modes(): + """Options are read live from x.com.samsung.da.supportedModes, not a + hardcoded tuple -- so a future device with a different mode set is + handled automatically.""" + desc = _desc('operating_mode') + assert isinstance(desc, SelectDesc) + assert desc.options_field == 'x.com.samsung.da.supportedModes' + assert desc.options == () + + +def test_operating_mode_write_contract(): + desc = _desc('operating_mode') + path, body = desc.write_fn('Quiet', {}) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.modes': ['Quiet']} + + +def test_operating_mode_reads_first_mode(): + state = _state() + assert state['operating_mode'] == 'Smart' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index d83351f..26c45f2 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -73,6 +73,23 @@ def test_registry_reproduces_golden_state_keys_for_dryer(): ) +def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): + """DVE50A8600V/A3 (issue #79) -- description pairs two model numbers + ('..._DVE50A8800_8600/...'), so the true 'DV' consumer-model token sits + one segment before the actual last segment ('8600'). The old + last-segment-only check missed it and fell back to 'unknown'; resolved + via _consumer_model_key scanning segments from the end.""" + from tests.conftest import _load_device + resources = _load_device('dryer_dve50a8600') + golden = json.loads((GOLDEN / 'dryer_dve50a8600.json').read_text()) + state_keys = _new_state_keys('dryer_dve50a8600', 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_registry_reproduces_golden_state_keys_for_airconditioner(): from tests.conftest import _load_device resources = _load_device('airconditioner') @@ -85,6 +102,39 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_dehumidifier(): + """TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_ + board family with the room-AC models but carries the '_DHM_' token; + resolved via the '_DHM_' modelNum fallback in for_device_by_model into a + dedicated dehumidifier registry (target humidity, operating mode, reused + AC filter/auto-clean/mute-once capabilities).""" + from tests.conftest import _load_device + resources = _load_device('dehumidifier') + golden = json.loads((GOLDEN / 'dehumidifier.json').read_text()) + state_keys = _new_state_keys('dehumidifier', 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_registry_reproduces_golden_state_keys_for_water_purifier(): + """TP2X_WATERPURIFIER_20K (issue #90) reports no oneUiVersion; resolved + via the 'WATERPURIFIER' modelNum/description fallback into a dedicated + water_purifier registry (dispense settings, sterilize/filter status, + favorite capacity, and the three lock switches).""" + from tests.conftest import _load_device + resources = _load_device('water_purifier') + golden = json.loads((GOLDEN / 'water_purifier.json').read_text()) + state_keys = _new_state_keys('water_purifier', 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_registry_reproduces_golden_state_keys_for_cooktop(): from tests.conftest import _load_device resources = _load_device('cooktop') @@ -270,6 +320,23 @@ def test_registry_reproduces_golden_state_keys_for_caww_tp2(): ) +def test_registry_reproduces_golden_state_keys_for_window_ac(): + """TP1X_DA_AC_WAC_01001_0000 (issue #87, Bespoke Window AC AW06C7155EWAZ) + reports no oneUiVersion and carries the '_WAC_' (Window Air Conditioner) + token instead of '_RAC_'/'_PRAC_'; resolved via the '_WAC_' modelNum + fallback in for_device_by_model. Otherwise binds cleanly against the + existing airconditioner registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_window_ac') + golden = json.loads((GOLDEN / 'airconditioner_window_ac.json').read_text()) + state_keys = _new_state_keys('airconditioner_window_ac', 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_registry_reproduces_golden_state_keys_for_tp1x_rac(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display light, self-check, mute-once, and a current-limit setting.""" diff --git a/tests/test_translations.py b/tests/test_translations.py index c360ffd..0d3c7c3 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,3 +168,4 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() + diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py new file mode 100644 index 0000000..c0eaa98 --- /dev/null +++ b/tests/test_water_purifier_capabilities.py @@ -0,0 +1,140 @@ +"""Tests for Samsung water-purifier support (issue #90, TP2X_WATERPURIFIER_20K). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the write contracts. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import NumberDesc + +from tests.conftest import _load_device + + +def _water_purifier(): + resources = _load_device('water_purifier') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound(): + reg, resources = _water_purifier() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_water_purifier_registry(): + reg, _ = _water_purifier() + assert reg is not None and reg.name == 'water_purifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #90 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _water_purifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity', + 'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status', + 'hotwater_lock', 'coldwater_lock', 'buzz_lock'): + assert key in state, key + + +def test_dispense_type_options_come_from_live_supported_types(): + desc = _desc('dispense_type') + assert desc.options_field == 'x.com.samsung.da.supportedTypes' + + +def test_dispense_type_write_contract(): + desc = _desc('dispense_type') + path, body = desc.write_fn('hotwater', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredType': 'hotwater'} + + +def test_hot_water_temperature_is_a_select_not_a_number(): + """Only a handful of discrete temperatures are selectable (not a + continuous range) -- confirmed by supportedHotTemperatures being a short + enumerated list, not a [min, max] range field.""" + desc = _desc('hot_water_temperature') + assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures' + + +def test_dispense_capacity_bounds_come_live_not_hardcoded(): + """Bounds and step come from the device's own desiredCapacityRange/ + capacityResolution fields, not a hardcoded constant -- see the + adding-device-support skill's 'never hard-code the one dump's values' + section.""" + desc = _desc('dispense_capacity') + assert isinstance(desc, NumberDesc) + assert desc.native_min is None + assert desc.native_max is None + assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange' + rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'], + 'x.com.samsung.da.capacityResolution': '10'} + assert desc.step_fn(rep) == 10 + + +def test_dispense_capacity_write_contract(): + desc = _desc('dispense_capacity') + path, body = desc.write_fn('550', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredCapacity': '550'} + + +def test_lock_switches_read_unlocked_as_off(): + state = _state() + assert state['hotwater_lock'] is False + assert state['coldwater_lock'] is False + assert state['buzz_lock'] is False + + +def test_lock_switch_write_contracts(): + hot = _desc('hotwater_lock') + cold = _desc('coldwater_lock') + buzz = _desc('buzz_lock') + assert hot.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'}) + assert cold.write_fn('Off', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'}) + assert buzz.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'}) + + +def test_favorite_capacity_options_come_from_live_capacity_list(): + desc = _desc('favorite_capacity') + assert desc.options_field == 'x.com.samsung.da.capacityList' + + +def test_sterilize_timestamps_parsed_as_utc(): + state = _state() + assert state['sterilize_last_time'].tzinfo is not None + assert state['sterilize_plan_time'].tzinfo is not None + + +def test_mode_hrefs_are_ignored_not_guessed(): + """/mode/vs/0's supportedModes carries a single opaque wizard token and + modes reports an unrelated value not even in supportedModes -- internal + plumbing, left unmodeled per the 'don't guess' rule rather than exposed + as a nonsensical select.""" + from custom_components.localthings.registry.capabilities import water_purifier + ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} + assert '/mode/vs/0' in ignored_hrefs + assert '/automation/waterpurifier/vs/0' in ignored_hrefs