diff --git a/README.md b/README.md index 33ef700..4a30aa2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Type | Registry | |---|---| +| Air conditioner | `by_type/airconditioner.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | | Cooktop (read-only burner/hood status) | `by_type/cooktop.py` | @@ -101,7 +102,7 @@ custom_components/localthings/ diagnostics.py Redacted diagnostics download (device state + coverage metadata) const.py Domain, config keys, probe ports entity.py Base entity wiring capability registry -> HA entity - sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py + sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py One module per HA platform strings.json / translations/ Config-flow copy + entity state translations registry/ @@ -112,9 +113,9 @@ custom_components/localthings/ adapter.py Flattens bound entities into HA-ready state identity.py Reads device identity for type detection redact.py Strips account/identity data before diagnostics leave HA - capabilities/ Shared + per-family Capability definitions (common, cooktop, - range_hood, dryer, oven, dishwasher, fridge, washer, laundry, - operational, ignored) + capabilities/ Shared + per-family Capability definitions (common, airconditioner, + cooktop, range_hood, dryer, oven, dishwasher, fridge, washer, + laundry, operational, ignored) by_type/ One DeviceRegistry per appliance type, composed from capabilities/ tests/ Registry composition, discovery, entity descriptors, coordinator/observe behavior, and golden-file regression against captured device dumps diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 6e6b3b8..34b1bc3 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -30,24 +30,28 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import ClimateDesc +# The AC's canonical resource hrefs live in the capability module (the single +# source of truth shared with its COVERAGE caps); power prefers the OCF-standard +# href, falling back to the vendor one, mirroring common.POWER_GENERIC / +# POWER_VS_FALLBACK. +from .registry.capabilities.airconditioner import ( + HREF_MODE as MODE_HREF, + HREF_POWER as POWER_HREF, + HREF_POWER_VS as POWER_VS_HREF, + HREF_TEMP_CURRENT as TEMP_CURRENT_HREF, + HREF_TEMP_DESIRED as TEMP_DESIRED_HREF, + HREF_TEMP_CONTROL as TEMP_CONTROL_HREF, + HREF_TEMPS_VS as TEMPS_VS_HREF, + HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, + HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, + HREF_CONVENIENT as CONVENIENT_HREF, +) +from .registry.capabilities.common import normalize_temp_unit from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included -# Sibling OCF resources the climate entity reads (the primary bound href is -# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor -# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK. -POWER_HREF = '/power/0' -POWER_VS_HREF = '/power/vs/0' -MODE_HREF = '/mode/vs/0' -TEMP_CURRENT_HREF = '/temperature/current/0' -TEMP_DESIRED_HREF = '/temperature/desired/0' -TEMP_CONTROL_HREF = '/temperature/control/vs/0' -WIND_STRENGTH_HREF = '/wind/strength/vs/0' -WIND_DIRECTION_HREF = '/wind/direction/vs/0' -CONVENIENT_HREF = '/mode/convenient/vs/0' - _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' @@ -122,6 +126,22 @@ def _num(value): return None +def _temps_vs_item(rep: dict) -> dict: + """First item of the vendor `/temperatures/vs/0` items[] array. + + Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g. + model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard + /temperature/current/0 + /temperature/desired/0 pair; it reports current + and target under a single `/temperatures/vs/0` resource whose + `x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/ + increment/unit. Returns {} when absent, so callers fall through cleanly. + """ + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + class LocalThingsClimate(LocalThingsEntity, ClimateEntity): """Composite climate entity for a Samsung air conditioner.""" @@ -159,24 +179,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + 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))) + + 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] + # -- temperature -------------------------------------------------------- + def _temps_vs(self) -> dict: + """Vendor `/temperatures/vs/0` items[0] (empty {} when absent).""" + return _temps_vs_item(self._rep(TEMPS_VS_HREF)) + @property def temperature_unit(self) -> str: - units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper() - return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS + raw = self._rep(TEMP_DESIRED_HREF).get('units') + if raw is None: + raw = self._temps_vs().get('x.com.samsung.da.unit') + return (UnitOfTemperature.FAHRENHEIT + if normalize_temp_unit(raw, '°C') == '°F' + else UnitOfTemperature.CELSIUS) @property def current_temperature(self): - return _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.current')) + return v @property def target_temperature(self): - return _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.desired')) + return v def _range(self) -> list | None: r = self._rep(TEMP_DESIRED_HREF).get('range') - return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None + if isinstance(r, (list, tuple)) and len(r) == 2: + return r + item = self._temps_vs() + lo = _num(item.get('x.com.samsung.da.minimum')) + hi = _num(item.get('x.com.samsung.da.maximum')) + return [lo, hi] if (lo is not None and hi is not None) else None @property def min_temp(self) -> float: @@ -190,7 +237,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def target_temperature_step(self) -> float: - return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0 + return (_num(self._rep(TEMP_CONTROL_HREF).get('increment')) + or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment')) + or _num(self._temps_vs().get('x.com.samsung.da.increment')) + or 1.0) # -- hvac mode ---------------------------------------------------------- @@ -214,30 +264,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def fan_mode(self): - return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def fan_modes(self) -> list[str]: - return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF) - if c in _DEVICE_TO_FAN] + return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def swing_mode(self): - return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def swing_modes(self) -> list[str]: - return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF) - if c in _DEVICE_TO_SWING] + return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def preset_mode(self): - return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))) + return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) @property def preset_modes(self) -> list[str]: - return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF) - if c in _DEVICE_TO_PRESET] + return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) # -- writes ------------------------------------------------------------- @@ -263,17 +310,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): async def async_turn_off(self) -> None: await self.coordinator.async_send_command(self._bound, ('power', False)) - async def async_set_fan_mode(self, fan_mode: str) -> None: - device = _FAN_TO_DEVICE.get(fan_mode) + async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None: + """Map an HA fan/swing/preset value back to its device code and write it.""" + device = mapping.get(value) if device is not None: - await self.coordinator.async_send_command(self._bound, ('fan', device)) + await self.coordinator.async_send_command(self._bound, (kind, device)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: - device = _SWING_TO_DEVICE.get(swing_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('swing', device)) + await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: - device = _PRESET_TO_DEVICE.get(preset_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('preset', device)) + await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 8b5f44f..94dc1d9 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -12,6 +12,7 @@ import cbor2 from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.device_registry import DeviceInfo @@ -511,13 +512,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None: - """Write a value to the device. Fire-and-forget style.""" + """Write a value to the device. Fire-and-forget style. + + A description-level validate_fn (currently SwitchDesc only) runs + here rather than per-platform, so rejecting a write with a + user-facing message -- as opposed to write_fn's silent no-op below + -- is available to every platform for free.""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: return href = bound_entity.href rep = self._cache.get(href or '') or {} + validate_fn = getattr(desc, 'validate_fn', None) + if validate_fn is not None: + error = validate_fn(payload, rep, self._cache.snapshot()) + if error: + raise ServiceValidationError(error) result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 8d75794..b42f821 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] integration = await async_get_integration(hass, DOMAIN) + # importlib.metadata.version() reads the installed package's metadata off + # disk (listdir + open + read_text), which trips HA's event-loop blocking + # detector when called inline here. Offload it to the executor. + stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local") + return { "device_type": coordinator.device_type_name or "unknown", "one_ui_version": coordinator.one_ui_version, "unbound_hrefs": sorted(coordinator._unbound_hrefs), "resources": redact_resources(coordinator.last_resources), "integration_version": integration.version, - "smartthings_local_version": pkg_version("smartthings-local"), + "smartthings_local_version": stl_version, "observe_mode": coordinator.observe_mode, "observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs), "observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs), diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 17962bf..8a8fc3d 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.const import EntityCategory from .registry.adapter import _key -from .registry.discovery import BoundEntity +from .registry.discovery import BoundEntity, _snake_to_title from .const import DOMAIN from .coordinator import LocalThingsCoordinator @@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str: instance number with a space: "door_cooler_open1" → "Door Cooler Open 1". """ name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key) - return name.replace('_', ' ').title().strip() + return _snake_to_title(name).strip() class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): @@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): self._bound = bound self._state_key = _key(bound) self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}" - self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key) + if bound.desc.name is not None: + self._attr_name = bound.desc.name + elif bound.instance_name: + # A device-given instance name (e.g. an ice maker's "Cubed + # Ice") takes the place of the href-derived instance label, + # keeping the same entity-specific suffix (issue #27). + self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip() + else: + self._attr_name = _derive_name(self._state_key) self._attr_translation_key = bound.desc.translation_key self._attr_icon = bound.desc.icon raw_cat = bound.desc.entity_category diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 1808a4c..dad4fc9 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.6.0" + "version": "0.9.0" } diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index acda4fe..e3c5aad 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -69,6 +69,7 @@ class ObserveManager: self.last_mode_change_wall = time.time() self._settle_until: dict[str, float] = {} self._settle_lock = threading.Lock() + self._cache_lock = threading.Lock() self.subscribed_hrefs: set[str] = set() self._notified: set[str] = set() self._last_notify_ts: float | None = None @@ -92,11 +93,35 @@ class ObserveManager: return True def apply(self, href: str, rep: dict, source: str) -> bool: - """Gate a StateCache.apply_rep call through the write-settle guard.""" + """Gate a StateCache.apply_rep call through the write-settle guard. + + Merges the incoming rep onto whatever's already cached for this + href rather than handing it to StateCache.apply_rep verbatim -- + apply_rep does a full replace, and Samsung devices don't always + repeat every field on every update (issue #27: a /mode/vs/0 + OBSERVE notify -- and, on at least one Bespoke fridge, even the + /device/0 sweep entry for that href -- can carry just `modes`, + omitting `supportedOptions`/`supportedModes` entirely). A full + replace would silently wipe fields a select entity's + exists_fn/options_field gates on the moment one partial update + comes through, even though nothing about the device's actual + supported options changed. + + `apply()` is the sole path StateCache mutations flow through in + this component (poll, sweep, and OBSERVE notify all funnel here), + so `_cache_lock` serializes the read-then-write across those + threads (DTLS reader for notifies, executor threads for poll/ + sweep) -- without it, two concurrent updates for the same href + could each read the same prior rep and the second writer would + silently lose the first's fields, reintroducing the exact bug + this merge fixes. + """ if self._is_settling(href): self.log.debug("dropping %s update for %s (settling)", source, href) return False - return self.cache.apply_rep(href, rep, source=source) + with self._cache_lock: + merged = {**(self.cache.get(href) or {}), **rep} + return self.cache.apply_rep(href, merged, source=source) def on_notification(self, href: str, payload: bytes) -> None: """Wired as DtlsCoapSession.on_notification. Runs on the DTLS diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 16e7556..102a734 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -77,6 +77,8 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]: _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { 'WW': 'washer', 'WD': 'washer', + 'WF': 'washer', + 'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19 'DV': 'dryer', 'DW': 'dishwasher', } @@ -103,6 +105,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. if key is None and '_PRAC_' in (model_num or ''): key = 'airconditioner' + # Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain + # '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the + # 'P' sits between the underscore and 'RAC' in that token). + if key is None and '_RAC_' in (model_num or ''): + key = 'airconditioner' model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7d27cc8..e218de5 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -3,27 +3,28 @@ The first device whose core controls surface as a single composite HA `climate` entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind are consumed by that entity rather than exposed as separate switches/selects, so -this registry deliberately does NOT include the common POWER caps -- on/off is -the climate entity's HVACMode.OFF / TURN_ON/OFF. +this registry includes *common.UNIVERSAL but deliberately NOT common.POWER -- +on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's +own comment in capabilities/common.py for why it's excluded. -Reuses common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every -registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0. +Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ -from ..capabilities import airconditioner, common, dishwasher, fridge, ignored +from ..capabilities import airconditioner, common, dishwasher, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='airconditioner', capabilities=_build([ *ignored.IGNORED, - common.ALARMS, - common.ENERGY_METER, - fridge.FIRMWARE_UPDATE, + *common.UNIVERSAL, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.DISPLAY_LIGHT, + airconditioner.MUTE_ONCE, + airconditioner.CURRENT_LIMIT, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index ffe6400..08084d9 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -1,6 +1,6 @@ """Cooktop device registry.""" -from ..capabilities import cooktop, fridge, ignored +from ..capabilities import common, cooktop, ignored from ._base import DeviceRegistry, _build @@ -12,6 +12,6 @@ REGISTRY = DeviceRegistry( cooktop.COOKTOP_MODE, cooktop.COOKTOP_CONNECTED, cooktop.PAIRED_HOOD_STATUS, - fridge.FIRMWARE_UPDATE, + common.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/dishwasher.py b/custom_components/localthings/registry/by_type/dishwasher.py index e12dbd7..1f099f3 100644 --- a/custom_components/localthings/registry/by_type/dishwasher.py +++ b/custom_components/localthings/registry/by_type/dishwasher.py @@ -1,19 +1,13 @@ """Dishwasher device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational +from ..capabilities import common, dishwasher, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='dishwasher', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, + *common.UNIVERSAL, + *common.POWER, common.WATER_METER, common.WATER_FILTER, operational.OPERATIONAL_STATE, @@ -25,6 +19,5 @@ REGISTRY = DeviceRegistry( laundry.DOOR_LED, laundry.SOUND_MODE, laundry.SOUND_VOLUME, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/dryer.py b/custom_components/localthings/registry/by_type/dryer.py index 71122f1..81d2891 100644 --- a/custom_components/localthings/registry/by_type/dryer.py +++ b/custom_components/localthings/registry/by_type/dryer.py @@ -9,21 +9,15 @@ objects the washer registry uses -- washer and dryer expose the same DA_WM_-family surface, so they stay consistent instead of each carrying a bespoke variant. """ -from ..capabilities import common, dryer, fridge, ignored, laundry, operational +from ..capabilities import common, dryer, ignored, laundry, operational from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='dryer', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, + *common.UNIVERSAL, + *common.POWER, operational.OPERATIONAL_STATE, laundry.DOOR_LED, laundry.SOUND_MODE, @@ -32,6 +26,5 @@ REGISTRY = DeviceRegistry( dryer.DRYER_SETTINGS, dryer.DRYER_COURSE, dryer.DRYER_DIAGNOSIS, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 69a0096..2ae3341 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -1,24 +1,18 @@ """Oven device registry.""" -from ..capabilities import common, fridge, ignored, oven +from ..capabilities import common, ignored, oven from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='oven', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, + *common.UNIVERSAL, + *common.POWER, oven.OVEN_CAVITY, oven.OVEN_SETPOINT, oven.OVEN_MODE, oven.OVEN_OPERATIONAL_STATE, oven.OVEN_DOOR, oven.OVEN_CONNECTED, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/by_type/range_hood.py b/custom_components/localthings/registry/by_type/range_hood.py index 9d6b8ec..9eab99c 100644 --- a/custom_components/localthings/registry/by_type/range_hood.py +++ b/custom_components/localthings/registry/by_type/range_hood.py @@ -1,6 +1,6 @@ """Range-hood device registry.""" -from ..capabilities import common, fridge, ignored, range_hood +from ..capabilities import common, ignored, range_hood from ._base import DeviceRegistry, _build @@ -10,7 +10,7 @@ REGISTRY = DeviceRegistry( *ignored.IGNORED, range_hood.HOOD_ALARMS, common.ENERGY_METER, - fridge.FIRMWARE_UPDATE, + common.FIRMWARE_UPDATE, range_hood.HOOD_FAN, range_hood.HOOD_LAMP, range_hood.HOOD_FILTER, diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index ce2f1dc..b893475 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -1,22 +1,17 @@ """Refrigerator device registry.""" -from ..capabilities import common, fridge, ignored +from ..capabilities import common, dishwasher, fridge, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='refrigerator', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, + *common.UNIVERSAL, + *common.POWER, fridge.STATUS_LOCK, fridge.DOOR_ALERT, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, - common.ALARMS, - common.ENERGY_METER, common.WATER_FILTER, + dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, fridge.REFRIGERATION, @@ -26,13 +21,14 @@ REGISTRY = DeviceRegistry( fridge.CABINET_LIGHT_ENHANCED, fridge.SABBATH, fridge.BEVERAGE_ZONE, - fridge.FIRMWARE_UPDATE, + fridge.PANTRY_ZONE, fridge.DEFROST_DELAY, + fridge.DEFROST_DELAY_NATIVE_DUPLICATE, fridge.DEFROST_BLOCK_STATUS, - fridge.SELF_CHECK, fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, + fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE, fridge.REFRIGERATION_FALLBACK, ]), pattern_capabilities=[ diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index 2b84126..c7732d8 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -1,26 +1,19 @@ """Washer device registry.""" -from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational, washer +from ..capabilities import common, dishwasher, ignored, laundry, operational, washer from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( name='washer', capabilities=_build([ *ignored.IGNORED, - common.POWER_GENERIC, - common.POWER_VS_FALLBACK, - common.KIDS_LOCK_GENERIC, - common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, - common.REMOTE_CONTROL_VS_FALLBACK, + *common.UNIVERSAL, + *common.POWER, washer.WASHER_SETTINGS, washer.WASHER_COURSE, laundry.BUZZER_SOUND, laundry.JOB_BEGINNING_STATUS, - common.ALARMS, - common.ENERGY_METER, common.WATER_METER, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, - fridge.FIRMWARE_UPDATE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index b085d70..c58cf03 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,7 +15,32 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import ClimateDesc, SensorDesc, SwitchDesc +from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc + +# --------------------------------------------------------------------------- +# Canonical AC resource hrefs. The climate entity (climate.py) binds the +# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS +# siblings off the coordinator snapshot; those siblings are marked covered +# (no-entity caps) so discover() reports no gap. Declared once here and +# imported by climate.py, so a new sibling read can't drift out of sync with +# its coverage entry. +# --------------------------------------------------------------------------- +HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE) +HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF +HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off +HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature +HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target) +HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step +HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode +HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode +HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode +HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) + +CLIMATE_CONSUMED_HREFS = [ + HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, + HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, + HREF_CONVENIENT, +] def _num(v): @@ -71,7 +96,7 @@ def _climate_write(payload, rep, href=None): CLIMATE = Capability( - href='/mode/vs/0', + href=HREF_MODE, poll_tier='warm', entities=( ClimateDesc(key='climate', translation_key='airconditioner', @@ -123,28 +148,76 @@ AIR_FILTER = Capability( ), ) -# --------------------------------------------------------------------------- -# AC-scoped coverage: hrefs consumed by the composite climate entity, and -# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in -# the global ignored.IGNORED because several of them (/mode/vs/0 handled above, -# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A -# no-entity Capability still marks the href as bound so discover() reports no -# coverage gap. -# --------------------------------------------------------------------------- -_CONSUMED_BY_CLIMATE = [ - '/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF - '/power/vs/0', # vendor fallback for on/off - '/temperature/current/0', # climate current_temperature - '/temperature/desired/0', # climate target_temperature (write target) - '/temperature/control/vs/0', # climate target_temperature_step - '/wind/strength/vs/0', # climate fan_mode - '/wind/direction/vs/0', # climate swing_mode - '/mode/convenient/vs/0', # climate preset_mode -] +DISPLAY_LIGHT = Capability( + href='/light/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='display_light', field='mode', + name='Display light', icon='mdi:led-on', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['light', 'vs', '0'], + {'mode': 'On' if p == 'On' else 'Off'})), + ), +) +# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single +# boolean field, no vendor prefix, mirroring the On/Off convention used +# throughout the rest of this API. +MUTE_ONCE = Capability( + href='/option/muteonce/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='mute_once', field='muteonce', + name='Mute once', icon='mdi:volume-mute', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['option', 'muteonce', 'vs', '0'], + {'muteonce': 'On' if p == 'On' else 'Off'})), + ), +) + +# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation` +# toggles the limiter and `modes` picks a level out of `supportedModes` +# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the +# dump to confirm what the levels mean (amps vs. an abstract tier) -- +# exposed read-only per the 'don't guess' rule rather than risking an +# unverified write to live HVAC hardware. +CURRENT_LIMIT = Capability( + href='/electriccurrent/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='current_limit_enabled', field='operation', + name='Current limit enabled', icon='mdi:current-ac', + entity_category='diagnostic', + value_fn=lambda v: v == 'On'), + SensorDesc(key='current_limit_level', field='modes', + name='Current limit level', icon='mdi:current-ac', + entity_category='diagnostic'), + ), +) + +# --------------------------------------------------------------------------- +# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate +# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. +# These are NOT in the global ignored.IGNORED because several of them +# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with +# other families' schemas. A no-entity Capability still marks the href as +# bound so discover() reports no coverage gap. +# +# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state +# (power, current/target temp, fan, swing, preset) -- the coordinator only +# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py), +# so leaving these at the Capability default of 'cold' meant every state +# change was invisible until the next full /device/0 summary sweep +# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to +# 'warm' -- same tier as CLIMATE's own primary href -- so they get push +# notifications (or, in poll-only mode, the warm sub-poll cadence) instead +# of waiting on the summary sweep. +# --------------------------------------------------------------------------- _AC_IGNORED = [ - # Vendor superset that duplicates the OCF /temperature/current+desired pair. - '/temperatures/vs/0', # 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', @@ -152,7 +225,29 @@ _AC_IGNORED = [ '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', + # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no + # user-actionable state or no documented write contract, so per the + # 'don't guess' rule they are ignored rather than modeled. + # /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here -- + # see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in + # the by_type registry, both of which have a confirmed, cleanly + # modelable contract. + '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing + '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) + '/mds/absencestate/vs/0', # motion-detection state (empty here) + '/remotedatacontrol/vs/0', # remote data-control session status + '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/welcome/temperature/vs/0', # welcome-cooling plumbing ] # Built as bare no-entity caps; folded into the AC registry (not global). -COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)] +COVERAGE = [ + Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS +] + [ + 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 eadd646..ef181fc 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -11,7 +11,9 @@ against live device dumps: /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ from ..capability import Capability -from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc, +) def _num(v): @@ -175,6 +177,38 @@ ENERGY_METER = Capability( state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, exists_fn=lambda rep, resources: ( not rep or 'x.com.samsung.da.cumulativePower' in rep)), + # cumulativeConsumption is a second, independently-varying running + # total alongside cumulativePower -- some fridges (issue #26) report + # both. Self-gates off where only cumulativePower is present. `not + # rep or` keeps the same empty-{} stub carve-out as power_watts/ + # energy_kwh above -- without it, an exists_fn permanently drops the + # entity if setup happens to land on a not-yet-fetched stub. + SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption', + name='Power energy', device_class='energy', + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)), + # AI Energy Mode's lifetime savings estimate vs. an unoptimized + # baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ + # #27) and absent on others (issue #20/#26), unlike cumulativePower. + SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower', + name='Energy saved', device_class='energy', + state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)), + # Monthly billing-cycle totals -- the completed prior month and the + # in-progress current month. Not ever-increasing (each resets at + # month boundary), so no state_class. + SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption', + name='Energy (last month)', device_class='energy', + unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.monthlyConsumption' in rep)), + SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption', + name='Energy (this month)', device_class='energy', + unit='kWh', value_fn=wh_to_kwh, + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)), ), ) @@ -199,3 +233,162 @@ WATER_FILTER = Capability( name='Filter status', icon='mdi:filter-check'), ), ) + +# AI energy-saving level -- '0' is off, and supportedAiLevel lists the +# additional level(s) the device offers ('1' meaning just "on" on most +# hardware, but multi-level boards have been reported). Verified cross-family: +# fridge (issue #21) and washer (issue #40) both expose this href. +# +# supportedAiLevel is a single-entry list on most captured hardware, where a +# select would offer only one real choice against an implicit "off" -- shown +# as a switch instead. '0' itself is never in supportedAiLevel but has been +# observed live as the off value of aiLevel, so the select synthesizes it +# back in as an explicit option rather than leaving no way to turn off. +# +# No translation_key: aiLevel's values are plain digit strings, and +# select.py's _display() already renders an untranslated numeric string +# as-is -- there's nothing a strings.json entry adds that's worth maintaining +# against an unknown, growing number of future levels. + + +def _ai_energy_supported_levels(rep): + """supportedAiLevel as a list -- a stray scalar (e.g. a string) must not + be len()-checked as if it were a list.""" + sl = rep.get('supportedAiLevel') + return list(sl) if isinstance(sl, (list, tuple)) else [] + + +def _ai_energy_level_options(resources): + rep = resources.get('/energy/ailevel/vs/0') or {} + return ['0', *_ai_energy_supported_levels(rep)] + + +def _ai_energy_level_write(p, rep, href=None): + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p} + + +def _ai_energy_level_switch_write(p, rep, href=None): + levels = _ai_energy_supported_levels(rep) + on_level = levels[0] if levels else '1' + return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'} + + +AI_ENERGY_LEVEL = Capability( + href='/energy/ailevel/vs/0', + poll_tier='cold', + entities=( + # No `not rep` stub carve-out on either side, unlike most exists_fn + # gates in this file -- entity creation only ever runs once, against + # whichever snapshot happens to be current the moment platforms are + # set up (see entity._is_included / __init__.py's + # async_config_entry_first_refresh-before-forward-entry-setups + # ordering), while flatten() re-evaluates exists_fn every poll + # against live data. Both descriptors share key='ai_energy_level', + # so if a stub carve-out let one of them win at setup time while the + # other wins once real data lands, flatten() would feed the + # instantiated entity a value shaped for the other platform (e.g. a + # bool into a Select). Requiring real, populated data on both sides + # keeps the entity-creation decision and the live-value decision in + # permanent agreement -- the cost is this entity doesn't appear + # until a reload if the device's very first poll stubs this + # cold-tier href, the same reload already required to fix which + # platform got picked in that case. + SwitchDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + value_fn=lambda v: v != '0', + exists_fn=lambda rep, resources: ( + len(_ai_energy_supported_levels(rep)) == 1), + write_fn=_ai_energy_level_switch_write), + SelectDesc(key='ai_energy_level', field='aiLevel', + name='AI energy level', icon='mdi:leaf', + entity_category='config', + options=_ai_energy_level_options, + exists_fn=lambda rep, resources: ( + len(_ai_energy_supported_levels(rep)) > 1), + write_fn=_ai_energy_level_write), + ), +) + +FIRMWARE_UPDATE = Capability( + href='/otninformation/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc( + key='firmware_update', + field='x.com.samsung.da.newVersionAvailable', + name='Firmware update available', + device_class='update', + entity_category='diagnostic', + value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, + ), + ), +) + +SELF_CHECK = Capability( + href='/selfcheck/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status', + name='Self-check status', icon='mdi:stethoscope', + entity_category='diagnostic'), + SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', + name='Self-check result', icon='mdi:clipboard-check-outline', + entity_category='diagnostic'), + # List of error codes from the last self-check; joined for display. + # Not every fridge reports the field, hence the exists_fn. + SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', + name='Self-check error', icon='mdi:alert-circle-outline', + entity_category='diagnostic', + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.error' in rep), + value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v), + ButtonDesc(key='selfcheck_start', field='', name='Start self-check', + payload='Start', icon='mdi:play-circle-outline', + entity_category='diagnostic', + write_fn=lambda p, rep, href=None: ( + ['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})), + ), +) + +# --------------------------------------------------------------------------- +# Cross-family bundles, unpacked into every by_type registry's _build([...]) +# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER). +# discover() only binds a capability whose href is actually present in a +# given device's resource dump, so listing one here for a family that +# doesn't expose the href is a no-op, not a phantom entity -- see the +# adding-device-support skill's coverage-discipline section. +# +# UNIVERSAL holds every capability with no known family that both (a) has +# the href and (b) needs to model it some other way -- broadening one of +# these to a new family is a safe, harmless guess (issue #40's AI energy +# level: 2 of 6 families confirmed, blanket-added everywhere else). +# +# POWER is kept separate -- airconditioner is the one family that opts out +# of it. Canonical reason (see by_type/airconditioner.py and its test for +# pointers back here, not restatements): AC's climate entity already owns +# /power/0 and /power/vs/0 via bare, no-entity Capability objects +# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ +# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href +# with >1 cap must have every cap discriminated by rt_filter/match_fn, and +# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have +# this conflict -- no AC dump has ever reported those hrefs -- so they stay +# in UNIVERSAL. +# --------------------------------------------------------------------------- + +UNIVERSAL = ( + ALARMS, + ENERGY_METER, + FIRMWARE_UPDATE, + SELF_CHECK, + AI_ENERGY_LEVEL, + KIDS_LOCK_GENERIC, + KIDS_LOCK_VS_FALLBACK, + REMOTE_CONTROL_GENERIC, + REMOTE_CONTROL_VS_FALLBACK, +) + +POWER = ( + POWER_GENERIC, + POWER_VS_FALLBACK, +) diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index ca5c8d5..887d11d 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -8,7 +8,7 @@ wash, auto release dry) are read locally here. """ from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_select, option_value, replace_in_options +from .laundry import bool_option_switch, cycle_select # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability( # --------------------------------------------------------------------------- # /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the # dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the -# same options array. Course display names live in translations under +# same options array (shared laundry.bool_option_switch, same options[] +# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches +# use). Course display names live in translations under # entity.select.dishwasher_cycle (see laundry.cycle_select). # --------------------------------------------------------------------------- - -def _storm_wash_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p), - } - - -def _auto_release_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p), - } - - CYCLE_OPTIONS = Capability( href='/course/vs/0', entities=( cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'), - SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy', - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On', - write_fn=_storm_wash_write), - SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open', - exists_fn=lambda rep, resources: any( - isinstance(o, str) and o.startswith('AutoDoorRelease_') - for o in (rep.get('x.com.samsung.da.options') or []) - ), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On', - write_fn=_auto_release_write), + bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy', + 'StormWashZone'), + bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open', + 'AutoDoorRelease', gate_on_presence=True), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 1f056e7..2f2589c 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -19,7 +19,7 @@ import datetime from ..capability import Capability from ..entities import ( - BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc, + BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc, ) from .common import normalize_temp_unit @@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability( # Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0) # /icemaker/status/vs/0 is kept as exact-href cap and binds first. # /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state). +# +# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE", +# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two +# ice makers are independent on/off toggles that can both be enabled at once +# (issue #27), so they stay separate entities rather than a single ice-type +# select, but users still want them labeled with the device's own names. # --------------------------------------------------------------------------- def _icemaker_write(field): @@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability( href=None, href_prefix='/icemaker/', match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep, + name_field='x.com.samsung.da.iceMaker.name', poll_tier='warm', entities=( SensorDesc(key='making_status', @@ -209,6 +216,14 @@ DEFROST_DELAY = Capability( ), ) +# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K +# firmware publishes the same state on both hrefs, but only the vendor resource +# above has a confirmed write contract. Bind the native mirror without another +# entity so discovery records it as an intentional duplicate. +DEFROST_DELAY_NATIVE_DUPLICATE = Capability( + href='/defrost/delay/0', +) + DEFROST_BLOCK_STATUS = Capability( href='/defrost/block/vs/0', poll_tier='warm', @@ -220,28 +235,6 @@ DEFROST_BLOCK_STATUS = Capability( ), ) -# --------------------------------------------------------------------------- -# Self-check diagnostic -# --------------------------------------------------------------------------- - -SELF_CHECK = Capability( - href='/selfcheck/vs/0', - poll_tier='cold', - entities=( - SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status', - name='Self-check status', icon='mdi:stethoscope', - entity_category='diagnostic'), - SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', - name='Self-check result', icon='mdi:clipboard-check-outline', - entity_category='diagnostic'), - ButtonDesc(key='selfcheck_start', field='', name='Start self-check', - payload='Start', icon='mdi:play-circle-outline', - entity_category='diagnostic', - write_fn=lambda p, rep, href=None: ( - ['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})), - ), -) - # --------------------------------------------------------------------------- # Refrigeration modes (rapid cooling) # --------------------------------------------------------------------------- @@ -494,17 +487,64 @@ BEVERAGE_ZONE = Capability( ), ) +# --------------------------------------------------------------------------- +# Pantry / Cool Select Zone -- a convertible compartment toggled between +# wine/deli/drinks temperature presets (issue #20). Same shape as +# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes +# list) but a distinct resource/field set -- x.com.samsung.da.mode / +# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than +# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one" +# instance has been seen; not generalized to a pattern cap until a second +# instance turns up. +# --------------------------------------------------------------------------- + +def _pantry_write(p, rep, href=None): + return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p} + + +PANTRY_ZONE = Capability( + href='/status/pantry/one/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode', + name='Pantry zone mode', icon='mdi:glass-wine', + translation_key='pantry_zone_mode', + entity_category='config', + options_field='x.com.samsung.da.supportedOptions', + write_fn=_pantry_write), + ), +) + # --------------------------------------------------------------------------- # Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges) # -# x.com.samsung.da.modes holds multiple orthogonal flags in one list. -# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix. +# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the +# flex-zone entry is whichever item is also a member of supportedOptions -- +# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker) +# never appear there. The prefix on that item varies by fridge family +# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 / +# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but +# stuck on None), so match by list membership instead of a hardcoded prefix. # Write replaces only that item; other flags are preserved. # --------------------------------------------------------------------------- +def _flex_zone_supported(rep): + return set(rep.get('x.com.samsung.da.supportedOptions') or ()) + + +def _flex_zone_current(rep): + # Every dump seen has at most one modes/supportedOptions overlap, so + # "first match" and "strip all matches" (in the write below) agree. If a + # future device ever reports two, this reads the first and the write + # would drop both -- revisit if that turns up. + modes = rep.get('x.com.samsung.da.modes') or [] + supported = _flex_zone_supported(rep) + return next((m for m in modes if m in supported), None) + + def _flex_zone_write(p, rep, href=None): - modes = list(rep.get('x.com.samsung.da.modes') or []) - modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')] + supported = _flex_zone_supported(rep) + modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported] modes.append(p) return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes} @@ -514,39 +554,17 @@ FLEX_ZONE = Capability( poll_tier='warm', entities=( SelectDesc(key='flex_zone_mode', - field='x.com.samsung.da.modes', name='Flex zone mode', icon='mdi:thermostat', translation_key='flex_zone_mode', entity_category='config', options_field='x.com.samsung.da.supportedOptions', exists_fn=lambda rep, resources: bool( rep.get('x.com.samsung.da.supportedOptions')), - value_fn=lambda modes: next( - (m for m in (modes or []) - if m.startswith('CV_TTYPE_RF9000A_')), None), + rep_fn=_flex_zone_current, write_fn=_flex_zone_write), ), ) -# --------------------------------------------------------------------------- -# Firmware update -# --------------------------------------------------------------------------- - -FIRMWARE_UPDATE = Capability( - href='/otninformation/vs/0', - poll_tier='cold', - entities=( - BinarySensorDesc( - key='firmware_update', - field='x.com.samsung.da.newVersionAvailable', - name='Firmware update available', - device_class='update', - entity_category='diagnostic', - value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, - ), - ), -) - # --------------------------------------------------------------------------- # Generic door pattern capability (href=None — use as pattern_cap only) # --------------------------------------------------------------------------- @@ -655,6 +673,14 @@ ICEMAKER_STATUS_FALLBACK = Capability( ), ) +# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured +# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit +# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so +# keep the proven per-unit/vendor controls and bind this as a duplicate only. +ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( + href='/icemaker/status/0', +) + # OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out # in this section: its three fields duplicate two *different* richer # hrefs (REFRIGERATION's rapidFridge/rapidFreezing and diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index be54b62..b7e3cc1 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -85,14 +85,9 @@ IGNORED: list[Capability] = [ # Warranty/service-plan enrollment status — every field reads "Unknown" # on hardware not enrolled in a Samsung Care+ style program. Capability(href='/dginformation/vs/0'), - # AI energy-saving level; only one value ('1') is ever in - # supportedAiLevel on hardware seen so far, so there's no real choice - # to expose. Revisit if a device surfaces more than one supported level. - Capability(href='/energy/ailevel/vs/0'), # OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF') - # has ever been seen in `modes` (issue #7's dump), same "no real choice - # to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a - # device surfaces it toggled on. + # has ever been seen in `modes` (issue #7's dump) -- no real choice to + # expose yet. Revisit if a device surfaces it toggled on. Capability(href='/mode/0'), # Opaque integer with no supportedModes/options list to interpret it # against — meaning unclear from the raw resource alone. diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index a84c8f2..820a2ad 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon): ) +# --------------------------------------------------------------------------- +# Plain boolean toggles over /course/vs/0's options[] array: a +# '_On'/'_Off' token, read-modify-written the same way as +# the 'Course' token above. Shared by washer (bubble soak, pre-wash, +# intensive -- issue #22) and dishwasher (storm wash, auto release dry) -- +# both families ride this exact contract, just with different prefixes and +# different presence/validation needs on top. +# --------------------------------------------------------------------------- + + +def bool_option_write(prefix): + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), + } + return write + + +def bool_option_value(prefix): + return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' + + +def bool_option_exists(prefix): + return lambda rep, resources: option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + +def bool_option_switch(key, name, icon, prefix, *, entity_category=None, + gate_on_presence=False, validate_fn=None): + """A SwitchDesc over a '_On'/'_Off' options[] token. + + gate_on_presence self-gates the entity off on models that never report + the token at all (washer's bubble soak/pre-wash/intensive); leave False + for a toggle every device in the family reports (dishwasher's storm + wash). validate_fn is passed straight through to SwitchDesc for callers + that need to reject a write against live device state (e.g. washer's + per-course availability check) -- this factory has no opinion on it and + building one, if needed, is the caller's job. + """ + return SwitchDesc( + key=key, name=name, icon=icon, entity_category=entity_category, + exists_fn=bool_option_exists(prefix) if gate_on_presence else None, + rep_fn=bool_option_value(prefix), + write_fn=bool_option_write(prefix), + validate_fn=validate_fn, + ) + + # --------------------------------------------------------------------------- # /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason # (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 859b6ce..04b52b5 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -17,10 +17,13 @@ from datetime import datetime, timezone from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc -from .laundry import cycle_select, hex_pairs, option_value, replace_in_options +from .laundry import ( + bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, + replace_in_options, +) # --------------------------------------------------------------------------- -# Course_XX hex codes. The 23 codes named in strings.json/translations +# Course_XX hex codes. 23 of the codes named in strings.json/translations # under entity.select.washer_cycle.state. were captured # from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList # (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched @@ -35,6 +38,13 @@ from .laundry import cycle_select, hex_pairs, option_value, replace_in_options # you'd expect to be pinned (default cycle + maintenance cycle), # corroborating the positional match. # +# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry, +# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the +# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer +# combo's editCourseList and were named from that user's app screenshot +# (issue #22). Combo units carry their own course set, so these codes +# don't imply anything about '1F' on a plain washer. +# # 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 @@ -82,6 +92,19 @@ WASHER_SETTINGS = Capability( options_field='x.com.samsung.da.supportedRinseCycles', write_fn=lambda p, rep, href=None: ( ['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})), + # Washer/dryer combo units carry a dryLevel field on the wash + # resource itself (no separate dryer device/course) -- see issue + # #22. Self-gates off on plain washers, which never report + # supportedDryLevel. + SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel', + name='Dry level', icon='mdi:tumble-dryer', + entity_category='config', + translation_key='washer_dry_level', + options_field='x.com.samsung.da.supportedDryLevel', + exists_fn=lambda rep, resources: bool( + rep.get('x.com.samsung.da.supportedDryLevel')), + write_fn=lambda p, rep, href=None: ( + ['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})), ), ) @@ -160,13 +183,50 @@ def _level_options(prefix): return lambda resources: _supported_level_options(resources, prefix) +def _dosing_level(prefix): + """Current dose code, normalized to the `Supported` code format. + + The device reports the selected level as `_` with the code + un-padded (e.g. '3'), but the valid codes -- which are also this select's + options and its translation keys -- come from `Supported_` + as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits + outside the select's own option list, so HA renders it 'unknown' (issue #9). + Resolve it to the supported code with the same integer value so + current_option matches an option (and its translation).""" + def fn(rep): + opts = rep.get('x.com.samsung.da.options') + raw = option_value(opts, prefix) + if raw is None: + return None + supported_raw = option_value(opts, f'Supported{prefix}') + try: + target = int(raw, 16) + except (TypeError, ValueError): + return raw + for code in hex_pairs(supported_raw) if supported_raw else []: + try: + if int(code, 16) == target: + return code + except (TypeError, ValueError): + continue + return raw + return fn + + def _level_write(prefix): def write(p, rep, href=None): opts = list(rep.get('x.com.samsung.da.options') or []) if not opts: return None + # `p` is the zero-padded supported code the UI selected (e.g. '03'); + # the device stores the level un-padded (e.g. '3'), matching how it + # reports it, so write it back in that native shape. + try: + native = format(int(p, 16), 'X') + except (TypeError, ValueError): + native = p return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), + 'x.com.samsung.da.options': replace_in_options(opts, prefix, native), } return write @@ -176,9 +236,58 @@ def _dosing_low(prefix): rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') -def _dosing_alarm_exists(prefix): - return lambda rep, resources: option_value( - rep.get('x.com.samsung.da.options'), prefix) is not None +# Bubble soak / pre-wash / intensive-wash toggles, from the same options[] +# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a +# plain '_On'/'_Off' token, confirmed by a dump taken with +# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape +# already used by AiOption and KidsLockBypass in this same array, so +# PreWashSetting/IntensiveSetting are assumed to follow suit. +# +# Each also has a differently-named hex-pair availability field that lines up +# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet, +# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of +# 24), all three read 'F0' at that position and the toggle was writable -- +# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak +# Off') decodes to '00' for that course, matching the app graying the +# control out there. 'F0'/'00' is treated as available/unavailable on that +# evidence. exists_fn (device-level presence) still only runs once, against +# the setup-time snapshot, so it isn't a fit for this per-course check -- +# validate_fn runs on every write attempt instead (dispatched from +# coordinator.async_send_command, ahead of write_fn), rejecting an on-write +# for a course whose byte isn't 'F0' with a user-facing error rather than +# silently no-opping against the device. The read/write/presence machinery +# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/ +# auto-release-dry toggles -- only this per-course gating is washer-only, so +# it stays here rather than in laundry.py (see laundry.bool_option_switch's +# docstring: it takes a prebuilt validate_fn and has no opinion on it). +def _bool_option_switch(key, name, icon, prefix, availability_field): + def validate(p, rep, resources): + """Reject turning on when the selected course's byte in + `availability_field` isn't 'F0'. Turning off is never blocked. Falls + back to allowing the write whenever the availability data can't be + resolved (unrecognized course, missing/mismatched-length bitmap) + rather than guessing -- a false rejection is worse than an + occasional no-op write.""" + if p != 'On': + return None + opts = rep.get('x.com.samsung.da.options') or [] + current = option_value(opts, 'Course') + courses = cycle_options(resources) + if not current or current not in courses: + return None + raw = option_value(opts, availability_field) + if raw is None: + return None + pairs = hex_pairs(raw) + if len(pairs) != len(courses): + return None + if pairs[courses.index(current)] != 'F0': + return f"{name} isn't available on the selected cycle." + return None + + return bool_option_switch( + key, name, icon, prefix, + entity_category='config', gate_on_presence=True, validate_fn=validate) WASHER_COURSE = Capability( @@ -201,8 +310,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevelCtrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'), + rep_fn=_dosing_level('DetergentLevelCtrl'), write_fn=_level_write('DetergentLevelCtrl')), SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', icon='mdi:water-opacity', @@ -211,8 +319,7 @@ WASHER_COURSE = Capability( options=_level_options('DetergentLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('DetergentLevel2Ctrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'), + rep_fn=_dosing_level('DetergentLevel2Ctrl'), write_fn=_level_write('DetergentLevel2Ctrl')), SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', translation_key='washer_dosing_quantity', @@ -220,8 +327,7 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevelCtrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevelCtrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'), + rep_fn=_dosing_level('SoftenerLevelCtrl'), write_fn=_level_write('SoftenerLevelCtrl')), SelectDesc(key='softener_concentration', name='Softener concentration', icon='mdi:flask-plus-outline', @@ -230,16 +336,21 @@ WASHER_COURSE = Capability( options=_level_options('SoftenerLevel2Ctrl'), exists_fn=lambda rep, resources: bool( _level_options('SoftenerLevel2Ctrl')(resources)), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'), + rep_fn=_dosing_level('SoftenerLevel2Ctrl'), write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_dosing_alarm_exists('DetergentAlarm'), + exists_fn=bool_option_exists('DetergentAlarm'), rep_fn=_dosing_low('DetergentAlarm')), BinarySensorDesc(key='softener_low', name='Softener low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_dosing_alarm_exists('SoftenerAlarm'), + exists_fn=bool_option_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), + _bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble', + 'BubbleSoak', 'BubbleSoakSet'), + _bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine', + 'PreWashSetting', 'PreWashAvailableSet'), + _bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine', + 'IntensiveSetting', 'IntensiveAvailableSet'), ), ) diff --git a/custom_components/localthings/registry/capability.py b/custom_components/localthings/registry/capability.py index 05921f3..2c6b2d3 100644 --- a/custom_components/localthings/registry/capability.py +++ b/custom_components/localthings/registry/capability.py @@ -15,6 +15,11 @@ class Capability: rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ()) href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override + # Rep field holding this instance's device-given name (e.g. an ice + # maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display + # name prefix in place of the href-derived instance label. Does not + # affect key_override/unique_id -- only what's shown in the UI. + name_field: Optional[str] = None match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool # Rare optional hook — only operational-state-style resources use this. on_observation: Optional[Callable[[dict, dict], None]] = None diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 61e27e3..891c2ae 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -27,6 +27,25 @@ class BoundEntity: desc: SamsungEntityDescription instance: str = '' key_override: Optional[str] = None + instance_name: Optional[str] = None + + +def _snake_to_title(s: str) -> str: + """'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's + _derive_name, which applies the same transform to a state key.""" + return s.replace('_', ' ').title() + + +def _instance_name(cap: Capability, rep: dict) -> Optional[str]: + """Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice") + for use as a display-name prefix, or None if the cap doesn't declare + one or the device didn't report it.""" + if not cap.name_field: + return None + raw = rep.get(cap.name_field) + if not isinstance(raw, str) or not raw: + return None + return _snake_to_title(raw) def instance_suffix(href: str) -> str: @@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str: return '' +def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str], + key_prefix: Optional[str] = None) -> list[BoundEntity]: + """Build one BoundEntity per entity on `cap`, sharing the instance/ + key-prefix/instance-name computed once by the caller.""" + return [ + BoundEntity(href=href, capability=cap, desc=desc, instance=inst, + key_override=f'{key_prefix}_{desc.key}' if key_prefix else None, + instance_name=inst_name) + for desc in cap.entities + ] + + def discover( resources: dict[str, dict], registry: dict[str, list[Capability]], @@ -58,9 +89,7 @@ def discover( if cap.match_fn is not None and not cap.match_fn(rep, resources): continue inst = instance_suffix(href) - for desc in cap.entities: - out.append(BoundEntity(href=href, capability=cap, - desc=desc, instance=inst)) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) matched = True if matched: @@ -78,10 +107,7 @@ def discover( # Auto-derive key prefix from href segments (skip digits and 'vs') src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] - for desc in cap.entities: - key_override = '_'.join(segs) + '_' + desc.key - out.append(BoundEntity(href=href, capability=cap, desc=desc, - instance=inst, key_override=key_override)) + out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) matched = True break diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 0072441..2e893ba 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -2,7 +2,9 @@ Frozen dataclasses so the future native HA component can consume them as EntityDescription subclasses unchanged. Read transforms live in value_fn; -presence gating in exists_fn; write logic in write_fn on command platforms. +presence gating in exists_fn; write logic in write_fn on command platforms; +pre-write rejection (surfaced to the user, not just logged) in validate_fn +where a description declares one. """ from __future__ import annotations @@ -10,6 +12,11 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] +# (payload, rep, resources) -> a human-readable rejection message, or None to +# allow the write. resources is the coordinator's full href->rep snapshot, for +# the same cross-resource lookups exists_fn needs (e.g. reading a sibling +# href's live option list). +ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] def _identity(v: Any) -> Any: @@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription): class SwitchDesc(SamsungEntityDescription): device_class: Optional[str] = None write_fn: WriteFn = None + validate_fn: ValidateFn = None @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 6f23953..b3e0026 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -36,13 +36,25 @@ "sp_ttype_wine_dessert": "Wine and Dessert" } }, + "pantry_zone_mode": { + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, "flex_zone_mode": { "state": { "cv_ttype_rf9000a_freeze": "Freeze", "cv_ttype_rf9000a_softfreeze": "Soft Freeze", "cv_ttype_rf9000a_meat_fish": "Meat/Fish", "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage" + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft Freezer" } }, "dishwasher_cycle": { @@ -83,7 +95,12 @@ "66": "Denim", "2d": "Silent Wash", "8f": "Intense Cold", - "96": "Less Microfiber" + "96": "Less Microfiber", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "1f": "Intense Cold" } }, "dryer_cycle": { @@ -125,6 +142,18 @@ "02": "2x", "03": "3x" } + }, + "washer_dry_level": { + "state": { + "none": "Off", + "cupboard": "Cupboard", + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6f23953..b3e0026 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -36,13 +36,25 @@ "sp_ttype_wine_dessert": "Wine and Dessert" } }, + "pantry_zone_mode": { + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, "flex_zone_mode": { "state": { "cv_ttype_rf9000a_freeze": "Freeze", "cv_ttype_rf9000a_softfreeze": "Soft Freeze", "cv_ttype_rf9000a_meat_fish": "Meat/Fish", "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage" + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft Freezer" } }, "dishwasher_cycle": { @@ -83,7 +95,12 @@ "66": "Denim", "2d": "Silent Wash", "8f": "Intense Cold", - "96": "Less Microfiber" + "96": "Less Microfiber", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "1f": "Intense Cold" } }, "dryer_cycle": { @@ -125,6 +142,18 @@ "02": "2x", "03": "3x" } + }, + "washer_dry_level": { + "state": { + "none": "Off", + "cupboard": "Cupboard", + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr" + } } }, "sensor": { diff --git a/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json new file mode 100644 index 0000000..9960b19 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json @@ -0,0 +1,595 @@ +{ + "meta": { + "note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).", + "issue": 17, + "model": "TP1X_DA-AC-RAC-01011", + "oneUiVersion": "7.0 Air conditioner" + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1784508715", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "Off", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "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": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C016102490F0F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_HEATPUMP_4.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1784704371", + "x.com.samsung.da.cumulativeSavedPower": "-1", + "x.com.samsung.da.cumulativePower": "75025", + "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": "+02:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "42", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "53" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000", + "x.com.samsung.da.serialNum": "**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": "02762A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02756C25082500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02669A24092600,02636A10001200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR4", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "Nano", + "NanoSleep", + "DryComfort" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.modes": [ + "Heat" + ], + "workingMode": "Heat", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Heat", + "Wind" + ], + "x.com.samsung.da.options": [ + "OptionCode_56378", + "Sleep_6", + "ArtificialWorking_Off", + "Off", + "OutdoorTemp_68", + "OutdoorConnection_Connected" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "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.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "39", + "displayCondition": "Enable" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000", + "x.com.samsung.da.id": "RAC", + "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": { + "x.com.samsung.da.cleanLevel": "1", + "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": [ + "1" + ] + }, + { + "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": "/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.current": "17.0", + "x.com.samsung.da.desired": "25.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "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", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "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" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-KR-TP1-25-ARXX00", + "versions": [ + "11260401" + ], + "visVersion": "260401" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Africa/Johannesburg", + "offset": "+02:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Normal", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/airconditioner_tp1x_rac_device.json b/tests/fixtures/airconditioner_tp1x_rac_device.json new file mode 100644 index 0000000..1eff68c --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_device.json @@ -0,0 +1,591 @@ +{ + "meta": { + "model": "TP1X_DA-AC-RAC-01001_0000", + "device_type": "airconditioner", + "source": "issue #38 diagnostics (scrubbed)", + "note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting." + }, + "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": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "AEB700A0012C01610248040F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_HEATPUMP_4.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ] + } + }, + { + "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": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-07-22T10:59:14Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1148", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/electriccurrent/vs/0", + "rep": { + "operation": "Off", + "modes": "3", + "supportedModes": [ + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1784725078", + "x.com.samsung.da.cumulativeSavedPower": "-1", + "x.com.samsung.da.cumulativePower": "160922", + "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": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "30", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000", + "x.com.samsung.da.serialNum": "**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": "02762A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02669A24092600,02636A10001200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR2", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Fan", + "Heat" + ], + "x.com.samsung.da.options": [ + "OptionCode_54328", + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "ExtendOptionCode_230024", + "UpdateAllow_NotAllowed", + "DurationOn_120", + "OutdoorTemp_92", + "CoolCapa_50", + "WarmCapa_60", + "Autoclean_Off", + "WelcomeCoolingState_Off", + "WelcomeHeatingState_Off", + "OutdoorConnection_Connected" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "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.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "Off", + "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", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-KR-TP1-25-ARXX00", + "versions": [ + "11260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "845210267841FFFFFFFF", + "versions": [ + "24092500", + "FFFFFFFF" + ], + "visVersion": "240925" + }, + { + "type": "Micom", + "modelId": "045210267841FFFFFFFF", + "versions": [ + "24092500", + "FFFFFFFF" + ], + "visVersion": "240925" + }, + { + "type": "Micom", + "modelId": "04521026694110263641", + "versions": [ + "24092600", + "10001200" + ], + "visVersion": "240926" + }, + { + "type": "Micom", + "modelId": "FGCAN3", + "versions": [ + "", + "" + ], + "visVersion": "999999" + }, + { + "type": "Micom", + "modelId": "845210266941FFFFFFFF", + "versions": [ + "24092600", + "FFFFFFFF" + ], + "visVersion": "240926" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "operationNumber": "9" + } + }, + { + "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": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00", + "x.com.samsung.da.id": "RAC", + "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": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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.current": "26.0", + "x.com.samsung.da.desired": "22.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Amman", + "offset": "+03:00", + "DST": "OFF" + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "All", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + } + ] +} diff --git a/tests/fixtures/airconditioner_tp2x_rac_20k_device.json b/tests/fixtures/airconditioner_tp2x_rac_20k_device.json new file mode 100644 index 0000000..1f4baaf --- /dev/null +++ b/tests/fixtures/airconditioner_tp2x_rac_20k_device.json @@ -0,0 +1,380 @@ +{ + "meta": { + "model": "TP2X_RAC_20K", + "device_type": "airconditioner", + "source": "issue #37 diagnostics (scrubbed)", + "note": "Older/simpler RAC board reporting no oneUiVersion -- resolved via the '_RAC_' modelNum token fallback in for_device_by_model (unlike issue #38's TP1X board, which self-reports oneUiVersion)." + }, + "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-22T12:42:46", + "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-22T12:42:46", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C01610249040F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "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_HEATPUMP_3.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0.000000", + "x.com.samsung.da.cumulativePower": "717060", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativePowerType": "total", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "39", + "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/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "43" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_RAC_20K|10229641|60010523001511014600003200800000", + "x.com.samsung.da.description": "TP2X_RAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "010", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "02273A260324", + "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": "102296A23012700", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "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": [ + "Auto", + "Cool", + "Dry", + "Wind", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "MotionState_Normal", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_85", + "CoolCapa_35", + "WarmCapa_35", + "Light_Off", + "Volume_100", + "StopAutoClean_Idle", + "Autoclean_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "MotionMode_PowerSave", + "OptionCode_56376", + "ExtendOptionCode_230157", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_50", + "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": "TP2X_RAC_20K", + "versions": [ + "11260324" + ], + "visVersion": "260324" + }, + { + "type": "Micom", + "modelId": "045210229641FFFFFFFF", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "04521022954110229941", + "versions": [ + "21092700", + "10000300" + ], + "visVersion": "210927" + } + ] + } + }, + { + "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": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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": "22.0", + "x.com.samsung.da.current": "26.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Amman", + "offset": "+03:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Left_And_Right", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "2", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index afe8a6d..68abd0f 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -8,6 +8,7 @@ "climate", "diagnosis_status", "energy_kwh", + "energy_saved_kwh", "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 new file mode 100644 index 0000000..b9643bf --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json new file mode 100644 index 0000000..b7ce7c3 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "current_limit_enabled", + "current_limit_level", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "mute_once", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json new file mode 100644 index 0000000..4b50703 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -0,0 +1,13 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "energy_kwh", + "firmware_update", + "mute_once", + "power_watts" + ] +} diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 21ae55f..2a50531 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -8,6 +8,7 @@ "delay_start_hours", "diagnosis_status", "energy_kwh", + "energy_saved_kwh", "finish_time", "firmware_update", "heated_dry", @@ -39,6 +40,7 @@ "samsung_dishwasher_diagnosis_start", "samsung_dishwasher_diagnosis_status", "samsung_dishwasher_energy_kwh", + "samsung_dishwasher_energy_saved_kwh", "samsung_dishwasher_finish_time", "samsung_dishwasher_firmware_update", "samsung_dishwasher_heated_dry", diff --git a/tests/fixtures/golden/range_hood.json b/tests/fixtures/golden/range_hood.json index 411cf7f..2753cb3 100644 --- a/tests/fixtures/golden/range_hood.json +++ b/tests/fixtures/golden/range_hood.json @@ -7,6 +7,7 @@ "automatic_ventilation_state", "clean_level", "energy_kwh", + "energy_saved_kwh", "fan", "firmware_update", "hood_filter_capacity", diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index 96e4ac2..0945c9a 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -1,5 +1,6 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", "auto_door_opener", "autofill", @@ -16,6 +17,9 @@ "door_cooler_open", "door_freezer_open", "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", "filter_status", "filter_usage", "firmware_update", @@ -31,15 +35,18 @@ "icemaker_two_type", "night_end", "night_start", + "power_energy_kwh", "power_watts", "rapid_freezing", "rapid_fridge", "sabbath_mode", + "selfcheck_error", "selfcheck_result", "selfcheck_status", "welcome_lighting" ], "discovery_unique_ids": [ + "samsung_refrigerator_ai_energy_level", "samsung_refrigerator_alarm_code", "samsung_refrigerator_auto_door_opener", "samsung_refrigerator_autofill", @@ -56,6 +63,9 @@ "samsung_refrigerator_door_cooler_open", "samsung_refrigerator_door_freezer_open", "samsung_refrigerator_energy_kwh", + "samsung_refrigerator_energy_last_month_kwh", + "samsung_refrigerator_energy_saved_kwh", + "samsung_refrigerator_energy_this_month_kwh", "samsung_refrigerator_filter_status", "samsung_refrigerator_filter_usage", "samsung_refrigerator_firmware_update", @@ -71,10 +81,12 @@ "samsung_refrigerator_icemaker_two_type", "samsung_refrigerator_night_end", "samsung_refrigerator_night_start", + "samsung_refrigerator_power_energy_kwh", "samsung_refrigerator_power_watts", "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", "samsung_refrigerator_sabbath_mode", + "samsung_refrigerator_selfcheck_error", "samsung_refrigerator_selfcheck_result", "samsung_refrigerator_selfcheck_start", "samsung_refrigerator_selfcheck_status", diff --git a/tests/fixtures/golden/refrigerator_artik051_ref_17k.json b/tests/fixtures/golden/refrigerator_artik051_ref_17k.json new file mode 100644 index 0000000..0a69dbb --- /dev/null +++ b/tests/fixtures/golden/refrigerator_artik051_ref_17k.json @@ -0,0 +1,28 @@ +{ + "state_keys": [ + "alarm_code", + "autofill", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_cooler_open", + "door_freezer_open", + "energy_last_month_kwh", + "energy_this_month_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "pantry_zone_mode", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json new file mode 100644 index 0000000..63b71e8 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_us.json @@ -0,0 +1,36 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "autofill", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "door_cooler_open", + "door_cvroom_open", + "door_freezer_open", + "energy_kwh", + "energy_last_month_kwh", + "energy_saved_kwh", + "energy_this_month_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "flex_zone_mode", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "icemaker_two_enabled", + "icemaker_two_making_status", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json b/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json new file mode 100644 index 0000000..ba43462 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp2x_ref_20k.json @@ -0,0 +1,29 @@ +{ + "state_keys": [ + "alarm_code", + "cooler_setpoint", + "cooler_temperature", + "defrost_active", + "defrost_delay", + "diagnosis_status", + "door_cooler_open", + "door_cvroom_open", + "door_freezer_open", + "energy_kwh", + "energy_last_month_kwh", + "energy_this_month_kwh", + "filter_status", + "filter_usage", + "firmware_update", + "flex_zone_mode", + "freezer_setpoint", + "freezer_temperature", + "icemaker_one_enabled", + "icemaker_one_making_status", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "sabbath_mode" + ] +} diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index e8beedc..b48cd1b 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,6 +1,8 @@ { "state_keys": [ + "ai_energy_level", "alarm_code", + "bubble_soak", "buzzer_sound", "child_lock", "cycle", @@ -11,11 +13,14 @@ "drum_clean_cycles_remaining", "drum_clean_last_cleaned", "energy_kwh", + "energy_saved_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json new file mode 100644 index 0000000..d2bab38 --- /dev/null +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -0,0 +1,29 @@ +{ + "state_keys": [ + "alarm_code", + "bubble_soak", + "child_lock", + "cycle", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "dry_level", + "energy_kwh", + "finish_time", + "firmware_update", + "intensive", + "job_beginning_status", + "machine_state", + "power_switch", + "pre_wash", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "softener_low", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json new file mode 100644 index 0000000..6db1e00 --- /dev/null +++ b/tests/fixtures/golden/washer_flexwash.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cycle_active", + "delay_start_hours", + "detergent_low", + "diagnosis_status", + "drum_clean_cycles_remaining", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "rinse_cycles", + "spin_speed", + "wash_temperature" + ] +} diff --git a/tests/fixtures/refrigerator_artik051_ref_17k_device.json b/tests/fixtures/refrigerator_artik051_ref_17k_device.json new file mode 100644 index 0000000..49e6069 --- /dev/null +++ b/tests/fixtures/refrigerator_artik051_ref_17k_device.json @@ -0,0 +1,351 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-21", + "x.com.samsung.da.current": "-21", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "2", + "x.com.samsung.da.current": "2", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -21.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "temperature": -21.0 + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 2.0 + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "temperature": 2.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "86000", + "x.com.samsung.da.thismonthlyConsumption": "64026" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": [ + "WATERFILTER_ENABLE" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": [ + "WATERFILTER_ENABLE" + ] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "DEFROST_BLOCK_ON", + "DEFROST_BLOCK_OFF" + ], + "x.com.samsung.da.modes": [ + "DEFROST_BLOCK_OFF" + ] + } + }, + { + "href": "/sabbath/vs/0", + "rep": { + "x.com.samsung.da.sabbathMode": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_REF_17K|00117141|0002074E051311200103000010000000", + "x.com.samsung.da.description": "ARTIK051_REF_17K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A201124", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2003030C,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-04:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "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.id": "1", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "US", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/defrost/delay/0", + "rep": { + "value": false + } + }, + { + "href": "/defrost/delay/vs/0", + "rep": { + "x.com.samsung.da.delayDefrost": "Off" + } + }, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + { + "href": "/icemaker/status/0", + "rep": { + "status": "On" + } + }, + { + "href": "/icemaker/status/vs/0", + "rep": { + "x.com.samsung.da.iceMaker": "On" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "00:00:00", + "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": "00:00:00", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "replace", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/autofill/vs/0", + "rep": { + "x.com.samsung.da.autofill": "Off" + } + }, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_MAKER", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + }, + { + "href": "/status/pantry/one/vs/0", + "rep": { + "x.com.samsung.da.controllable": "true", + "x.com.samsung.da.name": "COOL_SELECT_ZONE", + "x.com.samsung.da.mode": "FDR_DRINKS", + "x.com.samsung.da.supportedOptions": [ + "FDR_WINE", + "FDR_DELI", + "FDR_DRINKS" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_device.json b/tests/fixtures/refrigerator_device.json index abf85c5..ca6d750 100644 --- a/tests/fixtures/refrigerator_device.json +++ b/tests/fixtures/refrigerator_device.json @@ -279,7 +279,8 @@ "rep": { "aiLevel": "1", "supportedAiLevel": [ - "1" + "1", + "2" ], "href": "/energy/ailevel/vs/0" } diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json new file mode 100644 index 0000000..13c824e --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_us_device.json @@ -0,0 +1,253 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.maximum": "5", + "x.com.samsung.da.minimum": "-8", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "39", + "x.com.samsung.da.current": "39", + "x.com.samsung.da.maximum": "44", + "x.com.samsung.da.minimum": "34", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + {"href": "/temperature/current/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}}, + {"href": "/temperature/desired/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}}, + {"href": "/temperature/current/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}}, + {"href": "/temperature/desired/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}}, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": ["DA_ERROR_NONE"], + "x.com.samsung.da.supportedActions": ["Start"] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "63", + "x.com.samsung.da.cumulativePower": "1831899", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "3", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "52000", + "x.com.samsung.da.thismonthlyConsumption": "38900", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT", + "CV_FDR_SOFT_FREEZER" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": ["DEFROST_BLOCK_ON", "DEFROST_BLOCK_OFF"], + "x.com.samsung.da.modes": ["DEFROST_BLOCK_OFF"] + } + }, + {"href": "/sabbath/vs/0", "rep": {"x.com.samsung.da.sabbathMode": "Off"}}, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00148241|00020850031611204103000021010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "512", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "250420", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "23071923, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + {"href": "/file/information/vs/0", "rep": {"x.com.samsung.timeoffset": "-05:00", "x.com.samsung.supprtedtype": 1}}, + { + "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.id": "1", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"}, + {"x.com.samsung.da.id": "2", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"} + ] + } + }, + {"href": "/door/freezer/0", "rep": {"openState": "Close"}}, + {"href": "/door/cooler/0", "rep": {"openState": "Close"}}, + {"href": "/door/cvroom/0", "rep": {"openState": "Close"}}, + {"href": "/configuration/vs/0", "rep": {"x.com.samsung.da.countryCode": "", "x.com.samsung.da.region": ""}}, + {"href": "/defrost/delay/0", "rep": {"value": false}}, + {"href": "/defrost/delay/vs/0", "rep": {"x.com.samsung.da.delayDefrost": "Off"}}, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + {"href": "/icemaker/status/0", "rep": {"status": "On"}}, + {"href": "/icemaker/status/vs/0", "rep": {"x.com.samsung.da.iceMaker": "On"}}, + {"href": "/refrigeration/0", "rep": {"defrost": false, "rapidFreeze": false, "rapidCool": false}}, + { + "href": "/refrigeration/vs/0", + "rep": {"x.com.samsung.da.rapidFridge": "Off", "x.com.samsung.da.rapidFreezing": "Off"} + }, + {"href": "/drlc/0", "rep": {"DRLevel": 0, "override": false}}, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + {"href": "/bespoke/vs/0", "rep": {"x.com.samsung.da.BespokeProduct": "On"}}, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + {"type": "WIFI", "modelId": "A-RFWW-TP1-22-REV1", "versions": ["20250420"], "visVersion": "250420"}, + {"type": "Micom", "modelId": "024100148241FFFFFFFF", "versions": ["23071923", "FFFFFFFF"], "visVersion": "230719"} + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "31", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterResetType": ["replaceable"] + } + }, + {"href": "/autofill/vs/0", "rep": {"x.com.samsung.da.autofill": "Off"}}, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "CUBED_ICE", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + }, + { + "href": "/icemaker/two/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_BITES", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "On" + } + }, + {"href": "/energy/ailevel/vs/0", "rep": {"aiLevel": "1", "supportedAiLevel": ["1"]}}, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": ["ble_ocf", null], + "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": "/timezone/vs/0", "rep": {"timezoneid": "America/Chicago", "offset": "-05:00", "DST": "ON"}}, + {"href": "/wirelessinfo/vs/0", "rep": {"macaddressWiFi": "REDACTED", "macaddressBLE": "REDACTED"}}, + {"href": "/quickcontrol/info/vs/0", "rep": {"supportedVersion": "1.0"}} + ] +} diff --git a/tests/fixtures/refrigerator_tp2x_ref_20k_device.json b/tests/fixtures/refrigerator_tp2x_ref_20k_device.json new file mode 100644 index 0000000..be906eb --- /dev/null +++ b/tests/fixtures/refrigerator_tp2x_ref_20k_device.json @@ -0,0 +1,351 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.maximum": "5", + "x.com.samsung.da.minimum": "-8", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "37", + "x.com.samsung.da.current": "37", + "x.com.samsung.da.maximum": "44", + "x.com.samsung.da.minimum": "34", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -8.0, + 5.0 + ], + "units": "F", + "temperature": 0.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -8.0, + 5.0 + ], + "units": "F", + "temperature": 0.0 + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "range": [ + 34.0, + 44.0 + ], + "units": "F", + "temperature": 37.0 + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "range": [ + 34.0, + 44.0 + ], + "units": "F", + "temperature": 37.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "288048", + "x.com.samsung.da.cumulativePower": "1797027", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "115", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.monthlyConsumption": "55400", + "x.com.samsung.da.thismonthlyConsumption": "39737" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "x.com.samsung.da.modes": [ + "CVN_CONVERTIBLE_ZONE", + "CV_FDR_BEVERAGE", + "WATERFILTER_DISABLE" + ], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2", + "SUPPORT_SABBATH_CONTROL" + ], + "modes": [ + "CVN_CONVERTIBLE_ZONE", + "CV_FDR_BEVERAGE", + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/defrost/block/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "DEFROST_BLOCK_ON", + "DEFROST_BLOCK_OFF" + ], + "x.com.samsung.da.modes": [ + "DEFROST_BLOCK_OFF" + ] + } + }, + { + "href": "/sabbath/vs/0", + "rep": { + "x.com.samsung.da.sabbathMode": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_REF_20K|00113141|0002033F051311200103000010000000", + "x.com.samsung.da.description": "TP2X_REF_20K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A220110", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2011030E,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "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.id": "1", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/door/cvroom/0", + "rep": { + "openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "US", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/defrost/delay/0", + "rep": { + "value": false + } + }, + { + "href": "/defrost/delay/vs/0", + "rep": { + "x.com.samsung.da.delayDefrost": "Off" + } + }, + { + "href": "/defrost/reservation/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Summer Season", + "x.com.samsung.da.startTime": "0000-05-01T15:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-10-31T00:00:00" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Winter Season", + "x.com.samsung.da.startTime": "0000-11-01T06:00:00", + "x.com.samsung.da.period": "04:00:00", + "x.com.samsung.da.endTime": "0000-04-30T00:00:00" + } + ] + } + }, + { + "href": "/icemaker/status/0", + "rep": { + "status": "On" + } + }, + { + "href": "/icemaker/status/vs/0", + "rep": { + "x.com.samsung.da.iceMaker": "On" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_MAKER", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.type": "toggle", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceType.desired": "NORMAL" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/washer_device.json b/tests/fixtures/washer_device.json index 2f2eece..b76d18f 100644 --- a/tests/fixtures/washer_device.json +++ b/tests/fixtures/washer_device.json @@ -44,6 +44,21 @@ "href": "/energy/consumption/0", "rep": {} }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "0", + "supportedAiLevel": [ + "1" + ], + "energySavingInfo": [ + { + "aiLevel": "1", + "maxEnergySavingRate": "70" + } + ] + } + }, { "href": "/course/vs/0", "rep": { diff --git a/tests/fixtures/washer_dryer_combo_device.json b/tests/fixtures/washer_dryer_combo_device.json new file mode 100644 index 0000000..698aa12 --- /dev/null +++ b/tests/fixtures/washer_dryer_combo_device.json @@ -0,0 +1,325 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "1210200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784671200", + "x.com.samsung.da.cumulativeDateUTC": "1784667600" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0167", + "UpdateAllow_NotAllowed", + "Course_1C", + "AiOption_On", + "LaundryOutTime_0", + "AddWashSet_0", + "AddWashAvailable_7", + "AddWashIndicator_On", + "SeamlessControl_Enable", + "KidsLockBypass_On", + "WashingTimes_25", + "DrumCleanProposal_40", + "DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07", + "DetergentOnce_1", + "DetergentLeft_0", + "DetergentBase_5", + "DetergentAlarm_Off", + "DetergentType_2", + "DetergentTotal_49", + "SoftenerOnce_0", + "SoftenerLeft_0", + "SoftenerBase_0", + "SoftenerAlarm_Off", + "SoftenerType_0", + "SoftenerTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_159", + "BubbleSoak_Off", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_420BF48204D4A204F5", + "SendToDevice_Off", + "GMT_02", + "LaundryPlannerMicomSetTime_1704", + "PreWashSetting_Off", + "IntensiveSetting_Off", + "BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000", + "EnergyLevelSet_05030401040104020404020503020202030301010505050405", + "MostUsed_1C8410923FA67FB01F0000000000", + "PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000", + "IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000", + "GeoFenceAlarm", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "41C8410923FA67FB01F1B847E923FA67FB01F1E811E933FA53FB01F1D841E923FA67FB01F1F8102923FA57FB01F25843E923FA57FB01F26841E920FA207B01F33857E933FA67FB00024841E930FA30FB00032833E923FA37FB00020857E943FA67FB01F22841E920FA30FB00023831E930FA57FB01F2F831E933FA30FB00021831E943FA57FB0002D841E923FA30FB00030843E923FA67FB000278000913FA67FB01F2880009000A67EB01F368410923FA640B11E3880009000A000B11E3980009000A000B11E3780009000A000B0002985209204A520B000" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "02:39:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Weightsensing", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WW6600R/DC92-02617A_5049", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A250414(F546)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20260041200911390FFFFF202617412101203104FFFF(01672026004120261741_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20091139,21012031", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2025984120072205042FFFFFFFFFFFFFFFFFFFFFFFFE(016720259841FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20072205,FFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "40", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "20", + "30", + "40", + "60", + "90" + ], + "x.com.samsung.da.spinLevel": "1400", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "x.com.samsung.da.dryLevel": "None", + "x.com.samsung.da.supportedDryLevel": [ + "None", + "Cupboard", + "30", + "60", + "90", + "120", + "180", + "240" + ], + "x.com.samsung.da.rinseCycles": "2", + "x.com.samsung.da.supportedRinseCycles": [ + "0", + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_02_Course_1C", + "x.com.samsung.da.st.courseTable": "Table_02" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR", + "en_US" + ], + "x.com.samsung.da.setLanguage": "en_GB" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_1C3026241E293633281D27211B202F2223323825392D1F37", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_1C353A29" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", + "x.com.samsung.da.aiCourse": "true_formobile", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": { + "x.com.samsung.da.currentStatus": "None" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "GB" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/washer_flexwash_device.json b/tests/fixtures/washer_flexwash_device.json new file mode 100644 index 0000000..61aec09 --- /dev/null +++ b/tests/fixtures/washer_flexwash_device.json @@ -0,0 +1,253 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "199200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784631600", + "x.com.samsung.da.cumulativeDateUTC": "1784646000" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0146", + "UpdateAllow_NotAllowed", + "Course_A5", + "LaundryOutTime_0", + "AddGarmentIndicator_Off", + "SteamWash_Enable", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_14", + "DrumCleanProposal_40", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "SpecialFunction_4", + "AvailableDelayTime_124", + "LaundryPlannerUserSetTime_0", + "ProgressTimeSet_4210E08209F6A2021C", + "EnergyLevelSet_0502040502030302030105", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2023-08-11T19:17:11|2023-11-02T03:23:13|2023-12-22T22:35:54|2024-04-08T01:27:32|2024-04-22T01:07:26|2024-10-02T19:16:45|2025-02-03T02:01:33|2025-06-16T17:36:13|2026-01-04T18:34:46|2026-05-26T02:33:16", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "301833EA41BC20E9C843EA41FC30EA98520A41FC20EA2810EA207C20EAA831EA41FC20EA5830EA20FC20E9F831EA41FC10E9E830EA21FC20E9D8000A41FC000B18520A410C000" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "02:04: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", + "Wash", + "Rinse", + "Spin", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Wash", + "Rinse", + "Spin", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:04:00", + "progressPercentage": "1" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A230708(E257)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18030902,18090603", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-04:00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.waterTemperature": "Warm", + "x.com.samsung.da.supportedWaterTemperature": [ + "None", + "Cold", + "Cool", + "Warm", + "Hot", + "ExtraHot" + ], + "x.com.samsung.da.spinLevel": "Low", + "x.com.samsung.da.supportedSpinLevel": [ + "RinseHold", + "NoSpin", + "Low", + "Medium", + "High" + ], + "x.com.samsung.da.soilLevel": "Heavy", + "x.com.samsung.da.supportedSoilLevel": [ + "None", + "Light", + "Normal", + "Heavy" + ], + "x.com.samsung.da.rinseCycles": "3" + } + }, + { + "href": "/st/washercourse/vs/0", + "rep": { + "x.com.samsung.da.st.washerMode": "Table_00_Course_A5", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/setting/vs/0", + "rep": {} + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": true + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "US" + } + } + ] +} \ No newline at end of file diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index 4674f3c..a5a5e62 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -3,8 +3,10 @@ from __future__ import annotations import json from pathlib import Path +from typing import Any from unittest.mock import patch +import cbor2 import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -150,12 +152,26 @@ class FakeObserveSession: self.subscribed: list[str] = [] self.fail_hrefs: set[str] = set() self.closed = False + # When set to a rep dict, subscribe() immediately delivers that rep + # as an OBSERVE notification for the href — what a real device does + # when it answers a subscription with the current representation. + # `try_enter_observe_mode` clears its notified set before it + # subscribes, so this is the only way to deliver a notify that + # reliably counts: it lands synchronously via on_notification, + # after that clear and before the post-sleep fraction check — a + # notify raced in from another thread can be wiped by the clear + # (or arrive after the check) depending on scheduling. Set to + # None to model a device that answers subscriptions but never + # notifies. + self.notify_on_subscribe: dict[str, Any] | None = None def subscribe(self, path_segs): href = '/' + '/'.join(path_segs) if href in self.fail_hrefs: raise ConnectionError("subscribe failed") self.subscribed.append(href) + if self.notify_on_subscribe is not None and self.on_notification is not None: + self.on_notification(href, cbor2.dumps(self.notify_on_subscribe)) return b'\x01' def refresh_observes(self, paths): diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 3faa9e1..1cff426 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -1,8 +1,6 @@ """Tests for the LocalThingsCoordinator.""" from __future__ import annotations -import threading -import time from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -285,21 +283,18 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - # try_enter_observe_mode clears prior notifications as soon as it - # starts, so notifies must land *during* its grace-period sleep (same - # pattern test_observe.py uses), not before the call. - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + # try_enter_observe_mode clears prior notifications before it + # subscribes, then checks the notified fraction after the grace-period + # sleep — a notify counts as long as it arrives in that window, not + # specifically during the sleep itself. The fake session delivers one + # synchronously from subscribe() for every href it subscribes to (see + # FakeObserveSession.notify_on_subscribe in conftest.py), which lands + # it in that window by construction rather than by timing. + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator._observe.mode == MODE_OBSERVE @@ -325,18 +320,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( # Get the coordinator into observe mode the same way # test_enters_observe_mode_when_hot_warm_hrefs_notify does. - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -349,12 +337,12 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( # Simulate the existing "poll failed, reconnecting" branch: _poll_once # fails once (triggering the reconnect/backoff path), then succeeds. - # No fresh notifies are supplied for the immediate resubscribe attempt - # this now triggers, so its grace period (shortened for tests by the - # `_fast_coordinator_timers` autouse fixture — see conftest.py) times - # out and mode stays 'poll' — this test only asserts the tear-down half of - # the fix; test_reconnect_from_observe_mode_resubscribes_immediately - # covers the successful-immediate-resubscribe half. + # The device stops notifying on subscribe, so the immediate resubscribe + # attempt this now triggers gets nothing back and mode stays 'poll' — + # this test only asserts the tear-down half of the fix; + # test_reconnect_from_observe_mode_resubscribes_immediately covers the + # successful-immediate-resubscribe half. + fake.notify_on_subscribe = None with ( patch( 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', @@ -387,18 +375,11 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -433,18 +414,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -466,7 +440,10 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( assert coordinator.observe_mode == MODE_OBSERVE # The final consecutive timeout crosses the limit and triggers the - # existing reconnect path, which then succeeds and downgrades. + # existing reconnect path, which then succeeds and downgrades. The + # device stops notifying on subscribe, so the resubscribe attempt that + # follows the reconnect finds nothing and mode stays 'poll'. + fake.notify_on_subscribe = None with ( patch( 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', @@ -498,18 +475,11 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify_during_grace_period(): - time.sleep(0.005) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - - notifier = threading.Thread(target=_notify_during_grace_period, daemon=True) - notifier.start() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -551,21 +521,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -588,10 +548,8 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( new=AsyncMock(), ), ): - notifier = _notify() await coordinator.async_request_refresh() await hass.async_block_till_done() - notifier.join() assert coordinator.observe_mode == MODE_OBSERVE @@ -612,21 +570,11 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -662,21 +610,11 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - def _notify(delay: float = 0.005) -> threading.Thread: - def _run(): - time.sleep(delay) - for href in hrefs: - fake.on_notification(href, cbor2.dumps({'notified': True})) - t = threading.Thread(target=_run, daemon=True) - t.start() - return t - - notifier = _notify() + fake.notify_on_subscribe = {'notified': True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, fake, hrefs, 0.02, 0.8, ) - notifier.join() assert entered is True assert coordinator.observe_mode == MODE_OBSERVE diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index 214d7ff..13fde6b 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -2,10 +2,12 @@ from __future__ import annotations import json +import threading from pathlib import Path from homeassistant.core import HomeAssistant +from custom_components.localthings import diagnostics as diagnostics_mod from custom_components.localthings.const import DOMAIN from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics from custom_components.localthings.registry.redact import REDACTED @@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields( assert diag['observe_fallback_hrefs'] == [] assert 'observe_last_mode_change' in diag assert diag['observe_href_freshness_s'] == {} + + +async def test_dependency_version_read_off_the_event_loop( + hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch +) -> None: + """importlib.metadata.version() does blocking disk I/O, so it must run in + the executor, not on the event loop (issue #9's logs flagged it).""" + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + loop_thread_id = threading.get_ident() # this coroutine runs on the loop + seen: dict[str, int] = {} + real_pkg_version = diagnostics_mod.pkg_version + + def _spy(name: str) -> str: + seen['thread_id'] = threading.get_ident() + return real_pkg_version(name) + + monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy) + + diag = await async_get_config_entry_diagnostics(hass, mock_entry) + + assert diag['smartthings_local_version'] + assert seen['thread_id'] != loop_thread_id diff --git a/tests/localthings/test_observe.py b/tests/localthings/test_observe.py index a7e1aff..7d3fe9a 100644 --- a/tests/localthings/test_observe.py +++ b/tests/localthings/test_observe.py @@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling(): assert mgr.cache.get('/oven/vs/0') == {'a': 1} +def test_apply_merges_partial_update_onto_prior_rep(): + """Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify + (and even a later sweep entry for that href) can carry only `modes`, + omitting `supportedOptions` entirely. A full replace would wipe + `supportedOptions` from the cache the moment that partial update + arrives, even though the device's supported options didn't change -- + which is exactly what made the flex-zone select disappear.""" + mgr = _manager() + full = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'], + 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'], + } + mgr.apply('/mode/vs/0', full, source='poll') + + partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']} + mgr.apply('/mode/vs/0', partial, source='observe') + + cached = mgr.cache.get('/mode/vs/0') + assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE'] + assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT'] + + def test_apply_drops_update_during_settle_window(): mgr = _manager() mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed') diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index efcdbce..676f026 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -6,7 +6,7 @@ climate entity itself lives in climate.py (imports homeassistant) and is not importable here -- consistent with how the other HA platform files are untested. """ from custom_components.localthings.registry.adapter import flatten -from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.by_type import for_device, for_device_by_model from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc @@ -23,6 +23,22 @@ def _ac(): return reg, resources +def _resolve(name): + """Mirror the coordinator's detection order: oneUiVersion first, modelNum + fallback second (needed for issue #37's board, which reports neither + oneUiVersion nor a '_PRAC_' modelNum token).""" + resources = _load_device(name) + otn = resources.get('/otninformation/vs/0', {}) + one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '') + info = resources['/information/vs/0'] + reg = for_device(one_ui) if one_ui else None + if reg is None: + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + def _bound(): reg, resources = _ac() return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -106,3 +122,114 @@ def test_climate_consumed_hrefs_declared_as_coverage(): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href + + +# --------------------------------------------------------------------------- +# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a +# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard +# /temperature/current+desired pair (temperature lives on the vendor +# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and +# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36). +# --------------------------------------------------------------------------- + +def _ac_tp1x(): + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion'] + return for_device(one_ui), resources + + +def test_tp1x_resolves_to_airconditioner_registry(): + reg, _ = _ac_tp1x() + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_no_unbound_hrefs(): + """Every resource in the TP1X dump binds or is covered -- including + /temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from + the ARTIK051 dump. Clears the coverage-gap repair.""" + reg, resources = _ac_tp1x() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_display_light_switch_present(): + """/light/vs/0 (mode On/Off) surfaces as the display-light switch.""" + reg, resources = _ac_tp1x() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state.get('display_light') is True # device reports mode == 'On' + + +def test_tp1x_vendor_temperature_and_light_covered(): + """The vendor temperature resource (read by the climate entity) and the + display-light resource both resolve in the registry -- no gap.""" + reg, _ = _ac_tp1x() + assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0' + assert reg.capabilities.get('/light/vs/0'), '/light/vs/0' + + +def test_tp1x_climate_entity_is_bound(): + """The composite climate entity still binds the primary /mode/vs/0.""" + reg, resources = _ac_tp1x() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] + assert len(climate) == 1 and climate[0].href == '/mode/vs/0' + + +def test_tp2x_rac_20k_model_resolves_via_model_fallback(): + """TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token + -- resolved via the '_RAC_' modelNum fallback added for this device.""" + reg, _ = _resolve('airconditioner_tp2x_rac_20k') + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp2x_rac_20k_no_unbound_hrefs(): + reg, resources = _resolve('airconditioner_tp2x_rac_20k') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_rac_model_resolves_via_one_ui_version(): + """TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion + '7.0 Air conditioner' -- resolved via for_device(), not the modelNum + fallback.""" + reg, _ = _resolve('airconditioner_tp1x_rac') + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_rac_no_unbound_hrefs(): + reg, resources = _resolve('airconditioner_tp1x_rac') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_rac_expected_state_keys_present(): + reg, resources = _resolve('airconditioner_tp1x_rac') + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status', + 'selfcheck_result', 'current_limit_enabled', 'current_limit_level'): + assert key in state, key + + +def test_mute_once_write_target(): + write = airconditioner.MUTE_ONCE.entities[0].write_fn + assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'}) + assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'}) + + +def test_display_light_write_target(): + write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn + assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'}) + assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'}) + + +def test_current_limit_is_read_only(): + """Meaning/write contract for the current-limit levels isn't confirmed + from the dump alone -- exposed as read-only diagnostic sensors rather + than a guessed writable control.""" + for desc in airconditioner.CURRENT_LIMIT.entities: + assert getattr(desc, 'write_fn', None) is None diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 6c62890..e709c93 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -209,6 +209,26 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'range_hood' + def test_washer_wf_prefix(self): + """US front-load washers use the WF consumer-model prefix.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000', + 'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE', + ) + assert reg is not None + assert reg.name == 'washer' + + def test_washer_wv_prefix(self): + """FlexWash twin washers use the WV consumer-model prefix -- issue #19.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000', + 'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014', + ) + assert reg is not None + assert reg.name == 'washer' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') diff --git a/tests/test_climate_temperature_fallback.py b/tests/test_climate_temperature_fallback.py new file mode 100644 index 0000000..41fb3ca --- /dev/null +++ b/tests/test_climate_temperature_fallback.py @@ -0,0 +1,38 @@ +"""Tests for the /temperatures/vs/0 fallback helper in climate.py (PR #36). + +`_temps_vs_item` is a pure module-level function (plain dict in, plain dict +out) with no coordinator/entity dependency, so -- like select.py's `_display` +in test_select_display.py -- it's testable directly even though the +LocalThingsClimate entity itself needs a coordinator/hass fixture to +exercise. This covers the actual fallback-selection logic that the +registry-level "no unbound hrefs" tests in test_airconditioner_capabilities.py +don't reach: TP1X-class firmware has no OCF-standard +/temperature/current+desired pair, so current_temperature/target_temperature +would silently be None without this fallback. +""" +from custom_components.localthings.climate import _temps_vs_item + + +def test_temps_vs_item_returns_first_item(): + rep = { + 'x.com.samsung.da.items': [ + {'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'}, + ], + } + assert _temps_vs_item(rep) == { + 'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0', + } + + +def test_temps_vs_item_empty_when_items_missing(): + """OCF-pair devices don't populate /temperatures/vs/0's items at all -- + callers must fall through to the OCF hrefs cleanly, not raise.""" + assert _temps_vs_item({}) == {} + + +def test_temps_vs_item_empty_when_items_is_empty_list(): + assert _temps_vs_item({'x.com.samsung.da.items': []}) == {} + + +def test_temps_vs_item_empty_when_items_entry_is_not_a_dict(): + assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {} diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index f535d54..fdd1150 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -130,3 +130,202 @@ class TestEnergyMeter: phantom power sensor (the exists_fn replaces the field-presence gate).""" pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False + + +# --------------------------------------------------------------------------- +# AI energy-saving level. '0' is off; supportedAiLevel lists the additional +# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer) +# is really a binary toggle, so it's exposed as a switch instead of a +# one-choice select; multiple entries get a select with '0' synthesized back +# in as the explicit off option (supportedAiLevel never lists '0' itself, but +# it's a real, observed value of aiLevel). +# --------------------------------------------------------------------------- + + +def _ai_energy_level_desc(cls_name): + return next(e for e in common.AI_ENERGY_LEVEL.entities + if e.__class__.__name__ == cls_name) + + +class TestAiEnergyLevelSwitch: + def _desc(self): + return _ai_energy_level_desc('SwitchDesc') + + def test_href(self): + assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' + + def test_shown_only_with_single_supported_level(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False + + def test_hidden_when_supported_level_is_non_list_scalar(self): + """A stray scalar (e.g. a string) must not be len()-checked as if it + were a list -- a 2-char string would otherwise wrongly pass `== 1` + style checks.""" + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False + + def test_hidden_when_supported_level_missing(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1'}, {}) is False + + def test_hidden_when_supported_level_empty_list(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False + + def test_hidden_on_empty_stub_rep(self): + desc = self._desc() + assert desc.exists_fn({}, {}) is False + + def test_value_fn(self): + desc = self._desc() + assert desc.value_fn('0') is False + assert desc.value_fn('1') is True + + def test_write_on_uses_the_single_supported_level(self): + """The on-value is whatever the device calls its one level, not a + hardcoded '1'.""" + desc = self._desc() + path, body = desc.write_fn('On', {'supportedAiLevel': ['2']}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} + + def test_write_off(self): + desc = self._desc() + path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']}) + assert body == {'aiLevel': '0'} + + +class TestAiEnergyLevelSelect: + def _desc(self): + return _ai_energy_level_desc('SelectDesc') + + def test_shown_only_with_multiple_supported_levels(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False + + def test_hidden_when_supported_level_is_non_list_scalar(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False + + def test_hidden_when_supported_level_missing(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '1'}, {}) is False + + def test_hidden_when_supported_level_empty_list(self): + desc = self._desc() + assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False + + def test_no_translation_key(self): + """aiLevel's values are plain digits that render fine untranslated + (select.py's _display()) -- no strings.json entry to maintain + against an unknown number of future levels.""" + desc = self._desc() + assert desc.translation_key is None + + def test_options_synthesize_off(self): + """'0' is never in supportedAiLevel but is a real, observed aiLevel + value -- synthesized back in as the explicit off option.""" + desc = self._desc() + resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}} + assert desc.options(resources) == ['0', '1', '2'] + + def test_options_empty_when_resource_missing(self): + desc = self._desc() + assert desc.options({}) == ['0'] + + def test_write(self): + desc = self._desc() + path, body = desc.write_fn('2', {}) + assert path == ['energy', 'ailevel', 'vs', '0'] + assert body == {'aiLevel': '2'} + + +class TestAiEnergyLevelStubDoesNotDecideThePlatform: + """Issue found in review: entity *creation* runs once, against whatever + snapshot is current when platforms are set up (see + __init__.py's async_config_entry_first_refresh-before-forward-entry-setups + ordering), while flatten() re-evaluates exists_fn every poll against live + data. Both descriptors share key='ai_energy_level' (see adapter._key), so + if a stub carve-out let one of them win at setup time while the other + wins once real data lands, flatten() would feed the already-instantiated + entity a value shaped for the other platform (e.g. a bool into a Select + expecting a string option). Neither side gets a `not rep` carve-out, so + an unfetched stub can never win entity creation for either platform -- + the entity simply doesn't appear until a reload happens with real data, + same as any other exists_fn-gated entity in this codebase that's unlucky + on first-poll timing, instead of appearing as the wrong widget type.""" + + def test_neither_widget_exists_on_empty_stub_rep(self): + switch = _ai_energy_level_desc('SwitchDesc') + select = _ai_energy_level_desc('SelectDesc') + assert switch.exists_fn({}, {}) is False + assert select.exists_fn({}, {}) is False + + +class TestSelfCheckError: + """Self-check diagnostic error list -- surfaced on hardware that reports + x.com.samsung.da.error, joined into a single display string.""" + + def _desc(self): + return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error') + + def test_exists_when_field_present(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True + + def test_does_not_exist_when_field_absent(self): + desc = self._desc() + assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False + + def test_exists_for_empty_stub_rep(self): + """An empty {} rep is /device/0's not-yet-fetched-stub carve-out -- + must be included-for-now, same as ENERGY_METER's fields.""" + desc = self._desc() + assert desc.exists_fn({}, {}) is True + + def test_value_joins_list(self): + desc = self._desc() + assert desc.value_fn(['E1', 'E2']) == 'E1, E2' + + def test_value_passes_through_scalar(self): + desc = self._desc() + assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + + def test_value_none_for_empty_list(self): + """An empty error list means no value to show -- None (unknown), + not an empty string.""" + desc = self._desc() + assert desc.value_fn([]) is None + + +# --------------------------------------------------------------------------- +# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type +# registry's _build([...]) call in place of the hand-duplicated capability +# lists that used to live there. +# --------------------------------------------------------------------------- + + +class TestUniversalAndPowerBundles: + def test_universal_contains_the_no_conflict_capabilities(self): + assert set(common.UNIVERSAL) == { + common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE, + common.SELF_CHECK, common.AI_ENERGY_LEVEL, + common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK, + } + + def test_power_kept_separate_for_airconditioners_sake(self): + """See common.POWER's own comment for why airconditioner opts out.""" + assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK} + + def test_no_overlap_between_the_two_bundles(self): + assert not (set(common.UNIVERSAL) & set(common.POWER)) + + def test_airconditioner_registry_does_not_include_power(self): + from custom_components.localthings.registry.by_type import airconditioner + bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps} + assert common.POWER_GENERIC not in bound_caps + assert common.POWER_VS_FALLBACK not in bound_caps diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7b5a8d9..59702e2 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap(): assert seen == [] +def test_discover_pattern_cap_reads_name_field(): + """A pattern cap's name_field normalizes a device-given instance name + (e.g. an ice maker's "CUBED_ICE") for use in the entity's display name, + independent of the href-derived key (issue #27).""" + cap = Capability( + href=None, + href_prefix='/icemaker/', + name_field='x.com.samsung.da.iceMaker.name', + entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + ) + resources = { + '/icemaker/one/vs/0': { + 'x.com.samsung.da.iceMaker.state': 'On', + 'x.com.samsung.da.iceMaker.name': 'CUBED_ICE', + }, + '/icemaker/two/vs/0': { + 'x.com.samsung.da.iceMaker.state': 'On', + 'x.com.samsung.da.iceMaker.name': 'ICE_BITES', + }, + } + bound = discover(resources, {}, pattern_caps=[cap]) + names = {b.href: b.instance_name for b in bound} + assert names == { + '/icemaker/one/vs/0': 'Cubed Ice', + '/icemaker/two/vs/0': 'Ice Bites', + } + + +def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none(): + cap = Capability( + href=None, + href_prefix='/icemaker/', + name_field='x.com.samsung.da.iceMaker.name', + entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + ) + resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}} + bound = discover(resources, {}, pattern_caps=[cap]) + assert bound[0].instance_name is None + + def test_discover_rt_filter_gates_binding(): """Cap with rt_filter must not bind a rep whose rt list does not match.""" oven_mode_cap = Capability( diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py new file mode 100644 index 0000000..1b9bacf --- /dev/null +++ b/tests/test_entity_naming.py @@ -0,0 +1,45 @@ +"""Tests for LocalThingsEntity's display-name derivation +(custom_components/localthings/entity.py) -- the explicit-name, +device-given-instance-name, and href-derived fallbacks. +""" +from custom_components.localthings.entity import LocalThingsEntity +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import BinarySensorDesc + + +class _FakeCoordinator: + device_serial = 'TEST-SERIAL' + + def __init__(self, last_resources=None): + self.last_resources = last_resources or {} + + +def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None): + capability = Capability(href=href, entities=(desc,)) + bound = BoundEntity(href=href, capability=capability, desc=desc, + instance=instance, key_override=key_override, + instance_name=instance_name) + return LocalThingsEntity(_FakeCoordinator(), bound) + + +def test_explicit_name_wins_over_everything(): + desc = BinarySensorDesc(key='enabled', name='Explicit Name') + entity = _make_entity(desc, instance_name='Cubed Ice') + assert entity._attr_name == 'Explicit Name' + + +def test_instance_name_prefixes_the_derived_suffix(): + """Issue #27: an ice maker's device-given name ("Cubed Ice") replaces + the href-derived instance label ("Icemaker One") as the name prefix, + keeping the same entity-specific suffix.""" + desc = BinarySensorDesc(key='enabled') + entity = _make_entity(desc, key_override='icemaker_one_enabled', + instance_name='Cubed Ice') + assert entity._attr_name == 'Cubed Ice Enabled' + + +def test_no_instance_name_falls_back_to_derived_state_key(): + desc = BinarySensorDesc(key='enabled') + entity = _make_entity(desc, key_override='icemaker_one_enabled') + assert entity._attr_name == 'Icemaker One Enabled' diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index a45e7f7..11dc501 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -102,3 +102,159 @@ class TestRefrigerationFallback: for key in ('rapid_fridge', 'rapid_freezing'): desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True + + +class TestFlexZone: + """Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone + entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class, + CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left + CV_FDR_-family fridges with an always-unknown select, and a write would + have appended a duplicate flag instead of replacing the existing one.""" + + def test_reads_rf9000_prefix(self): + rep = { + 'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'], + } + assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE' + + def test_reads_cv_fdr_prefix(self): + rep = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + } + assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT' + + def test_write_replaces_rather_than_duplicates(self): + rep = { + 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], + 'x.com.samsung.da.supportedOptions': [ + 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + } + path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep) + assert path == ['mode', 'vs', '0'] + modes = payload['x.com.samsung.da.modes'] + assert modes.count('CV_FDR_WINE') == 1 + assert 'CV_FDR_MEAT' not in modes + assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes + + +class TestTp1xNativeDuplicateResources: + """The US TP1X_REF_21K publishes two native mirrors in addition to the + richer vendor resources. They must count as covered without producing + duplicate entities or guessing unverified write contracts.""" + + def test_duplicate_capabilities_have_no_entities(self): + assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == () + assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == () + + def test_us_fixture_has_complete_coverage(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp1x_ref_21k_us') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + + assert unbound == [] + state = flatten(bound, resources) + assert state['defrost_delay'] is False + assert 'ice_maker_enabled' not in state + assert state['icemaker_one_enabled'] is True + assert state['icemaker_two_enabled'] is True + assert state['selfcheck_error'] == 'DA_ERROR_NONE' + + +class TestPantryZone: + """Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20.""" + + def test_href(self): + assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0' + + def test_write(self): + desc = fridge.PANTRY_ZONE.entities[0] + path, body = desc.write_fn('FDR_WINE', {}) + assert path == ['status', 'pantry', 'one', 'vs', '0'] + assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} + + +class TestArtik051AndTp2xFixturesHaveCompleteCoverage: + """issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered + the incomplete-capability-coverage repair; both must resolve to zero + unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for + fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native + defrost/icemaker mirrors are covered.""" + + def test_artik051_ref_17k(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_artik051_ref_17k') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['pantry_zone_mode'] == 'FDR_DRINKS' + + def test_tp2x_ref_20k(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp2x_ref_20k') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + + +class TestRefrigeratorAiEnergyLevelFixtureCoverage: + """AI energy-saving level (common.AI_ENERGY_LEVEL, see + test_common_capabilities.py) is exercised end-to-end here against the + refrigerator fixture/registry. + + refrigerator_device.json's supportedAiLevel was extended to two entries + (['1', '2']) specifically to exercise the select branch -- the real + captured TP1X_REF_21K_US dump only ever reports one entry (switch + branch), so the select is synthetic-fixture-only for now.""" + + def test_synthetic_fixture_has_complete_coverage(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['ai_energy_level'] == '1' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 93e0623..9585d3a 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -97,6 +97,20 @@ def test_registry_reproduces_golden_state_keys_for_cooktop(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp1x_ref_21k_us') + golden = json.loads( + (GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', 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_hood(): from tests.conftest import _load_device resources = _load_device('range_hood') @@ -109,6 +123,110 @@ def test_registry_reproduces_golden_state_keys_for_range_hood(): ) +def test_registry_reproduces_golden_state_keys_for_washer_flexwash(): + """FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW) + report no oneUiVersion and previously fell through for_device_by_model's + consumer-prefix map entirely -- issue #19.""" + from tests.conftest import _load_device + resources = _load_device('washer_flexwash') + golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text()) + state_keys = _new_state_keys('washer_flexwash', 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_washer_dryer_combo(): + """Washer/dryer combo units carry a writable dryLevel field on + /washer/vs/0 itself, with no separate dryer resource -- issue #22.""" + from tests.conftest import _load_device + resources = _load_device('washer_dryer_combo') + golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text()) + state_keys = _new_state_keys('washer_dryer_combo', 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_artik051_ref_17k(): + """ARTIK051_REF_17K's Cool Select Zone pantry compartment + (/status/pantry/one/vs/0) -- issue #20.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_artik051_ref_17k') + golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text()) + state_keys = _new_state_keys('refrigerator_artik051_ref_17k', 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_tp2x_ref_20k(): + """TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra + energy fields (cumulativeConsumption/monthlyConsumption/ + thismonthlyConsumption) surfaced by issue #26.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp2x_ref_20k') + golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text()) + state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', 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_ac_tp1x_da_ac_rac_01011(): + """Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model + TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0 + items[] resource and adds a /light/vs/0 display light, with extra vendor + housekeeping hrefs -- issue #17 for this model class (PR #36).""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', 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_tp2x_rac_20k(): + """TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the + '_RAC_' modelNum token fallback in for_device_by_model.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp2x_rac_20k') + golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text()) + state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', 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.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac') + golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text()) + state_keys = _new_state_keys('airconditioner_tp1x_rac', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index c13a913..a06243f 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -40,6 +40,22 @@ class TestWasherSettings: assert body == {'x.com.samsung.da.rinseCycles': '3'} +class TestDryLevel: + """Washer/dryer combo units carry a writable dryLevel field on + /washer/vs/0 itself, self-gated off on plain washers -- issue #22.""" + + def test_exists_only_when_supported_dry_level_present(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') + assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True + assert desc.exists_fn({}, {}) is False + + def test_write(self): + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') + path, body = desc.write_fn('Cupboard', {}) + assert path == ['washer', 'vs', '0'] + assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'} + + class TestWasherCourse: def test_href(self): assert washer.WASHER_COURSE.href == '/course/vs/0' @@ -151,11 +167,32 @@ class TestDetergentSoftenerDosing: return next(e for e in washer.WASHER_COURSE.entities if e.key == key) def test_quantity_and_hardness_read(self): + """The device reports the level un-padded ('3'), but the options and + translation keys are zero-padded supported codes ('03'); rep_fn + normalizes to the supported code so the value is a valid option + (issue #9 -- otherwise HA renders the select 'unknown').""" rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - assert self._desc('detergent_quantity').rep_fn(rep) == '3' - assert self._desc('detergent_water_hardness').rep_fn(rep) == '2' - assert self._desc('softener_quantity').rep_fn(rep) == '3' - assert self._desc('softener_concentration').rep_fn(rep) == '2' + assert self._desc('detergent_quantity').rep_fn(rep) == '03' + assert self._desc('detergent_water_hardness').rep_fn(rep) == '02' + assert self._desc('softener_quantity').rep_fn(rep) == '03' + assert self._desc('softener_concentration').rep_fn(rep) == '02' + + def test_current_value_is_a_valid_option_for_every_dosing_select(self): + """The core regression: HA shows a select 'unknown' when current_option + is not in options. Each dosing select's value must be one of its own + options.""" + rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} + for key in ('detergent_quantity', 'detergent_water_hardness', + 'softener_quantity', 'softener_concentration'): + desc = self._desc(key) + assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key + + def test_read_passes_through_when_no_supported_match(self): + """A value with no matching supported code is returned as-is rather than + dropped, so an unexpected device stays visible instead of blank.""" + rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7', + 'SupportedDetergentLevelCtrl_00010203']} + assert self._desc('detergent_quantity').rep_fn(rep) == '7' def test_translation_keys(self): """detergent_quantity and softener_quantity share one translation_key @@ -181,17 +218,21 @@ class TestDetergentSoftenerDosing: assert desc.exists_fn({}, _DOSING_RESOURCES) is True def test_quantity_write(self): + """The UI selects a padded supported code ('01'); the write posts the + un-padded device code ('1'), mirroring how the device reports it.""" rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} path, body = self._desc('detergent_quantity').write_fn('01', rep) assert path == ['course', 'vs', '0'] - assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options'] + assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options'] assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options'] + # untouched siblings survive the read-modify-write + assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options'] def test_hardness_write(self): rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} path, body = self._desc('softener_concentration').write_fn('03', rep) assert path == ['course', 'vs', '0'] - assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options'] + assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options'] def test_low_reservoir_off_when_alarm_off(self): rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} @@ -208,3 +249,201 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} assert self._desc('detergent_low').exists_fn(rep, {}) is True + + +class TestWashOptionToggles: + """Bubble soak / pre-wash / intensive-wash switches, from the same + options[] array as the cycle select (issue #22 follow-up). Confirmed + On/Off shape from a dump with Bubble Soak toggled on in the app.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + @staticmethod + def _keys(): + return ('bubble_soak', 'pre_wash', 'intensive') + + @staticmethod + def _prefix(key): + return {'bubble_soak': 'BubbleSoak', + 'pre_wash': 'PreWashSetting', + 'intensive': 'IntensiveSetting'}[key] + + def test_exists_only_when_field_present(self): + for key in self._keys(): + desc = self._desc(key) + assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert desc.exists_fn(rep, {}) is True + + def test_reads_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).rep_fn(rep) is False + + def test_reads_on(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + assert self._desc(key).rep_fn(rep) is True + + def test_write_on_and_off(self): + """write_fn receives the same 'On'/'Off' string switch.py sends + (not a bool) -- covers a bug where an earlier `'On' if p else 'Off'` + implementation always wrote 'On', since any non-empty string + (including 'Off') is truthy.""" + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} + path, body = self._desc(key).write_fn('On', rep) + assert path == ['course', 'vs', '0'] + assert f'{prefix}_On' in body['x.com.samsung.da.options'] + assert 'GMT_02' in body['x.com.samsung.da.options'] + + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + path, body = self._desc(key).write_fn('Off', rep) + assert f'{prefix}_Off' in body['x.com.samsung.da.options'] + assert f'{prefix}_On' not in body['x.com.samsung.da.options'] + + def test_write_rejects_non_on_off_payload(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).write_fn('bogus', rep) is None + + +# editCourseList and availability bitmaps from the reporter's issue #22 +# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just +# turned on in the app): 24 courses, course '30' at position 1 reads 'F0' +# (available) on all three bitmaps; course '1C' at position 0 reads '00' on +# BubbleSoakSet (matching the app graying that control out for Eco 40-60). +_EDIT_COURSE_RESOURCES = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': + 'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729', + }, +} +_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000' +_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' +_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' + + +class TestWashOptionToggleValidation: + """validate_fn rejects turning a toggle on for a course whose byte in + its availability bitmap isn't 'F0', with a user-facing message switch.py + raises as ServiceValidationError -- distinct from write_fn's silent + no-op for a malformed payload.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + def test_allowed_on_a_supported_course(self): + rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_rejected_on_an_unsupported_course(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) + assert msg == "Bubble soak isn't available on the selected cycle." + + def test_pre_wash_and_intensive_use_their_own_availableset_field(self): + rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} + assert self._desc('pre_wash').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]} + assert self._desc('intensive').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_turning_off_is_never_blocked(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'Off', rep, _EDIT_COURSE_RESOURCES) is None + + def test_allows_write_when_course_unresolvable(self): + """No editCourseList, no Course_ token, or a bitmap whose length + doesn't match editCourseList -- in every case, fail open rather than + block a write we can't actually verify.""" + desc = self._desc('bubble_soak') + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, {}) is None + + rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + + rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + + +class TestAiEnergyLevel: + """Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer. + + The capability itself (common.AI_ENERGY_LEVEL) is tested in + test_common_capabilities.py; this just confirms it's wired into the + washer registry and that the fixture's single-entry supportedAiLevel + (['1'], matching the issue's dump) surfaces as a switch, not a select.""" + + def test_fixture_has_complete_coverage(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['ai_energy_level'] is False # aiLevel '0' -- off + + +class TestFlexWashAndComboFixturesHaveCompleteCoverage: + """FlexWash (issue #19, previously unrecognized entirely) and + washer/dryer combo (issue #22, dry_level) dumps must both resolve to + zero unbound hrefs.""" + + def test_flexwash(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer_flexwash') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert 'dry_level' not in state # plain washer -- no supportedDryLevel field + + def test_dryer_combo(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import washer as washer_registry + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('washer_dryer_combo') + unbound = [] + bound = discover( + resources, + washer_registry.REGISTRY.capabilities, + washer_registry.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + # the device's own "off" sentinel is the literal string 'None', not absence + assert state['dry_level'] == 'None'