diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 79d38af..e144da7 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike. """ from __future__ import annotations +import logging + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -53,6 +55,8 @@ from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included +_LOGGER = logging.getLogger(__name__) + _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' @@ -68,6 +72,19 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = { } _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} +# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat +# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation +# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto' +# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as +# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled +# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto' +# would if it were ever mapped there) and a dedicated 'ai_comfort' preset +# carries the distinction a bare hvac_mode can't. Not reachable via +# async_set_hvac_mode -- entered/left only through the preset, since there's +# no dedicated HVACMode value for it to write back to. +_AI_COMFORT_MODE = 'AIComfort' +PRESET_AI_COMFORT = 'ai_comfort' + # Fan (wind strength): device codes "0".."4" -> HA standard fan constants where # a clean match exists so they auto-localize; "turbo" is custom (translated). _DEVICE_TO_FAN: dict[str, str] = { @@ -84,6 +101,7 @@ _DEVICE_TO_SWING: dict[str, str] = { 'Fix': 'off', 'All': 'both', 'Up_And_Low': 'vertical', + 'Left_And_Right': 'horizontal', # issue #75 } _SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} @@ -164,6 +182,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF ) + # (href, raw device code) pairs already logged by _warn_unmapped -- + # these properties are read on every coordinator refresh, so an + # un-deduped warning would spam the log for any device with a + # genuinely unrecognized code. + self._warned_unmapped: set[tuple[str, str]] = set() # -- resource helpers --------------------------------------------------- @@ -180,13 +203,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + def _warn_unmapped(self, href: str, code: str) -> None: + """Log once per (href, code) when a device-reported mode has no + entry in the relevant device<->HA map, so a real device gap surfaces + in the log instead of silently vanishing (issue #93).""" + key = (href, code) + if key in self._warned_unmapped: + return + self._warned_unmapped.add(key) + _LOGGER.warning( + "%s: device mode %r on %s has no HA mapping and was dropped; " + "please file an issue with your diagnostics dump", + self.entity_id, code, href, + ) + def _read_mode(self, href: str, mapping: dict): """Current mode of a wind/convenient resource, mapped to its HA value.""" - return mapping.get(_first(self._rep(href).get(_MODES_FIELD))) + raw = _first(self._rep(href).get(_MODES_FIELD)) + if raw is not None and raw not in mapping: + self._warn_unmapped(href, raw) + return mapping.get(raw) def _read_modes(self, href: str, mapping: dict) -> list[str]: """Supported modes of a resource, mapped to HA values (unknowns dropped).""" - return [mapping[c] for c in self._supported(href) if c in mapping] + supported = self._supported(href) + for c in supported: + if c not in mapping: + self._warn_unmapped(href, c) + return [mapping[c] for c in supported if c in mapping] # -- temperature -------------------------------------------------------- @@ -250,14 +294,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): if not self._is_on(): return HVACMode.OFF device = _first(self._rep(MODE_HREF).get(_MODES_FIELD)) + if device == _AI_COMFORT_MODE: + return HVACMode.AUTO + if device is not None and device not in _DEVICE_TO_HVAC: + self._warn_unmapped(MODE_HREF, device) return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO) @property def hvac_modes(self) -> list[HVACMode]: modes = [HVACMode.OFF] for m in self._supported(MODE_HREF): + if m == _AI_COMFORT_MODE: + continue mapped = _DEVICE_TO_HVAC.get(m) - if mapped is not None and mapped not in modes: + if mapped is None: + self._warn_unmapped(MODE_HREF, m) + continue + if mapped not in modes: modes.append(mapped) return modes @@ -281,11 +334,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def preset_mode(self): + if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE: + return PRESET_AI_COMFORT return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) @property def preset_modes(self) -> list[str]: - return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) + modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) + if _AI_COMFORT_MODE in self._supported(MODE_HREF): + modes.append(PRESET_AI_COMFORT) + return modes # -- writes ------------------------------------------------------------- @@ -324,4 +382,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: + if preset_mode == PRESET_AI_COMFORT: + # Writes the primary mode resource, not the convenient one -- + # 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in + # /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep. + await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE)) + return await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) 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 171630f..333df3a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -22,7 +22,11 @@ from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model, for_device_by_resources -from .registry.capabilities.common import merge_options_field, remote_control_enabled +from .registry.capabilities.common import ( + merge_options_field, + remote_control_enabled, + remote_control_required_for_write, +) from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -49,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.""" @@ -312,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 @@ -531,7 +551,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue #54: some devices accept certain writes, e.g. a washer's default dosing levels, even while reporting remote control off, so the - block's assumption doesn't hold for every model).""" + block's assumption doesn't hold for every model), or the laundry + firmware flag isModelSettingWithoutSC declares settings writable + without Smart Control (cycle start/pause/stop on /operational/state + still require it).""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: @@ -540,7 +563,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): rep = self._cache.get(href or '') or {} resources = self._cache.snapshot() bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False) - if not bypass_remote_control and not remote_control_enabled(resources): + if ( + not bypass_remote_control + and remote_control_required_for_write(resources, href or '') + and not remote_control_enabled(resources) + ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="remote_control_disabled", @@ -553,7 +580,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): translation_domain=DOMAIN, translation_key=error, ) - result = write_fn(payload, rep, href) + try: + result = write_fn(payload, rep, href, resources) + except TypeError: + result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) return diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 935d47f..c9f8677 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -88,6 +88,26 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { } +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 @@ -101,8 +121,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 or ''): key = 'refrigerator' # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and @@ -123,6 +142,15 @@ 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' + # 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 ''): @@ -175,4 +203,18 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist and '/hood/lamp/vs/0' in resources ): return _REGISTRY_BY_KEY['range_hood'] + # Oven/range-combo boards that report no /information/vs/0 at all + # (issue #74's NE63B8411SS -- the resource is simply absent from the + # dump, not just empty) can't be matched via for_device_by_model's + # modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that + # no other family's /mode/vs/0 uses (confirmed against the microwave, + # cooktop, and every laundry fixture), so its presence alongside the + # oven cavity resource is a safe signature. Distinguish range (has a + # cooktop half) from a plain wall oven by which cooktop-status resource, + # if any, is also present. + supported_modes = mode.get('x.com.samsung.da.supportedModes') or () + if '/oven/vs/0' in resources and 'Bake' in supported_modes: + if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources: + return _REGISTRY_BY_KEY['range'] + return _REGISTRY_BY_KEY['oven'] return None diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index e218de5..7a7c38c 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -25,6 +25,9 @@ REGISTRY = DeviceRegistry( airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, + airconditioner.CURRENT_TEMPERATURE, + airconditioner.CURRENT_TEMPERATURE_VS, + airconditioner.HUMIDITY, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/by_type/range.py b/custom_components/localthings/registry/by_type/range.py index 7cc891f..c53bdca 100644 --- a/custom_components/localthings/registry/by_type/range.py +++ b/custom_components/localthings/registry/by_type/range.py @@ -25,5 +25,6 @@ REGISTRY = DeviceRegistry( range_caps.COOKTOP_STATUS, range_caps.COOKTOP_SPEC, range_caps.COOKTOP_SAFETY, + range_caps.COOKTOP_MONITORING, ]), ) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index b893475..43e776b 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -33,7 +33,7 @@ REGISTRY = DeviceRegistry( ]), pattern_capabilities=[ fridge.TEMP_CURRENT_GENERIC, - fridge.TEMP_SETPOINT_GENERIC, + fridge.TEMP_SETPOINT, fridge.ICEMAKER_GENERIC, fridge.DOOR_GENERIC, ], diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 70f5e1b..599affd 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -16,6 +16,7 @@ by_type registry. """ from ..capability import Capability from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from .common import normalize_temp_unit # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the @@ -50,6 +51,25 @@ def _num(v): return None +def _temps_vs_item(rep): + """First item of the vendor `/temperatures/vs/0` items[] array -- the + Tizen Lite board's only current-temperature source (see climate.py's + identical helper; duplicated rather than imported to avoid a + capabilities<->platform import cycle).""" + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + +def _temps_vs_current(rep): + return _num(_temps_vs_item(rep).get('x.com.samsung.da.current')) + + +def _temps_vs_unit(rep): + return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C') + + def _filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. The device reports `filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g. @@ -199,6 +219,48 @@ CURRENT_LIMIT = Capability( ), ) +# The climate entity already surfaces current_temperature as a card +# attribute, but that's not enough for history graphs/automations/ +# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- +# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK +# pair: both share key='current_temperature_c' so only one ever binds +# (match_fn gates the vendor one off when the OCF resource is present). +CURRENT_TEMPERATURE = Capability( + href=HREF_TEMP_CURRENT, + poll_tier='warm', + entities=( + SensorDesc(key='current_temperature_c', field='temperature', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')), + ), +) + +CURRENT_TEMPERATURE_VS = Capability( + href=HREF_TEMPS_VS, + poll_tier='warm', + match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources, + entities=( + SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current, + device_class='temperature', state_class='measurement', + unit_fn=_temps_vs_unit), + ), +) + +# Only the vendor resource's `fivepercentHumidity` (current reading, rounded +# to the nearest 5%) has live data on the issue #75 dump -- its `humidity` +# field, and the OCF-standard /humidity/0 resource entirely, both read a +# stuck "0" there and stay ignored per the 'don't guess' rule (see +# _AC_IGNORED below). +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity', + device_class='humidity', state_class='measurement', unit='%', + value_fn=_num), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -221,8 +283,10 @@ _AC_IGNORED = [ # All-zero and ambiguously encoded on this model (2-value arrays); the # 'don't guess' rule -- leave unmodeled rather than invent entities. '/sensors/vs/0', + # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor + # resource's usable fivepercentHumidity field instead; this OCF-standard + # one has no corresponding live value confirmed yet. '/humidity/0', - '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no @@ -251,8 +315,11 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). +# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE +# / CURRENT_TEMPERATURE_VS above already cover those two with real entities. COVERAGE = [ Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS + if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS) ] + [ Capability(href=h) for h in _AC_IGNORED ] diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index eb14c04..eb33d37 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -59,9 +59,21 @@ def _ml_to_l(v): def _active_alarm_codes(items): + """Join active alarm codes; skip retained rows Samsung leaves as Deleted. + + Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the + condition clears (see WD7000B diagnostics). Surface only live alarms so + HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter + helper that also drops ErrorCode_OFF. + """ if not items or not isinstance(items, list): return 'none' - codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')] + codes = [ + i.get('x.com.samsung.da.code') + for i in items + if i.get('x.com.samsung.da.code') + and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted' + ] return ', '.join(codes) if codes else 'none' @@ -92,6 +104,52 @@ def merge_options_field(cached, new_tokens): return merged +# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on +# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the +# life of a given board, so reading them from the /device/0 seed (no dedicated +# poll_tier) is enough. +_SETINFO_HREF = '/wm/setinfo/vs/0' +_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff' +_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC' + + +def model_allows_power_on_off(resources: dict) -> bool: + """True unless firmware explicitly declares remote power on/off unsupported. + + `/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry + boards (washers/dryers): `/power/0` and `/power/vs/0` still report state, + but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps + the writable switch -- current behavior. + """ + setinfo = resources.get(_SETINFO_HREF) + if setinfo is None: + return True + flag = setinfo.get(_POWER_ON_OFF_FIELD) + if flag is None: + return True + return str(flag).lower() != 'false' + + +def model_setting_without_sc(resources: dict) -> bool: + """True when firmware declares settings writable without Smart Control. + + `/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers + that accept temperature/spin/cycle-option writes while remote control is + off. Cycle start/pause/stop still need Smart Control on those boards -- + the flag name is settings-specific, not a blanket remote-control bypass. + """ + setinfo = resources.get(_SETINFO_HREF) or {} + return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true' + + +def _power_switch_exists(rep, resources): + return model_allows_power_on_off(resources) + + +def _power_sensor_exists(rep, resources): + return not model_allows_power_on_off(resources) + + def sensor_item_value(items, sensor_type, index=0): """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each item is `{type, value: [...]}`; `index` picks which slot of a possibly @@ -128,10 +186,17 @@ def sensor_item_value(items, sensor_type, index=0): POWER_GENERIC = Capability( href='/power/0', entities=( + # Writable when firmware allows remote power; otherwise a read-only + # binary_sensor with the same key keeps HA state without a dead switch. SwitchDesc(key='power_switch', field='value', value_fn=lambda v: bool(v), + exists_fn=_power_switch_exists, write_fn=lambda p, rep, href=None: ( ['power', '0'], {'value': p == 'On'})), + BinarySensorDesc(key='power_switch', field='value', + device_class='power', + value_fn=lambda v: bool(v), + exists_fn=_power_sensor_exists), ), ) @@ -141,9 +206,14 @@ POWER_VS_FALLBACK = Capability( entities=( SwitchDesc(key='power_switch', field='x.com.samsung.da.power', value_fn=lambda v: v == 'On', + exists_fn=_power_switch_exists, write_fn=lambda p, rep, href=None: ( ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), + BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power', + device_class='power', + value_fn=lambda v: v == 'On', + exists_fn=_power_sensor_exists), ), ) @@ -192,6 +262,20 @@ def remote_control_enabled(resources: dict) -> bool: return True +def remote_control_required_for_write(resources: dict, bound_href: str) -> bool: + """Whether a write to bound_href should be gated on Smart Control. + + When isModelSettingWithoutSC is true, laundry firmware accepts settings + writes (wash temp, spin, course options, buzzer, ...) with remote + control off, but cycle start/pause/stop on /operational/state still + need Smart Control. Absent that flag, keep the historical blanket gate. + """ + if not model_setting_without_sc(resources): + return True + href = bound_href or '' + return href.startswith('/operational/state') + + REMOTE_CONTROL_GENERIC = Capability( href='/remotectrl/0', poll_tier='warm', 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/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 72bf573..855f3b5 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -62,7 +62,38 @@ TEMP_CURRENT_GENERIC = Capability( ), ) -TEMP_SETPOINT_GENERIC = Capability( +def _temp_setpoint_write(p, rep, href=None, resources=None): + """Write temperature — prefer vendor /temperatures/vs/0 when available, + fall back to direct OCF /temperature/desired/ write otherwise. + + Samsung fridges expose both OCF-standard /temperature/desired/* and vendor + /temperatures/vs/0. On some models only the vendor path commits the change; + on others both work. Using the vendor path when present is always correct. + Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler. + """ + if not href: + return None + if resources and '/temperatures/vs/0' in resources: + if '/cooler/' in href: + item_id = '1' + elif '/freezer/' in href: + item_id = '0' + else: + return None + return ( + ['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': [ + {'x.com.samsung.da.id': item_id, + 'x.com.samsung.da.desired': str(int(round(float(p))))} + ]} + ) + return ( + [s for s in href.strip('/').split('/') if s], + {'temperature': int(round(float(p)))} + ) + + +TEMP_SETPOINT = Capability( href=None, href_prefix='/temperature/desired/', strip_prefix_in_key=True, @@ -73,10 +104,7 @@ TEMP_SETPOINT_GENERIC = Capability( use_instance_name=True, device_class='temperature', unit_fn=_temp_unit, native_min=-20.0, native_max=50.0, range_field='range', entity_category='config', - write_fn=lambda p, rep, href=None: ( - [s for s in href.strip('/').split('/') if s], - {'temperature': int(round(float(p)))} - ) if href else None), + write_fn=_temp_setpoint_write), ), ) diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 2ebb8af..88c88f9 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -133,3 +133,22 @@ COOKTOP_SAFETY = Capability( value_fn=lambda v: (v or {}).get('state') == 'on'), ), ) + +# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 +# burner array at all -- their local API only exposes this coarse +# monitoring resource for the cooktop half, with no per-burner detail. +# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and +# `warmingCenterState`'s full value set aren't confirmed, so both are +# exposed as plain sensors rather than guessed at as a switch/select -- +# `supportedHoodLampStateList` has no corresponding live-state field on +# this resource, so nothing to bind it to yet. +COOKTOP_MONITORING = Capability( + href='/cooktopmonitoring/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState', + icon='mdi:pot-steam-outline'), + SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState', + icon='mdi:heat-wave', entity_category='diagnostic'), + ), +) 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/translations/en.json b/custom_components/localthings/translations/en.json index 54863b6..322eb38 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -61,6 +61,9 @@ "power_state": { "name": "Power state" }, + "power_switch": { + "name": "Power" + }, "rapid_freezing": { "name": "Rapid freezing" }, @@ -101,6 +104,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI Comfort", "quiet": "Quiet", "smart": "Smart", "speed": "Speed" @@ -213,6 +217,8 @@ "dryer_cycle_table_03": { "name": "Cycle", "state": { + "01": "Normal", + "06": "Time dry", "16": "Cotton", "18": "Synthetics", "19": "Delicates", @@ -397,30 +403,37 @@ "27": "Rinse+Spin", "28": "Drain/Spin", "29": "Drum Clean+", + "2a": "Jeans", + "2b": "AI Wash", "2d": "Silent Wash", "2e": "Baby Care", "2f": "Activewear", "30": "Cloudy Day", "32": "Shirts", "33": "Bedding", + "34": "Mixed", "36": "Wash+Dry", "37": "Air Wash", "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", "7d": "Bedding/Waterproof", "7e": "Self-Clean", + "7f": "Wool/Delicate", "86": "Deep Wash", + "87": "Download", "8f": "Intense Cold", - "96": "Less Microfiber", - "2b": "AI Wash", - "2a": "Jeans" + "96": "Less Microfiber" } }, "washer_dry_level": { @@ -466,6 +479,9 @@ "poll": "Polling" } }, + "cooktop_running_state": { + "name": "Cooktop running state" + }, "cooktop_state": { "name": "Cooktop state" }, @@ -475,6 +491,9 @@ "current_temp_c": { "name": "Temperature" }, + "current_temperature_c": { + "name": "Temperature" + }, "diagnosis": { "name": "Diagnosis" }, @@ -547,6 +566,9 @@ "hood_filter_usage": { "name": "Filter usage" }, + "humidity": { + "name": "Humidity" + }, "instance_temperature": { "name": "{instance_name} temperature" }, @@ -625,6 +647,9 @@ "super_fine_dust": { "name": "Super fine dust" }, + "warming_center_state": { + "name": "Warming center state" + }, "water_liters": { "name": "Water consumption" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 44d1533..9cd49b6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -61,6 +61,9 @@ "power_state": { "name": "Voedingsstatus" }, + "power_switch": { + "name": "Voeding" + }, "rapid_freezing": { "name": "Snelvriezen" }, @@ -101,6 +104,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI-comfort", "quiet": "Stil", "smart": "Slim", "speed": "Snel" @@ -213,6 +217,8 @@ "dryer_cycle_table_03": { "name": "Programma", "state": { + "01": "Normaal", + "06": "Tijdprogramma", "16": "Katoen", "18": "Synthetisch", "19": "Fijne was", @@ -397,30 +403,37 @@ "27": "Spoelen+centrifugeren", "28": "Afpompen/centrifugeren", "29": "Drum Clean+", + "2a": "Spijkerbroek", + "2b": "AI Wash", "2d": "Stille was", "2e": "Babyverzorging", "2f": "Sportkleding", "30": "Bewolkte dag", "32": "Overhemden", "33": "Beddengoed", + "34": "Gemengd", "36": "Wassen+drogen", "37": "Air Wash", "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", "7d": "Beddengoed/Waterdicht", "7e": "Zelfreinigend", + "7f": "Wol/Fijn", "86": "Diep wassen", + "87": "Downloaden", "8f": "Intensief koud", - "96": "Minder microvezels", - "2b": "AI Wash", - "2a": "Spijkerbroek" + "96": "Minder microvezels" } }, "washer_dry_level": { @@ -466,6 +479,9 @@ "poll": "Polling" } }, + "cooktop_running_state": { + "name": "Loopstatus kookplaat" + }, "cooktop_state": { "name": "Status kookplaat" }, @@ -475,6 +491,9 @@ "current_temp_c": { "name": "Temperatuur" }, + "current_temperature_c": { + "name": "Temperatuur" + }, "diagnosis": { "name": "Diagnose" }, @@ -547,6 +566,9 @@ "hood_filter_usage": { "name": "Filterverbruik" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "instance_temperature": { "name": "Temperatuur {instance_name}" }, @@ -625,6 +647,9 @@ "super_fine_dust": { "name": "Ultrafijnstof" }, + "warming_center_state": { + "name": "Status warmhoudzone" + }, "water_liters": { "name": "Waterverbruik" }, diff --git a/tests/fixtures/airconditioner_windfree_device.json b/tests/fixtures/airconditioner_windfree_device.json new file mode 100644 index 0000000..9fca817 --- /dev/null +++ b/tests/fixtures/airconditioner_windfree_device.json @@ -0,0 +1,380 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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-25T08:19:03" + }, + { + "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-25T08:19:03" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_2.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.cumulativePower": "320466", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "41", + "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": "42" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010519001411010200001000000000", + "x.com.samsung.da.description": "ARTIK051_PRAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "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": "02181A230313", + "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": "20082000,FFFFFFFF", + "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": "20091600,10000400" + } + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "MotionDirect", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Dry" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "MotionState_Normal", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_87", + "CoolCapa_25", + "WarmCapa_32", + "Light_Off", + "Volume_100", + "MotionMode_Default", + "OptionCode_56376", + "ExtendOptionCode_781", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "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": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 26.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": "26.0", + "x.com.samsung.da.current": "27.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": {} + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} \ No newline at end of file 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/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.json b/tests/fixtures/golden/airconditioner.json index 68abd0f..6a6596c 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -6,9 +6,11 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", + "humidity", "power_watts" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 290a269..c3cdd7c 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -5,10 +5,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json index b9643bf..af71051 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -6,10 +6,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "selfcheck_error", "selfcheck_result", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index b7ce7c3..b192cdf 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -8,6 +8,7 @@ "climate", "current_limit_enabled", "current_limit_level", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index 4b50703..a2c11e4 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -5,8 +5,10 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "energy_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json new file mode 100644 index 0000000..70ba9c5 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -0,0 +1,15 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "humidity", + "power_watts" + ] +} diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json new file mode 100644 index 0000000..ea27504 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} 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/range_no_info.json b/tests/fixtures/golden/range_no_info.json new file mode 100644 index 0000000..a7ef5a5 --- /dev/null +++ b/tests/fixtures/golden/range_no_info.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "current_temp_c", + "cycle_active", + "door_open", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound", + "warming_center_state" + ] +} diff --git a/tests/fixtures/range_no_info_device.json b/tests/fixtures/range_no_info_device.json new file mode 100644 index 0000000..dcf88bf --- /dev/null +++ b/tests/fixtures/range_no_info_device.json @@ -0,0 +1,201 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2024-06-03T04:35:25" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.cooktopRunningState": "Ready", + "supportedHoodLampStateList": [ + "Run", + "Ready" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE8411B-/AC0", + "SettingPossible_0", + "meatprobe_disconnected", + "UpperLamp_Off", + "Sound_On", + "AdjustingTemp_11", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlert_On" + ], + "x.com.samsung.da.defaultMode": "ConvectionBake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Broil\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61441\",\"tempMaxC\":\"61442\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61441\",\"tempMaxF\":\"61442\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"KeepWarm\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"BreadProof\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Dehydrate\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"105\",\"tempDefaultC\":\"65\",\"tempListLengthC\":\"0\",\"tempMinF\":\"100\",\"tempMaxF\":\"225\",\"tempDefaultF\":\"150\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SelfClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SteamClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"}]", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "0", + "x.com.samsung.da.current": "80", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} \ No newline at end of file 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 0a1b420..a6abccd 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -16,6 +16,7 @@ from custom_components.localthings.const import ( from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.common import ( remote_control_enabled, + remote_control_required_for_write, ) from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S @@ -136,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: @@ -880,6 +904,38 @@ class TestRemoteControlEnabled: assert remote_control_enabled({}) is True +class TestRemoteControlRequiredForWrite: + """isModelSettingWithoutSC auto-exempts settings writes, not cycle control.""" + + def test_without_flag_always_requires(self): + resources = {} + assert remote_control_required_for_write(resources, '/washer/vs/0') is True + assert remote_control_required_for_write( + resources, '/operational/state/vs/0') is True + + def test_without_sc_exempts_settings_keeps_operational(self): + resources = { + '/wm/setinfo/vs/0': { + 'x.com.samsung.da.isModelSettingWithoutSC': 'true', + }, + } + assert remote_control_required_for_write(resources, '/washer/vs/0') is False + assert remote_control_required_for_write(resources, '/course/vs/0') is False + assert remote_control_required_for_write(resources, '/buzzersound/vs/0') is False + assert remote_control_required_for_write( + resources, '/operational/state/vs/0') is True + assert remote_control_required_for_write( + resources, '/operational/state/0') is True + + def test_without_sc_false_still_requires(self): + resources = { + '/wm/setinfo/vs/0': { + 'x.com.samsung.da.isModelSettingWithoutSC': 'false', + }, + } + assert remote_control_required_for_write(resources, '/washer/vs/0') is True + + async def test_send_command_blocked_when_remote_control_disabled( hass: HomeAssistant, mock_entry, mock_coordinator_observe_session ) -> None: @@ -1024,3 +1080,95 @@ async def test_send_command_bypasses_remote_control_when_option_enabled( await coordinator.async_send_command(bound, 5) assert coordinator._cache.get('/some/path') == {'value': 5} + + +async def test_send_command_settings_allowed_when_without_sc( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """Laundry boards with isModelSettingWithoutSC=true accept settings + writes while remote control is off -- no options-flow bypass needed.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import SelectDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + coordinator._cache.apply_rep( + '/wm/setinfo/vs/0', + {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, + source='test', + ) + + def _write_fn(payload, rep, href=None): + return (['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': payload}) + + desc = SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', + write_fn=_write_fn) + bound = BoundEntity( + href='/washer/vs/0', capability=coordinator.bound[0].capability, desc=desc, + ) + + with patch.object(fake, 'subscribe'): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound, '1000') + + assert coordinator._cache.get('/washer/vs/0') == { + 'x.com.samsung.da.spinLevel': '1000', + } + + +async def test_send_command_operational_still_blocked_when_without_sc( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """Start/Pause/Stop stay gated on Smart Control even when settings + are allowed without it -- isModelSettingWithoutSC is settings-only.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import ButtonDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + coordinator._cache.apply_rep( + '/wm/setinfo/vs/0', + {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, + source='test', + ) + + def _write_fn(payload, rep, href=None): + return (['operational', 'state', 'vs', '0'], + {'x.com.samsung.da.state': payload}) + + desc = ButtonDesc(key='start', payload='Run', write_fn=_write_fn) + bound = BoundEntity( + href='/operational/state/vs/0', + capability=coordinator.bound[0].capability, + desc=desc, + ) + + posted = False + + def _post(*a, **k): + nonlocal posted + posted = True + return (0x44, b'') + + with patch.object(fake, 'subscribe'): + fake.post = _post + with pytest.raises(ServiceValidationError) as exc_info: + await coordinator.async_send_command(bound, 'Run') + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == 'remote_control_disabled' + assert posted is False diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 981dc5d..a7ea9d6 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -114,11 +114,13 @@ def test_climate_write_targets(): def test_climate_consumed_hrefs_declared_as_coverage(): """The climate-consumed and ambiguous hrefs are declared in the AC registry (as no-entity coverage caps) so they don't leak as gaps -- but produce no - standalone entities.""" + standalone entities. /temperature/current/0 and /temperatures/vs/0 are + NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give + those two real sensor entities (issue #75).""" reg, _ = _ac() for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', '/wind/strength/vs/0', '/mode/convenient/vs/0', - '/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'): + '/sensors/vs/0', '/humidity/0'): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -257,3 +259,50 @@ def test_current_limit_is_read_only(): than a guessed writable control.""" for desc in airconditioner.CURRENT_LIMIT.entities: assert getattr(desc, 'write_fn', None) is None + + +# --------------------------------------------------------------------------- +# WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the +# original issue #17 fixture, but its /mode/convenient/vs/0 additionally +# supports Nano/NanoSleep/MotionDirect/MotionIndirect, /wind/direction/vs/0 +# additionally supports Left_And_Right, and /humidity/vs/0's +# fivepercentHumidity is populated (unlike the all-zero original dump). +# --------------------------------------------------------------------------- + +def _ac_windfree(): + resources = _load_device('airconditioner_windfree') + 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 test_windfree_no_unbound_hrefs(): + reg, resources = _ac_windfree() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_windfree_humidity_and_temperature_sensors_present(): + reg, resources = _ac_windfree() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['humidity'] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field + assert state['current_temperature_c'] == 27.0 + + +def test_current_temperature_vs_only_binds_when_ocf_href_absent(): + """CURRENT_TEMPERATURE_VS's match_fn must not double-bind alongside + CURRENT_TEMPERATURE when a device (like this one) reports both + /temperature/current/0 and /temperatures/vs/0.""" + match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn + assert match({}, {'/temperature/current/0': {}}) is False + assert match({}, {}) is True + + +def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): + desc = airconditioner.HUMIDITY.entities[0] + rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} + assert desc.value_fn(rep.get(desc.field)) == 42.0 diff --git a/tests/test_by_type.py b/tests/test_by_type.py index b2bf2d1..b8216ca 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -111,6 +111,27 @@ class TestWasherRegistry: assert href in registry.capabilities, f"{href} missing from washer registry" +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.""" @@ -142,6 +163,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.""" @@ -201,6 +236,19 @@ class TestForDeviceByModel: ) 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.""" @@ -296,3 +344,46 @@ class TestForDeviceByResources: reg = for_device_by_resources(resources) assert reg is not None assert reg.name == 'range_hood' + + def test_ne63b8411ss_without_information_or_burner_status_is_range(self): + """Issue #74: no oneUiVersion, no /information/vs/0 at all, and no + /cooktop/status/vs/0 burner array -- only /cooktopmonitoring/vs/0. + 'Bake' in supportedModes plus that monitoring resource must still + route this to the range registry, not plain oven or unknown.""" + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil', 'SelfClean'], + 'x.com.samsung.da.options': ['DeviceType_NE8411B-/AC0'], + }, + '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + '/cooktopmonitoring/vs/0': {'x.com.samsung.da.cooktopRunningState': 'Ready'}, + } + reg = for_device_by_resources(resources) + assert reg is not None + assert reg.name == 'range' + + def test_bake_without_cooktop_resource_is_plain_oven(self): + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], + 'x.com.samsung.da.options': ['DeviceType_SOME_OVEN'], + }, + '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + } + reg = for_device_by_resources(resources) + assert reg is not None + assert reg.name == 'oven' + + def test_bake_without_oven_cavity_resource_is_not_matched(self): + """'Bake' alone isn't enough -- the oven cavity resource must also + be present, or this falls through to None like any other unknown + shape.""" + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], + }, + } + assert for_device_by_resources(resources) is None diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py new file mode 100644 index 0000000..41c5bfe --- /dev/null +++ b/tests/test_climate_ac_modes.py @@ -0,0 +1,40 @@ +"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93). + +Module-level dicts/constants with no coordinator/entity dependency, so -- +like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're +testable directly. +""" +from homeassistant.components.climate import HVACMode + +from custom_components.localthings.climate import ( + _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, +) + + +def test_auto_still_maps_to_heat_cool(): + """'Auto' is unchanged -- AIComfort is handled separately, not folded + into this map.""" + assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL + + +def test_aicomfort_not_in_flat_hvac_map(): + """AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on + top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of + a distinct HVACMode value (see the climate.py module comment).""" + assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC + + +def test_hvac_auto_not_writable_via_hvac_mode(): + """HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via + async_set_hvac_mode -- entered/left only through the ai_comfort preset.""" + assert HVACMode.AUTO not in _HVAC_TO_DEVICE + + +def test_fan_only_still_reachable_via_wind(): + """Guard against regressing the existing 'Wind' -> FAN_ONLY entry while + editing this map.""" + assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY + + +def test_preset_ai_comfort_constant(): + assert PRESET_AI_COMFORT == 'ai_comfort' diff --git a/tests/test_climate_windfree.py b/tests/test_climate_windfree.py new file mode 100644 index 0000000..d9e74f0 --- /dev/null +++ b/tests/test_climate_windfree.py @@ -0,0 +1,23 @@ +"""Tests for the horizontal-swing mapping added to climate.py for issue #75 +-- pure dict lookups, testable without a coordinator/entity fixture (see +test_climate_temperature_fallback.py). + +The preset side of issue #75 (WindFree/motion convenient modes not +surfacing) is intentionally not addressed here: PR #91 replaces +climate.py's static _DEVICE_TO_PRESET table with a generic resolver that +reads any device preset code straight off the unit's own supportedModes, +which covers WindFree/motion generically instead of a per-model dict -- +duplicating that here would just conflict with it. +""" +from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE + + +def test_horizontal_swing_maps_both_directions(): + assert _DEVICE_TO_SWING['Left_And_Right'] == 'horizontal' + assert _SWING_TO_DEVICE['horizontal'] == 'Left_And_Right' + + +def test_existing_swing_modes_unchanged(): + assert _DEVICE_TO_SWING['Fix'] == 'off' + assert _DEVICE_TO_SWING['All'] == 'both' + assert _DEVICE_TO_SWING['Up_And_Low'] == 'vertical' diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index ef6adda..cc0a2fe 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -1,5 +1,7 @@ from custom_components.localthings.registry.capabilities import common from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc +from tests.conftest import _load_device def _reg(): @@ -65,7 +67,7 @@ class TestMergeOptionsField: class TestPowerFallback: def test_generic_href_read_write(self): assert common.POWER_GENERIC.href == '/power/0' - desc = common.POWER_GENERIC.entities[0] + desc = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) assert desc.value_fn(True) is True assert desc.value_fn(False) is False path, body = desc.write_fn('On', {}) @@ -80,13 +82,72 @@ class TestPowerFallback: {}, {'/power/0': {}, '/power/vs/0': {}}) is False def test_vs_fallback_read_write(self): - desc = common.POWER_VS_FALLBACK.entities[0] + desc = next(e for e in common.POWER_VS_FALLBACK.entities if isinstance(e, SwitchDesc)) 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'} + def test_power_switch_hidden_when_firmware_disallows(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = { + '/power/0': {'value': True}, + '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'false'}, + } + assert switch.exists_fn(resources['/power/0'], resources) is False + assert sensor.exists_fn(resources['/power/0'], resources) is True + + def test_power_switch_writable_when_firmware_allows(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = { + '/power/0': {'value': True}, + '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'true'}, + } + assert switch.exists_fn(resources['/power/0'], resources) is True + assert sensor.exists_fn(resources['/power/0'], resources) is False + + def test_power_switch_default_writable_without_setinfo(self): + switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) + sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) + resources = {'/power/0': {'value': True}} + assert switch.exists_fn(resources['/power/0'], resources) is True + assert sensor.exists_fn(resources['/power/0'], resources) is False + + +class TestWmSetinfoFlags: + def test_washer_fixture_carries_setinfo_from_device0(self): + """/wm/setinfo/vs/0 lands in the seed snapshot -- no dedicated capability.""" + resources = _load_device('washer') + assert '/wm/setinfo/vs/0' in resources + assert common.model_allows_power_on_off(resources) is False + assert common.model_setting_without_sc(resources) is True + + def test_dishwasher_allows_power_on_off(self): + resources = _load_device('dishwasher') + assert common.model_allows_power_on_off(resources) is True + assert common.model_setting_without_sc(resources) is False + + def test_deleted_alarms_filtered(self): + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode', + 'x.com.samsung.da.state': 'Deleted', + }, + ]) == 'none' + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'LE', + 'x.com.samsung.da.state': 'Triggered', + }, + { + 'x.com.samsung.da.code': 'ErrorCode', + 'x.com.samsung.da.state': 'Deleted', + }, + ]) == 'LE' + class TestKidsLockFallback: def test_generic_read_write(self): diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index d35f5d4..d2bd4c5 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -20,9 +20,9 @@ class TestTempCurrentGeneric: assert desc.unit_fn({'temperature': 5.0}) == '°F' -class TestTempSetpointGeneric: +class TestTempSetpoint: def test_unit_reads_celsius(self): - desc = fridge.TEMP_SETPOINT_GENERIC.entities[0] + desc = fridge.TEMP_SETPOINT.entities[0] assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 8bd26fe..afba777 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') @@ -247,6 +264,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.""" @@ -261,6 +295,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): + """ARTIK051_PRAC_20K, WindFree-capable unit (issue #75) -- same modelNum + family as the original issue #17 fixture, but its /mode/convenient/vs/0 + additionally reports Nano/NanoSleep/MotionDirect/MotionIndirect, its + /wind/direction/vs/0 reports Left_And_Right, and /humidity/vs/0's + fivepercentHumidity is actually populated.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_windfree') + golden = json.loads((GOLDEN / 'airconditioner_windfree.json').read_text()) + state_keys = _new_state_keys('airconditioner_windfree', 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_range(): """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- reports no oneUiVersion; resolved via the '-RANGE-' modelNum token @@ -278,6 +329,26 @@ def test_registry_reproduces_golden_state_keys_for_range(): ) +def test_registry_reproduces_golden_state_keys_for_range_no_info(): + """NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no + /information/vs/0 at all, so neither for_device nor + for_device_by_model has anything to key off; resolved via the 'Bake'- + in-supportedModes + /cooktopmonitoring/vs/0 signature in + for_device_by_resources. This board's local API has no per-burner + /cooktop/status/vs/0 array either -- only the coarse + /cooktopmonitoring/vs/0 monitoring resource covered by range.py's + COOKTOP_MONITORING.""" + from tests.conftest import _load_device + resources = _load_device('range_no_info') + golden = json.loads((GOLDEN / 'range_no_info.json').read_text()) + state_keys = _new_state_keys('range_no_info', 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_air_purifier(): """ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via the '_TVTL_' modelNum token fallback in for_device_by_model.""" diff --git a/tests/test_range_no_info_capabilities.py b/tests/test_range_no_info_capabilities.py new file mode 100644 index 0000000..71d11ff --- /dev/null +++ b/tests/test_range_no_info_capabilities.py @@ -0,0 +1,58 @@ +"""Tests for the no-/information/vs/0, no-burner-status range variant +(NE63B8411SS-class, issue #74).""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_resources +from custom_components.localthings.registry.capabilities import range as range_caps +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_no_info') + reg = for_device_by_resources(resources) + return reg, resources + + +def _state(): + reg, resources = _range() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_range_registry(): + reg, _ = _range() + assert reg is not None and reg.name == 'range' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #74 dump binds or is ignored -- clears + the coverage-gap repair.""" + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_no_burner_entities(): + """This board reports no /cooktop/status/vs/0, so none of range.py's + per-burner entities should appear -- only COOKTOP_MONITORING's.""" + state = _state() + assert not any(k.startswith('burner_') for k in state) + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', + 'machine_state', 'door_open', 'cloud_connected', + 'cooktop_running_state', 'warming_center_state', + ): + assert key in state, key + + +def test_cooktop_monitoring_reads_live_fields(): + desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'cooktop_running_state') + assert desc.value_fn('Ready') == 'Ready' + desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'warming_center_state') + assert desc.value_fn('Off') == 'Off' 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() +