diff --git a/README.md b/README.md index e9881b3..3ac35d0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,19 @@ LocalThings Logo +

+ GitHub Repo stars + GitHub watchers +

+ +

+ GitHub Release + hacs validation + hassfest + tests + +

+ # LocalThings **A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN. @@ -25,7 +38,8 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Air purifier | `by_type/air_purifier.py` | | Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | -| Oven (including combi microwaves) | `by_type/oven.py` | +| Oven | `by_type/oven.py` | +| Microwave | `by_type/microwave.py` | | Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 43e9aff..5ba2137 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -50,6 +50,7 @@ from .registry.capabilities.airconditioner import ( HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, + HREF_AIRFLOW as AIRFLOW_HREF, ) from .registry.capabilities.common import normalize_temp_unit @@ -228,8 +229,45 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # -- resource helpers --------------------------------------------------- + # Preset codes on legacy ARTIK051 boards, learned by driving the same unit + # through its cloud integration and reading the local token back each time: + # Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none. + _LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed') + + def _legacy_convenient(self) -> dict: + """A /mode/convenient/vs/0-shaped rep built from the Comode_* token in + /mode/vs/0's options, for boards that have no convenient resource.""" + options = (self.coordinator.resource(MODE_HREF) or {}).get( + 'x.com.samsung.da.options') or [] + for option in options: + if isinstance(option, str) and option.startswith('Comode_'): + return {_MODES_FIELD: [option.split('_', 1)[1]], + _SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)} + return {} + + def _legacy_airflow(self) -> dict: + """The /airflow/vs/0 rep, but only when it is the fan/swing channel to + use -- i.e. this board has no /wind/strength/vs/0.""" + if self.coordinator.resource(WIND_STRENGTH_HREF): + return {} + return self.coordinator.resource(AIRFLOW_HREF) or {} + + def _legacy_preset(self) -> bool: + """Whether presets come from the Comode_* token rather than a resource. + + Gated on the same board test as _legacy_airflow, not on the convenient + rep being empty alone: newer boards carry Comode tokens too, so a + momentarily empty /mode/convenient/vs/0 there must not silently switch + the preset read (and write) over to the token path. + """ + return (not self.coordinator.resource(CONVENIENT_HREF) + and bool(self._legacy_airflow())) + def _rep(self, href: str) -> dict: - return self.coordinator.resource(href) or {} + rep = self.coordinator.resource(href) or {} + if not rep and href == CONVENIENT_HREF and self._legacy_airflow(): + return self._legacy_convenient() + return rep def _is_on(self) -> bool: # Prefer the vendor /power/vs/0 (present on every observed board and @@ -373,10 +411,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def fan_mode(self): + airflow = self._legacy_airflow() + if airflow: + return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel'))) return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def fan_modes(self) -> list[str]: + if self._legacy_airflow(): + # This resource carries no supportedModes, so the full scale is offered. + return list(_DEVICE_TO_FAN.values()) return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) def _swing_via_direction(self) -> bool: @@ -387,12 +431,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def swing_mode(self): + airflow = self._legacy_airflow() + if airflow: + return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction')) if self._swing_via_direction(): return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF)) @property def swing_modes(self) -> list[str]: + if self._legacy_airflow(): + return list(_SWING_TO_DEVICE.keys()) if self._swing_via_direction(): return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) if self._rep(WIND_OSCILLATION_HREF): @@ -468,9 +517,21 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command(self._bound, (kind, device)) async def async_set_fan_mode(self, fan_mode: str) -> None: + if self._legacy_airflow(): + level = _FAN_TO_DEVICE.get(fan_mode) + if level is not None: + await self.coordinator.async_send_command( + self._bound, ('fan_legacy', level)) + return await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: + if self._legacy_airflow(): + code = _SWING_TO_DEVICE.get(swing_mode) + if code is not None: + await self.coordinator.async_send_command( + self._bound, ('swing_legacy', code)) + return if self._swing_via_direction(): await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) return @@ -489,5 +550,6 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep'). for code in self._supported(CONVENIENT_HREF): if _preset_to_ha(code) == preset_mode: - await self.coordinator.async_send_command(self._bound, ('preset', code)) + kind = 'preset_legacy' if self._legacy_preset() else 'preset' + await self.coordinator.async_send_command(self._bound, (kind, code)) return diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 63bffbd..10c04df 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5 # without stalling setup; it matches the per-resource read timeout elsewhere. PROBE_GET_TIMEOUT_S = 10.0 +# Base for the local (client-side) DTLS source port, distinct from the +# destination probe ports above. See coordinator._local_source_port for why a +# fixed per-device source port matters and how the per-device offset is +# derived. Base mirrors the upstream smartthings-local reference bridge. +# Requires smartthings-local >= 0.1.1. +DTLS_LOCAL_PORT_BASE = 49700 + SUMMARY_INTERVAL_S = 30.0 DEVICE_SUPPORT_ISSUE_URL = ( diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 27e6c7e..d7591f1 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +import ipaddress import logging import threading import time +import zlib from datetime import timedelta from typing import Any @@ -37,6 +39,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S from .const import ( DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S, + DTLS_LOCAL_PORT_BASE, ) _LOGGER = logging.getLogger(__name__) @@ -54,6 +57,29 @@ class _NoOpDescriptor: _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling +def _local_source_port(host: str) -> int: + """Deterministic UDP source port for this device's DTLS socket. + + Binding the same source port on every (re)connect keeps the client on one + 5-tuple, so the appliance evicts an orphaned session left by a previous run + (unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347 + §4.2.8, instead of holding it for 5-15 min while the new session's reads + hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local + >= 0.1.1 (the version that added DtlsCoapSession(local_port=...)). + + The port must be stable across restarts and unique per device on this HA + host: the library's socket is unconnected (recvfrom), so two devices + sharing a source port would mis-demux each other's datagrams. For the usual + dotted-IPv4 host we use the last octet as the offset (unique on a /24); + anything else folds a stable CRC32 into the same 256-wide window. + """ + try: + offset = int(ipaddress.IPv4Address(host)) & 0xFF + except (ipaddress.AddressValueError, ValueError): + offset = zlib.crc32(host.encode()) & 0xFF + return DTLS_LOCAL_PORT_BASE + offset + + def _is_placeholder_serial(serial: str) -> bool: """True for a non-empty serialNum that isn't actually a real identity. @@ -182,7 +208,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): key_pem = self._entry.data[CONF_LEAF_KEY_PEM] sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem, - on_notification=self._observe.on_notification) + on_notification=self._observe.on_notification, + local_port=_local_source_port(host)) sess.connect() sess.start_reader() self._session = sess diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index e8c0b9b..cbdbca1 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -8,6 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.const import EntityCategory from .registry.adapter import _key +from .registry.batch import is_stub_rep from .registry.discovery import BoundEntity, _snake_to_title from .const import DOMAIN @@ -21,9 +22,17 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b require that field to be present in the resource rep so that optional fields on shared resources don't create phantom entities. - An empty rep ({}) means /device/0 returned a stub for this resource — - the resource exists on the device but data hasn't been fetched yet. - In that case we include the entity so it can be populated by sub-polls. + A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched + yet" marker) is included anyway so it can be populated by sub-polls. A + genuinely empty {} rep is included too by this default gate -- whether + empty means "not populated yet" or "permanently unsupported" needs + per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's + {} is fridge.py's documented *normal* no-alarm state (see + _active_alarm_codes), not an absence signal, and it's far from the only + resource like that. Only a capability whose author has actually verified + a field is genuinely never populated on unsupported hardware opts into + stricter gating with its own is_stub_rep-based exists_fn (see + common.ENERGY_METER, issue #127) -- this default stays permissive. """ rep = coordinator.last_resources.get(bound.href) if rep is None: @@ -31,7 +40,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b if bound.desc.exists_fn is not None: return bound.desc.exists_fn(rep, coordinator.last_resources) if bound.desc.field: - if not rep: # stub — resource known to exist, data not yet fetched + if not rep or is_stub_rep(rep): return True return bound.desc.field in rep return True # rep_fn or no-field entities (ButtonDesc) are always included diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 15ac111..9c9d623 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -1,10 +1,15 @@ """Fan platform for Samsung range hoods and air purifiers. -Two FanDesc-bound hrefs exist, one per family, dispatched by href in -async_setup_entry below since they need different HA fan semantics: the -range hood's fan speed is an ordered set of numeric levels (SET_SPEED), while -the newer air-purifier board family's modes (Smart/Max/Mid/WindFree/Sleep, -issue #130) are named behaviors with no linear order (PRESET_MODE).""" +Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry +below since each needs different HA fan semantics: the range hood's fan +speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/ +Medium/High (issue #56) are both an ordered set of numeric levels +(SET_SPEED) -- the latter confirmed monotonic in capabilities/ +air_purifier.py's module docstring, with no named-mode list to preserve +since this board never self-reports one. The newer TP1X air-purifier +family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named +behaviors with no linear order (PRESET_MODE), reported directly by that +board's own supportedModes.""" from __future__ import annotations @@ -22,6 +27,7 @@ from homeassistant.util.percentage import ( from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included +from .registry.capabilities.air_purifier import HREF_AIRFLOW from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF from .registry.entities import FanDesc @@ -31,6 +37,8 @@ POWER_HREF = '/power/0' POWER_VS_HREF = '/power/vs/0' _FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed' _SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed' +_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed' +_OFF_SPEED_CODE = '0' _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes' @@ -48,13 +56,32 @@ async def async_setup_entry( continue if bound.href == AIR_PURIFIER_FAN_HREF: entities.append(LocalThingsAirPurifierFan(coordinator, bound)) + elif bound.href == HREF_AIRFLOW: + entities.append(LocalThingsAirflowFan(coordinator, bound)) else: entities.append(LocalThingsRangeHoodFan(coordinator, bound)) async_add_entities(entities) class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): - """A hood fan combining sibling power and fan-speed resources.""" + """A hood fan combining sibling power and fan-speed resources. + + Some boards that reuse this capability (built-in microwave vent fans, + issues #137/#142) report no sibling `/power/0` or `/power/vs/0` + resource at all -- fan speed 0 is itself the off state there, with no + separate power toggle to write. `_speed_zero_is_off` detects that + shape from the hood resource's own settableMinFanSpeed/ + supportedFanSpeed fields and switches every method below to drive + off/on purely through the fanSpeed field, including '0' in the + ordered speed codes as the off step instead of assuming every + advertised code is an active speed. + + This is deliberately not the same question as `_has_separate_power`, + which only proves *some* power resource exists on the device -- on a + combi appliance (e.g. an over-the-range microwave) that resource can + belong to the cavity, not the vent fan, and toggling it from here + would turn off the whole appliance instead of just the fan. + """ _enable_turn_on_off_backwards_compatibility = False _attr_supported_features = ( @@ -70,15 +97,35 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _rep(self, href: str) -> dict: return self.coordinator.resource(href) or {} + def _has_separate_power(self) -> bool: + return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF)) + + def _speed_zero_is_off(self) -> bool: + """Whether fan speed '0' is itself this hood's off step, with no + separate power resource to toggle. The board says so directly: + settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The + standalone hood's codes start at 14 and it carries a real /power + resource instead, so this is False there.""" + rep = self._rep(self._bound.href) + return ( + str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE + or _OFF_SPEED_CODE in self._all_speed_codes() + ) + def _all_speed_codes(self) -> list[str]: rep = self._rep(self._bound.href) return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())] def _active_speed_codes(self) -> list[str]: - # Power is carried by the separate /power resource. fanSpeed retains - # the selected setting while power is off (as the lamp's `current` - # field does), so every advertised code is an active ordered speed. - return self._all_speed_codes() + codes = self._all_speed_codes() + if self._speed_zero_is_off(): + # No separate power resource: '0' is the off step, not a speed. + return [code for code in codes if code != _OFF_SPEED_CODE] + # Power is carried by the separate /power resource. fanSpeed + # retains the selected setting while power is off (as the + # lamp's `current` field does), so every advertised code is an + # active ordered speed. + return codes def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: """Target whichever power resource this hood actually exposes.""" @@ -88,6 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: + if self._speed_zero_is_off(): + current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) + return current not in ('', _OFF_SPEED_CODE) rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -113,6 +163,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: + if self._speed_zero_is_off(): + if percentage is not None: + await self.async_set_percentage(percentage) + return + if self.is_on: + # Already running: no percentage given means "just turn on", + # not "reset to the lowest speed". + return + codes = self._active_speed_codes() + if codes: + await self.coordinator.async_send_command(self._bound, ('speed', codes[0])) + return await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) @@ -120,6 +182,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: + if self._speed_zero_is_off(): + await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE)) + return await self.coordinator.async_send_command( self._bound, self._power_payload(False), ) @@ -131,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if not self.is_on: + if not self._speed_zero_is_off() and not self.is_on: await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) @@ -214,3 +279,87 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): "%s: %r is not a valid preset mode (supported: %s)", self.entity_id, preset_mode, self.preset_modes, ) + + +_AIRFLOW_SPEED_FIELD = 'speed' +# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1, +# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered +# as plain strings, same as _all_speed_codes above, so +# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat +# it exactly like the range hood's numeric levels -- no named-preset table +# needed since this board never reports mode names to hang one off of. +_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4') + + +class LocalThingsAirflowFan(LocalThingsEntity, FanEntity): + """ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric + speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: + super().__init__(coordinator, bound) + self._attr_name = None + + def _rep(self, href: str) -> dict: + return self.coordinator.resource(href) or {} + + def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: + """Prefer /power/0 like LocalThingsRangeHoodFan above, NOT + LocalThingsAirPurifierFan's vs/0-first order -- that order is only + harmless for the TP1X board because it never reports /power/0 at + all. This family's dumps carry both hrefs, and common.POWER_GENERIC + (the power_switch entity) is unconditionally bound to /power/0 + whenever it's present, so writing here to /power/vs/0 first would + leave power_switch and this fan reading/writing two different + resources -- disagreeing until the next poll refreshes the other + one (the same optimistic-apply lag coordinator.py's own comments + warn about).""" + resources = self.coordinator.last_resources + target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF + return 'power', enabled, target + + @property + def is_on(self) -> bool: + power = self._rep(POWER_HREF) + if 'value' in power: + return bool(power.get('value')) + return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on' + + @property + def speed_count(self) -> int: + return len(_AIRFLOW_SPEED_CODES) + + @property + def percentage(self) -> int | None: + if not self.is_on: + return 0 + current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, '')) + if current not in _AIRFLOW_SPEED_CODES: + return None + return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current) + + async def async_turn_on( + self, percentage: int | None = None, preset_mode: str | None = None, + **kwargs, + ) -> None: + await self.coordinator.async_send_command(self._bound, self._power_payload(True)) + if percentage is not None: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + await self.coordinator.async_send_command(self._bound, self._power_payload(False)) + + async def async_set_percentage(self, percentage: int) -> None: + if percentage <= 0: + await self.async_turn_off() + return + if not self.is_on: + await self.coordinator.async_send_command(self._bound, self._power_payload(True)) + code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage) + await self.coordinator.async_send_command(self._bound, ('speed', int(code))) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 470bd6c..fde1d36 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -10,7 +10,7 @@ "requirements": [ "cbor2>=5.4.6", "pyOpenSSL>=23.0", - "smartthings-local>=0.1.0" + "smartthings-local>=0.1.1" ], - "version": "0.13.0" + "version": "0.14.0" } diff --git a/custom_components/localthings/registry/batch.py b/custom_components/localthings/registry/batch.py index 6a90c2e..c7fb3ad 100644 --- a/custom_components/localthings/registry/batch.py +++ b/custom_components/localthings/registry/batch.py @@ -2,8 +2,25 @@ from __future__ import annotations +def is_stub_rep(rep: dict) -> bool: + """True for the device's "resource exists, no data fetched yet" marker -- + an echoed {"href": "..."} with no other fields. + + Distinct from a genuinely empty {} rep, which is the device's confirmed + (if empty) answer -- e.g. an unsupported resource on this model that will + never populate. Conflating the two used to make every field-gated entity + on a permanently-empty resource look like a not-yet-fetched stub forever, + creating phantom always-"unknown" entities (issue #127).""" + return isinstance(rep, dict) and set(rep.keys()) == {'href'} + + def parse_device0_batch(device0: list) -> dict[str, dict]: - """Extract {href: rep} from a /device/0 CBOR list response.""" + """Extract {href: rep} from a /device/0 CBOR list response. + + A stub rep is passed through unchanged rather than collapsed to {} -- + downstream code (entity._is_included, capability exists_fns) uses + is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}. + """ out = {} for entry in device0[1:]: # skip [0] (device-level rep) if not isinstance(entry, dict): @@ -12,8 +29,6 @@ def parse_device0_batch(device0: list) -> dict[str, dict]: rep = entry.get('rep') if not href: continue - # rep == {"href": "..."} is a stub (resource present, no current data). - # Include it as {} so capabilities still bind and the entity exists. if isinstance(rep, dict): - out[href] = {} if set(rep.keys()) == {'href'} else rep + out[href] = rep return out diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 13107d8..1a9b9aa 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -4,8 +4,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - induction_cooktop, oven, range as _range, range_hood, refrigerator, - vacuum_station, washer, water_purifier, + induction_cooktop, microwave, oven, range as _range, range_hood, + refrigerator, vacuum_station, washer, water_purifier, ) __all__ = [ @@ -24,6 +24,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'induction_cooktop': induction_cooktop.REGISTRY, + 'microwave': microwave.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, 'range': _range.REGISTRY, @@ -211,6 +212,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. + # Room air conditioners on the ARTIK051 board (e.g. ARTIK051_KRAC_18K, + # issue #136) report no oneUiVersion and carry a '_KRAC_' token. The '_RAC_' + # check above cannot see it -- the 'K' sits between the underscore and 'RAC' -- + # and the consumer-prefix fallback only covers washers/dryers/dishwashers, so + # these units fell back to 'unknown' and exposed nothing but power. Same + # ARTIK051 board family as the '_TVTL_' air purifier below. + if key is None and '_KRAC_' in (model_num or ''): + key = 'airconditioner' if key is None and '_TVTL_' in (model_num or ''): key = 'air_purifier' model_identity = f'{model_num} {description}'.upper() @@ -232,15 +241,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. if key is None and '-OVEN-' in (model_num or '').upper(): key = 'oven' - # Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same - # board family as the wall oven above (an '/oven/vs/0' cavity resource - # with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a - # different cavity. Reports no oneUiVersion and doesn't match the - # washer/dryer/dishwasher prefix map either. Binds cleanly against the - # existing oven registry once routed here (confirmed against the issue - # #121 dump) -- no microwave-specific device type needed. + # Microwaves, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) + # and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board + # family as the wall oven above (an '/oven/vs/0' cavity resource, same + # /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode + # vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds + # from the oven registry, plus a powerLevel field ovens don't report -- + # its own device type rather than folded into 'oven' (issue #121 shipped + # it onto the oven registry initially; split out per user feedback). + # Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher + # prefix map either. if key is None and '-MICROWAVE-' in (model_num or '').upper(): - key = 'oven' + key = 'microwave' # Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue # #86) -- same board family and '/cooktop/status/vs/0' resource shape # as the range combo above, but no oven attached at all. Distinct from diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py new file mode 100644 index 0000000..7e76a9a --- /dev/null +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -0,0 +1,37 @@ +"""Microwave device registry (combi and plain microwaves, issues #66/#121). + +Shares the oven board family's cavity/cook-cycle resource shape, so the +operational-state, door, cloud-connected, and quick-recipe-display +Capability objects are reused directly from oven.py rather than duplicated. +Cooking mode, setpoint, cavity power level, and lamp are genuinely +different for this family (different mode vocabulary, different setpoint +bounds, an extra powerLevel field, a differently-named lamp option) and are +defined fresh in capabilities/microwave.py -- see that module's docstring. + +Some combi units (built-in over-the-range microwaves, issues #137/#142) +also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same +shape a standalone range hood reports it in -- reused directly from +range_hood.py rather than duplicated. Unlike a standalone hood, this dump +has no sibling `/power/0` or `/power/vs/0` resource; fan.py's +LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that +case (see its `_speed_zero_is_off` check). +""" +from ..capabilities import common, ignored, microwave, oven, range_hood +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='microwave', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + microwave.MICROWAVE_CAVITY, + microwave.MICROWAVE_SETPOINT, + microwave.MICROWAVE_MODE, + oven.OVEN_OPERATIONAL_STATE, + oven.OVEN_DOOR, + oven.OVEN_CONNECTED, + oven.OVEN_RECIPE_COOK, + range_hood.HOOD_FAN, + ]), +) diff --git a/custom_components/localthings/registry/by_type/range_hood.py b/custom_components/localthings/registry/by_type/range_hood.py index 9eab99c..b647844 100644 --- a/custom_components/localthings/registry/by_type/range_hood.py +++ b/custom_components/localthings/registry/by_type/range_hood.py @@ -11,6 +11,7 @@ REGISTRY = DeviceRegistry( range_hood.HOOD_ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE, + range_hood.AFTER_RUN, 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 43e776b..4c0abf5 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -36,5 +36,7 @@ REGISTRY = DeviceRegistry( fridge.TEMP_SETPOINT, fridge.ICEMAKER_GENERIC, fridge.DOOR_GENERIC, + fridge.KIMCHI_ZONE, + fridge.KIMCHI_DOOR_GENERIC, ], ) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5e867d6..f9ba661 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -39,14 +39,23 @@ set to Auto/Sleep/Low/Medium/High): automatic side effect of sleep mode, e.g. a display-dimming level, but that's still a guess). -/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real -fan-speed control: across the same five dumps it read 0 for both Auto *and* -High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any -selectable level, and the dumps were all captured within about three minutes -of each other (only one poll cycle apart at this integration's 30s summary -interval), so the values may not have settled after each change before the -diagnostics snapshot was taken. Exposed read-only pending a confirmed, -stable capture -- see the issue #56 discussion for what's needed. +/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up). +The first round of five dumps above wasn't conclusive -- it read 0 for both +Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five +were captured within about a minute of each other, faster than this +integration's own ~30s poll cycle could settle each change. A second round, +captured 60-90s apart per setting on two independent units, confirmed a +clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4. +AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4 +range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's +percentage steps straight onto the raw code, no named-preset table needed +(unlike the TP1X family's FAN, which exposes real named modes because its +board actually reports a supportedModes list to hang names off of). + +/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it +was unreliable on both units in that second round (Low/Medium collided on +one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below +stays a plain read-only diagnostic even after this change. """ from ..capability import Capability from ..entities import ( @@ -64,6 +73,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option # Both board generations share the /mode/vs/0 href, so FAN and MODE below # are mutually exclusive via this presence check rather than colliding. HREF_MODE = '/mode/vs/0' +HREF_AIRFLOW = '/airflow/0' def _has_top_level_modes(rep, resources): @@ -126,21 +136,52 @@ DEVICE_ACTIVE = Capability( ), ) -# OCF-native / vendor pair for fan speed+direction -- see module docstring for -# why these are read-only for now. +def _airflow_fan_write(payload, rep, href=None): + kind, value, *args = payload + if kind == 'power': + power_href = args[0] if args else '/power/vs/0' + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + if kind == 'speed': + return ['airflow', '0'], {'speed': int(value)} + return None + + +# Confirmed via issue #56's second, properly-spaced round of diagnostics +# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is +# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now +# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same +# SET_SPEED shape as the range hood's) instead of a read-only sensor -- +# no named-preset table needed, since HA's percentage steps map onto the +# raw 0-4 code directly, the same way the range hood's numeric levels do. +# `direction` stays a plain diagnostic: every dump seen (both rounds, both +# units) reads 'Off' for it regardless of fan setting, so there's nothing +# confirmed to control there yet. +# +# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared +# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built +# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are +# only *empirically* mutually exclusive (every dump seen has one board +# generation's shape or the other, never both), not architecturally +# enforced the way same-href caps are by _build()'s match_fn check -- a +# same key would collide if a future board ever reported both. AIRFLOW_GENERIC = Capability( - href='/airflow/0', + href=HREF_AIRFLOW, poll_tier='warm', entities=( - SensorDesc(key='fan_speed_level', field='speed', - icon='mdi:fan', - state_class='measurement', entity_category='diagnostic'), + FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write), SensorDesc(key='fan_direction', field='direction', icon='mdi:rotate-3d-variant', entity_category='diagnostic'), ), ) +# Left exactly as a read-only fallback -- speedLevel is NOT the same +# confirmed-reliable field as /airflow/0's speed above (see module +# docstring): it collided Low/Medium on one unit and stuck at 0 throughout +# on the other in the same properly-spaced round. AIRFLOW_VS_FALLBACK = Capability( href='/airflow/vs/0', match_fn=lambda rep, resources: '/airflow/0' not in resources, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 4355155..5ccfae7 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -29,21 +29,19 @@ def _int(v): return None -def _option_token(rep, prefix): - """Return the `_` token from /mode/vs/0 options, else None.""" - for o in _mode_options(rep): - if isinstance(o, str) and o.startswith(prefix + '_'): - return o - return None - - def _beep_on(rep): """Beep on/off from the `Volume_*` option token: Volume_Mute = off, - Volume_100 (and any non-Mute) = on. None when no Volume_ slot.""" + Volume_100 (and any non-Mute) = on. None when no Volume_ slot. + + _option_token (defined further below, alongside the legacy-board token + helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the + full 'Volume_100' token -- shared with _option_token_num/_option_token_on, + which this module's other options[] readers already rely on. + """ tok = _option_token(rep, 'Volume') if tok is None: return None - return tok != 'Volume_Mute' + return tok != 'Mute' def _beep_write(payload, rep, href=None): @@ -58,7 +56,7 @@ def _beep_write(payload, rep, href=None): token = 'Mute' else: prev = _option_token(rep, 'Volume') - token = prev[len('Volume_'):] if (prev and prev != 'Volume_Mute') else '100' + token = prev if (prev and prev != 'Mute') else '100' return ['mode', 'vs', '0'], { 'x.com.samsung.da.options': option_write('Volume', token), } @@ -155,11 +153,18 @@ HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) +# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources +# at all: fan speed and vane direction sit together in this one resource, as +# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and +# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their +# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a +# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient. +HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode 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_WIND_OSCILLATION, HREF_CONVENIENT, + HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW, ] @@ -239,6 +244,91 @@ def _display_light_write(payload, rep, href=None): {'x.com.samsung.da.options': option_write('Light', token)}) +# --------------------------------------------------------------------------- +# Legacy ARTIK051 boards keep several settings that newer boards expose as their +# own resources (/option/*, /electriccurrent/vs/0, ...) as `_` +# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart; +# writes reuse option_write's single-token merge, exactly like the display light. +# --------------------------------------------------------------------------- + + +def _option_token(rep, prefix): + """Value part of a `_` token in /mode/vs/0's options.""" + for option in _mode_options(rep): + if isinstance(option, str) and option.startswith(prefix + '_'): + return option.split('_', 1)[1] + return None + + +def _is_legacy_board(resources): + """True for the board generation whose airflow lives in /airflow/vs/0. + + Newer families carry several of the same option tokens (Volume, Sleep, + OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings, + so an ungated token entity would either duplicate an existing one or apply a + scale calibrated elsewhere. Every AC dump on record has one shape or the + other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no + /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities + below and the climate entity can never disagree about the generation. + """ + return ('/airflow/vs/0' in resources + and '/wind/strength/vs/0' not in resources) + + +def _has_option_token(prefix): + return lambda rep, resources: ( + _is_legacy_board(resources) and _option_token(rep, prefix) is not None) + + +def _option_token_on(prefix): + return lambda rep: _option_token(rep, prefix) == 'On' + + +def _option_token_num(prefix, offset=0, divisor=1): + def read(rep): + raw = _option_token(rep, prefix) + try: + return (float(raw) - offset) / divisor + except (TypeError, ValueError): + return None + return read + + +def _option_switch_write(prefix): + def write(payload, rep, href=None): + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': option_write(prefix, payload)}) + return write + + +def _option_number_write(prefix): + def write(payload, rep, href=None): + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': + option_write(prefix, str(int(round(float(payload)))))}) + return write + + +def _humidity(rep): + """Relative humidity, preferring the 5%-rounded field where it exists. + + ARTIK051 boards have no fivepercentHumidity field at all and report the + plain x.com.samsung.da.humidity instead -- and only populate it while the + Air monitoring option is on: the unit measures for roughly half a minute + (51% observed, matching what the same unit's cloud integration reported at + that moment), then zeroes the field and switches Air monitoring back off by + itself. So 0 reads as "not measuring" and is reported as unknown rather than + as 0% humidity, which would poison long-term history -- which is also why + the boards that do have fivepercentHumidity read a permanent 0 here. + """ + for field in ('x.com.samsung.da.fivepercentHumidity', + 'x.com.samsung.da.humidity'): + if field in rep: + value = _num(rep[field]) + return value if value else None + return None + + def _climate_write(payload, rep, href=None): """Map a (kind, value) command from the climate platform to the (path_segs, body) for that one sub-write. `value` is already the raw device @@ -292,6 +382,16 @@ def _climate_write(payload, rep, href=None): 'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix', 'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix', }) + if kind == 'fan_legacy': + return (['airflow', 'vs', '0'], + {'x.com.samsung.da.speedLevel': str(value)}) + if kind == 'swing_legacy': + return (['airflow', 'vs', '0'], + {'x.com.samsung.da.direction': value}) + if kind == 'preset_legacy': + # Single-token options merge, same mechanism as _display_light_write. + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': option_write('Comode', value)}) if kind == 'preset': return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value}) return None @@ -318,17 +418,79 @@ CLIMATE = Capability( icon='mdi:led-on', entity_category='config'), # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. + # Gated off the legacy ARTIK051 board generation (see _is_legacy_board): + # that generation's own Volume_ token is already modeled as the + # buzzer_volume Number below, and both reading the same options[] slot + # into two entities would be redundant. SwitchDesc(key='beep', rep_fn=_beep_on, - exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None, + exists_fn=lambda rep, resources: ( + not _is_legacy_board(resources) + and _option_token(rep, 'Volume') is not None), write_fn=_beep_write, icon='mdi:volume-high', entity_category='config'), # Tropical night mode level (0-16) from the `Sleep_` option token. # Single-token option_write. Cloud: custom.airConditionerTropicalNightMode. + # Gated off the legacy board for the same reason as beep above -- its + # Sleep_ token is already the good_sleep Number below. NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value, - exists_fn=lambda rep, resources: _option_token(rep, 'Sleep') is not None, + exists_fn=lambda rep, resources: ( + not _is_legacy_board(resources) + and _option_token(rep, 'Sleep') is not None), write_fn=_tropical_night_write, native_min=0, native_max=16, step=1, icon='mdi:weather-night', entity_category='config'), + # Settings that this board generation keeps as options[] tokens. + SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'), + exists_fn=_has_option_token('Spi'), + write_fn=_option_switch_write('Spi'), + icon='mdi:air-purifier', entity_category='config'), + # Shares AUTO_CLEAN's catalog entry rather than duplicating it: same + # feature, different board generation (that one is a /option/autoclean/ + # vs/0 field, absent here). Distinct key, so nothing collides if some + # future board ever reported both. + SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean', + rep_fn=_option_token_on('Autoclean'), + exists_fn=_has_option_token('Autoclean'), + write_fn=_option_switch_write('Autoclean'), + icon='mdi:fan-auto', entity_category='config'), + SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'), + exists_fn=_has_option_token('AirMonitoring'), + write_fn=_option_switch_write('AirMonitoring'), + icon='mdi:air-filter', entity_category='config'), + NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'), + exists_fn=_has_option_token('Volume'), + write_fn=_option_number_write('Volume'), + native_min=0, native_max=100, step=10, + icon='mdi:volume-high', entity_category='config'), + # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token + # carries no range hint and only 0 has been observed on hardware), so a + # write above 0 is unverified. + NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'), + exists_fn=_has_option_token('Sleep'), + write_fn=_option_number_write('Sleep'), + native_min=0, native_max=12, step=1, unit='h', + icon='mdi:sleep', entity_category='config'), + # Outdoor temperature, offset by 55. Two calibration points on one unit: + # token 75 while an independent outdoor thermometer in the same install + # read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far + # worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer + # climate, which is also plausible. + SensorDesc(key='outdoor_temperature', + rep_fn=_option_token_num('OutdoorTemp', offset=55), + exists_fn=_has_option_token('OutdoorTemp'), + device_class='temperature', state_class='measurement', + unit='°C', icon='mdi:home-thermometer-outline'), + # Filter time in tenths of an hour: the token read 1710 while the official + # Samsung app displayed "171 hours 0 minutes" for the filter on the same + # unit, and the .0 matching the app's "0 minutes" pins the scale. Whether + # it counts up or down is NOT established -- it was seen rising (171.0 -> + # 171.5) while the unit ran, which contradicts the app's "remaining" + # wording, so the entity is deliberately named neutrally. + SensorDesc(key='filter_time', + rep_fn=_option_token_num('FilterTime', divisor=10), + exists_fn=_has_option_token('FilterTime'), + device_class='duration', unit='h', + state_class='measurement', icon='mdi:air-filter'), ), ) @@ -504,15 +666,23 @@ CURRENT_TEMPERATURE_VS = Capability( # Only the vendor resource's `fivepercentHumidity` (current reading, rounded # to the nearest 5%) has live data on the issue #75 dump -- its `humidity` # field, and the OCF-standard /humidity/0 resource entirely, both read a -# stuck "0" there and stay ignored per the 'don't guess' rule (see -# _AC_IGNORED below). +# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule +# (see _AC_IGNORED below). +# +# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and +# there the plain `humidity` field is not stuck: it carries a real reading +# (51%, matching the same unit's cloud integration at that moment) for as long +# as the Air monitoring option is on, which the unit itself switches back off +# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's +# fallback, and hence 0 reading as "not measuring" rather than 0%: on both +# board generations a zero here means no measurement, never dry air. +# /humidity/0 stayed 0 throughout that same observation too. HUMIDITY = Capability( href='/humidity/vs/0', poll_tier='warm', entities=( - SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity', - device_class='humidity', state_class='measurement', unit='%', - value_fn=_num), + SensorDesc(key='humidity', rep_fn=_humidity, + device_class='humidity', state_class='measurement', unit='%'), ), ) @@ -573,6 +743,12 @@ _AC_IGNORED = [ '/humidity/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', + # OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs + # {speedLevel, direction}, identical values). The climate entity reads and + # writes the vendor form, which is the one confirmed on hardware here -- + # note that air_purifier.py found the opposite ordering on its family, so + # neither form is reliable sight-unseen and this one stays unmodeled. + '/airflow/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. diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index cd03a91..ac60523 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -12,6 +12,7 @@ against live device dumps: """ from datetime import datetime, timezone +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc, @@ -380,32 +381,35 @@ _DEAD_INSTANTANEOUS_POWER = '-500' ENERGY_METER = Capability( href='/energy/consumption/vs/0', entities=( - # `not rep` keeps the empty-{} stub carve-out (see entity._is_included): + # `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included): # an explicit exists_fn otherwise bypasses it, which would drop the - # entity when /device/0 returns a not-yet-fetched stub. On a populated - # rep, hide power only for the dead sentinel or an absent field. + # entity when /device/0 returns a not-yet-fetched stub. A genuinely + # empty {} rep is NOT a stub -- it's the device's confirmed (if empty) + # answer, so it falls through to the normal field/sentinel checks like + # any populated rep. On a populated rep, hide power only for the dead + # sentinel or an absent field. SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', device_class='power', state_class='measurement', unit='W', value_fn=clamp_power, - exists_fn=lambda rep, resources: not rep or ( + exists_fn=lambda rep, resources: is_stub_rep(rep) or ( rep.get('x.com.samsung.da.instantaneousPower') not in (None, _DEAD_INSTANTANEOUS_POWER))), SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', 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.cumulativePower' in rep)), + is_stub_rep(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/ + # both. Self-gates off where only cumulativePower is present. The + # `is_stub_rep(rep) or` keeps the same 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', 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)), + is_stub_rep(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. @@ -413,7 +417,7 @@ ENERGY_METER = Capability( 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)), + is_stub_rep(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. @@ -421,12 +425,12 @@ ENERGY_METER = Capability( 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)), + is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)), SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption', 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)), + is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)), ), ) @@ -499,7 +503,7 @@ 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 + # No is_stub_rep 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 @@ -562,7 +566,7 @@ SELF_CHECK = Capability( icon='mdi:alert-circle-outline', entity_category='diagnostic', exists_fn=lambda rep, resources: ( - not rep or 'x.com.samsung.da.error' in rep), + is_stub_rep(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='', payload='Start', icon='mdi:play-circle-outline', diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py index b76138f..96f1cfd 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -9,6 +9,7 @@ cooktop must not be remotely ignited by an automation. import re +from ..batch import is_stub_rep from ..capability import Capability from ..entities import BinarySensorDesc, SensorDesc @@ -97,7 +98,7 @@ COOKTOP_MODE = Capability( options, f'OperationState{slot}' ), exists_fn=lambda rep, resources, slot=slot: ( - not rep or _option_value( + is_stub_rep(rep) or _option_value( rep.get('x.com.samsung.da.options'), f'OperationState{slot}', ) is not None diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 3e9677a..14e7436 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -590,8 +590,17 @@ FLEX_ZONE = Capability( 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')), + # A nonempty supportedOptions alone isn't sufficient: the + # kimchi-refrigerator family (issue #26) also populates + # /mode/vs/0's modes/supportedOptions with real data, but + # its tokens carry a "_[n]:[n]" parameter suffix on + # supportedOptions that modes never repeats, so no item + # ever overlaps -- the RF9000/Bespoke-class overlap this + # capability was built for never happens there. Require an + # actual resolvable value instead of just a populated + # list, so this stays absent on that family rather than + # showing a select permanently stuck on "unknown". + exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None, rep_fn=_flex_zone_current, write_fn=_flex_zone_write), ), @@ -625,6 +634,105 @@ DOOR_GENERIC = Capability( ), ) +# --------------------------------------------------------------------------- +# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi +# units, issue #26) -- top/middle/bottom each report their own storage mode +# plus a ripening status/timer on /status/kimchi//vs/0, all three in +# an identical shape; modeled as a pattern capability the same way +# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment +# key and {instance_name} from the href's top/middle/bottom segment. Only +# the top compartment's door has been seen reported separately (kimchidoors); +# middle/bottom apparently have no contact switch of their own, so that's +# its own narrower pattern cap rather than assumed universal. +# +# The same state is also mirrored -- packed into single tokens like +# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with +# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads +# for RF9000-class fridges. /status/kimchi//vs/0's plain currentMode/ +# supportMode fields are unpacked and self-describing, so that's what this +# binds to instead. +# +# Write path is unconfirmed (no live write against a real unit) -- same +# "write the same field back to the entity's own href" convention as +# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test. +# +# translations/en.json's kimchi_zone_mode state labels were translated +# directly from the reporter's own (Korean-language) SmartThings app +# screenshots, not guessed from the codes or from their English paraphrase. +# Cross-checking the screenshots against supportMode confirms the on-screen +# option order matches the array order everywhere it's verifiable: the top +# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at +# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the +# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry +# ripening list, and 4-entry custom-storage list each line up 1:1 with their +# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a +# colder or warmer preset around the NORMAL setpoint) everywhere that suffix +# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants, +# which weren't directly screenshotted but share the same NORMAL/COLD/WARM +# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy") +# and BUY (구입, "purchased") are also confirmed exact matches, not +# abbreviation guesses. +# --------------------------------------------------------------------------- + +def _kimchi_mode_write(p, rep, href=None): + if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()): + return None + return [s for s in href.strip('/').split('/') if s], { + 'x.com.samsung.da.currentMode': p, + } + + +KIMCHI_ZONE = Capability( + href=None, + href_prefix='/status/kimchi/', + strip_prefix_in_key=True, + poll_tier='warm', + entities=( + SelectDesc(key='mode', field='x.com.samsung.da.currentMode', + use_instance_name=True, icon='mdi:fridge-outline', + translation_key='kimchi_zone_mode', + entity_category='config', + options_field='x.com.samsung.da.supportMode', + write_fn=_kimchi_mode_write), + SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus', + use_instance_name=True, icon='mdi:progress-clock', + translation_key='kimchi_ripening_status', + entity_category='diagnostic'), + SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime', + use_instance_name=True, icon='mdi:timer-sand', + translation_key='kimchi_ripening_remaining', + entity_category='diagnostic', + # No dump has this nonzero (ripeStatus is always "Off" so + # far) -- device-reported unit unconfirmed, so this stays + # a bare number rather than asserting minutes or hours. + value_fn=_int), + SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount', + use_instance_name=True, icon='mdi:tray-full', + translation_key='kimchi_rack_count', + entity_category='diagnostic', enabled_default=False, + value_fn=_int), + ), +) + +KIMCHI_DOOR_GENERIC = Capability( + href=None, + href_prefix='/kimchidoors/', + strip_prefix_in_key=True, + poll_tier='hot', + entities=( + # Not deduped against DOORS_FALLBACK below: on the one reporter + # (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the + # /doors/vs/0 aggregate carries a single generic item (id "4", no + # /door/ siblings for DOORS_FALLBACK's match_fn to see) + # that doesn't share this compartment's "top" instance numbering -- + # a distinct main-cabinet door, not this kimchi drawer's own contact + # switch reported twice. + BinarySensorDesc(key='open', rep_fn=_door_open_state, + translation_key='instance_open', + use_instance_name=True, device_class='door'), + ), +) + # --------------------------------------------------------------------------- # Aggregate-resource fallbacks # diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py new file mode 100644 index 0000000..7dcb7f5 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -0,0 +1,195 @@ +"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-* +class boards, both combi units and plain microwaves). + +Shares the oven board family's cavity/cook-cycle resource shape +(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`, +`/recipe/cook/vs/0`) -- those Capability objects are reused directly from +oven.py in by_type/microwave.py rather than duplicated. What's genuinely +different from an oven, and defined fresh here: + + * Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/ + KeepWarm never appear on an oven's /mode/vs/0, and this family spells + some shared-sounding modes differently than oven.py's own constants + (e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and + mode list, not oven.OVEN_MODE. + * Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec + (issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's + 30-270 C range (verified against a different, bake-oven-class board). + * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W + on the MicroWave mode's powerListData) that plain ovens don't report -- + exposed as its own sensor. + * Lamp: this family's option-array token is bare 'Lamp' (issue #137's + 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on + the combi dump (issue #121), so it's gated with exists_fn rather than + assumed universal like oven.py's lamp switch. + +Note: cooking-mode writes are unproven here, same caveat as oven.py's +OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write +is also the test. +""" +from ..capability import Capability +from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import int_or_none, normalize_temp_unit +from .laundry import option_value, option_write + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Union of every mode seen across the two known dumps: issue #121's combi +# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/ +# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's +# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown +# every mode below on one device -- the select surfaces whatever a given +# board's own /mode/vs/0 supportedModes reports; an entry here that a device +# never sends just never gets picked. +_MICROWAVE_MODES = ( + 'NoOperation', + 'MicroWave', + 'MicroWaveGrill', + 'MicroWaveConvection', + 'Convection', + 'AirFryer', + 'Grill', + 'Autocook', + 'AutocookCustom', + 'Deodorization', + 'KeepWarm', +) + +# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40, +# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family; +# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified +# against issue #44's range dump), there's nothing to verify a microwave's +# Fahrenheit bounds against, so this module only exposes the setpoint +# control when the live unit is Celsius (see _microwave_temp_unit below). +SETPOINT_MIN_C = 40 +SETPOINT_MAX_C = 200 +SETPOINT_STEP_C = 5 + + +def _microwave_temp_unit(rep): + """Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[] + carries a per-item x.com.samsung.da.unit field. Both known dumps for + this family report 'Celsius'; kept live rather than hardcoded per the + fridge/oven convention (issue #7).""" + items = rep.get('x.com.samsung.da.items') or [] + unit = items[0].get('x.com.samsung.da.unit') if items else None + return normalize_temp_unit(unit, default='°C') + + +def _setpoint_write(p, rep, href=None): + """RMW write to /temperatures/vs/0 items array -- unproven for this + family (no live write confirmed against a real unit), same "exposed for + fidelity" caveat as the mode select.""" + try: + temp = float(p) + except (TypeError, ValueError): + return None + temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C) + if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C): + return None + items = rep.get('x.com.samsung.da.items') + if not items: + return None + items = [dict(it) for it in items] + items[0]['x.com.samsung.da.desired'] = str(temp_i) + return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items} + + +def _power_level_watts(v): + """'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts.""" + if v is None: + return None + s = str(v).strip() + if s.upper().endswith('W'): + s = s[:-1] + return int_or_none(s) + + +def _mode_write(p, rep, href=None): + if p not in _MICROWAVE_MODES: + return None + return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} + + +def _sound_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Sound', p), + } + + +def _lamp_exists(rep, resources): + return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None + + +def _lamp_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Lamp', p), + } + + +# --------------------------------------------------------------------------- +# Capabilities +# --------------------------------------------------------------------------- + +MICROWAVE_CAVITY = Capability( + href='/oven/vs/0', + poll_tier='hot', + entities=( + SensorDesc(key='cavity_state', field='x.com.samsung.da.state'), + SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel', + unit='W', value_fn=_power_level_watts), + ), +) + +MICROWAVE_SETPOINT = Capability( + href='/temperatures/vs/0', + poll_tier='hot', + entities=( + NumberDesc(key='setpoint', field='x.com.samsung.da.items', + device_class='temperature', unit_fn=_microwave_temp_unit, + native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C), + step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', + exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C', + value_fn=lambda items: int_or_none( + (items[0].get('x.com.samsung.da.desired') if items else None)), + write_fn=_setpoint_write), + SensorDesc(key='current_temp_c', field='x.com.samsung.da.items', + device_class='temperature', + state_class='measurement', unit_fn=_microwave_temp_unit, + value_fn=lambda items: int_or_none( + (items[0].get('x.com.samsung.da.current') if items else None))), + ), +) + +MICROWAVE_MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + # SelectDesc first — test_microwave_mode_options_nonempty uses entities[0] + SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes', + icon='mdi:tune', + options=_MICROWAVE_MODES, + value_fn=lambda v: v[0] if v else None, + write_fn=_mode_write), + SwitchDesc(key='sound', field='x.com.samsung.da.options', + icon='mdi:volume-high', + entity_category='config', + value_fn=lambda opts: option_value(opts, 'Sound') == 'On', + write_fn=_sound_write), + SwitchDesc(key='lamp', field='x.com.samsung.da.options', + icon='mdi:track-light', + exists_fn=_lamp_exists, + value_fn=lambda opts: option_value(opts, 'Lamp') == 'On', + write_fn=_lamp_write), + ), +) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 59cebc9..59aa9b7 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -9,9 +9,11 @@ independent fields. from datetime import datetime, timezone +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, + ButtonDesc, FanDesc, SelectDesc, SensorDesc, @@ -99,6 +101,11 @@ HOOD_FAN = Capability( field='x.com.samsung.da.hood.autoOperation', icon='mdi:fan-auto', entity_category='diagnostic', + # Absent on the microwave family's built-in vent fan (issue + # #137) -- this board has no auto-ventilation mode, unlike the + # standalone range hood this capability was written for. + exists_fn=lambda rep, resources: ( + is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep), value_fn=lambda value: str(value).lower() == 'on', ), ), @@ -179,6 +186,50 @@ HOOD_FILTER = Capability( ) +# After Run (issue #147): the hood keeps the fan running at low speed for a +# while after it's switched off, to clear residual cooking smoke -- a +# feature a user actively watches and cancels, not passive diagnostics, so +# none of the three entities below carry entity_category. No +# supported-values list is advertised for activationState, so it's modeled +# read-only (monitoring, not an invented "enable" write) per the 'don't +# guess' rule; runningCancel's only observed value is the command name +# itself ('Cancel'), the same self-describing command-field shape as +# operational.STOP_BUTTON. runningProgress's own name states its domain +# (a percentage of the cycle completed), so it's modeled as one rather than +# left an opaque passthrough -- unlike activationState/runningCancel, there's +# no ambiguous field name or missing-write-contract question here to hedge on. +AFTER_RUN = Capability( + href='/afterrun/vs/0', + poll_tier='warm', + entities=( + BinarySensorDesc( + key='after_run_active', + field='x.com.samsung.da.activationState', + icon='mdi:fan-clock', + value_fn=lambda value: str(value).lower() == 'on', + ), + SensorDesc( + key='after_run_progress', + field='x.com.samsung.da.runningProgress', + unit='%', + state_class='measurement', + icon='mdi:fan-clock', + value_fn=int_or_none, + ), + ButtonDesc( + key='after_run_cancel', + field='', + payload='Cancel', + icon='mdi:fan-off', + write_fn=lambda p, rep, href=None: ( + ['afterrun', 'vs', '0'], + {'x.com.samsung.da.runningCancel': p}, + ), + ), + ), +) + + AIR_QUALITY = Capability( href='/sensors/vs/0', poll_tier='warm', diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 953cd3d..019517c 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -102,20 +102,64 @@ FAVORITE_CAPACITY = Capability( ), ) -# Coffee-capable variant (issue #107) -- same "favorite" enable-toggle + -# supported-list select shape as FAVORITE_CAPACITY above, but for the hot -# water dispensed alongside brewing rather than the pour capacity. +# Coffee-capable variant (issue #107) -- a "favorite" supported-list select +# for the hot water dispensed alongside brewing, same shape as +# FAVORITE_CAPACITY above. + + +def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool: + """Three-way read of /status/lock/vs/0's hotwaterLock field, favouring + LOCK.hotwater_lock (the primary descriptor) whenever the outcome is + still ambiguous: + + - href entirely absent from this device -> definitely no clash, the + switchHotwater fallback below may claim the entity. + - href present but an unfetched stub ({}) -> outcome pending, *not* a + confirmed absence. LOCK's own exists_fn optimistically includes itself + through a stub (matching entity.py's default), so returning True here + too would register both descriptors -- as SwitchDescs sharing one key, + with identical unique_ids -- until the next poll resolves it. + - href present and fetched -> the real answer.""" + rep = resources.get('/status/lock/vs/0') + if rep is None: + return True + if not rep: + return False + return 'x.com.samsung.da.hotwaterLock' not in rep + + FAVORITE_HOTWATER = Capability( href='/favorite/hotwater/vs/0', poll_tier='cold', entities=( - SwitchDesc(key='favorite_hotwater_enabled', field='x.com.samsung.da.switchHotwater', - icon='mdi:star-outline', + # Despite the resource/field naming, switchHotwater's value domain is + # Locked/Unlocked, not an enable flag (issue #144) -- it's the same + # hot-water lock as LOCK.hotwater_lock below, just surfaced through + # this href on boards that don't populate /status/lock/vs/0's + # hotwaterLock field. Shares that descriptor's key so only one "Hot + # water lock" entity ever appears. + # + # Both halves of this fallback pair need an exists_fn, not just this + # one: adapter.flatten() (the coordinator.data source every entity's + # is_on reads) only ever honours exists_fn, never entity.py's + # implicit "require own field present" default that gates plain + # registration. Two same-keyed descriptors with only one of them + # gated still both land in flatten()'s output dict -- whichever is + # processed last silently wins, decided by device-reported href + # order, not by which one is actually correct. So this exists_fn + # also re-asserts its own field's presence (switchHotwater), the + # gate a bare `field=` used to get for free before it had to share a + # key with LOCK's descriptor. + SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater', + device_class='lock', entity_category='config', - value_fn=lambda v: v != 'Locked', + value_fn=lambda v: v != 'Unlocked', + exists_fn=lambda rep, resources: ( + 'x.com.samsung.da.switchHotwater' in rep + and _status_lock_definitely_lacks_hotwater_field(resources)), write_fn=lambda p, rep, href=None: ( ['favorite', 'hotwater', 'vs', '0'], - {'x.com.samsung.da.switchHotwater': 'Unlocked' if p == 'On' else 'Locked'})), + {'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})), SelectDesc(key='favorite_hotwater_temperature', field='x.com.samsung.da.favorite.defaultTemperature', icon='mdi:thermometer', @@ -150,10 +194,19 @@ LOCK = Capability( href='/status/lock/vs/0', poll_tier='warm', entities=( + # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback + # above (issue #144); see the comment there for why this half also + # needs an explicit exists_fn now that the two share a key in + # adapter.flatten()'s output. A stub rep ({}) still counts as + # "present" here (matches entity.py's own default for a field-less + # gate) since the alternative -- treating an unfetched resource as + # confirmed-absent -- is what let both descriptors register at once. SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock', device_class='lock', entity_category='config', value_fn=lambda v: v != 'Unlocked', + exists_fn=lambda rep, resources: ( + not rep or 'x.com.samsung.da.hotwaterLock' in rep), write_fn=lambda p, rep, href=None: ( ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4c8cf80..b311177 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1,6 +1,9 @@ { "entity": { "binary_sensor": { + "after_run_active": { + "name": "After run active" + }, "any_burner_active": { "name": "Any burner active" }, @@ -93,6 +96,9 @@ } }, "button": { + "after_run_cancel": { + "name": "Cancel after run" + }, "diagnosis_start": { "name": "Start diagnosis" }, @@ -128,7 +134,8 @@ "longwind": "Long wind", "motionindirect": "Motion indirect", "motiondirect": "Motion direct", - "drycomfort": "Dry comfort" + "drycomfort": "Dry comfort", + "2step": "2-Step" } } } @@ -150,6 +157,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Beep volume" + }, "cook_time": { "name": "Cook time" }, @@ -159,12 +169,18 @@ "dispense_capacity": { "name": "Dispense capacity" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, "oven_setpoint": { "name": "Setpoint" }, + "setpoint": { + "name": "Setpoint" + }, "sound_volume": { "name": "Sound volume" }, @@ -350,6 +366,22 @@ "high": "High" } }, + "cooking_mode": { + "name": "Cooking mode", + "state": { + "no_operation": "No operation", + "micro_wave": "Microwave", + "micro_wave_grill": "Microwave + grill", + "micro_wave_convection": "Microwave + convection", + "convection": "Convection", + "air_fryer": "Air fry", + "grill": "Grill", + "autocook": "Auto cook", + "autocook_custom": "Auto cook (custom)", + "deodorization": "Deodorize", + "keep_warm": "Keep warm" + } + }, "oven_mode": { "name": "Cooking mode", "state": { @@ -368,6 +400,37 @@ "operating_mode": { "name": "Operating mode" }, + "kimchi_zone_mode": { + "name": "{instance_name} storage mode", + "state": { + "off": "Off", + "kimchi_storage_normal": "Kimchi", + "kimchi_storage_cold": "Kimchi, strong", + "kimchi_storage_warm": "Kimchi, weak", + "kimchi_storage_low_salt_normal": "Low-salt kimchi", + "kimchi_storage_low_salt_cold": "Low-salt kimchi, strong", + "kimchi_storage_low_salt_warm": "Low-salt kimchi, weak", + "kimchi_storage_crunfch": "Crisp kimchi", + "kimchi_storage_buy": "Purchased kimchi", + "storage_fridge_normal": "Fridge", + "storage_fridge_cold": "Fridge, strong", + "storage_fridge_warm": "Fridge, weak", + "storage_freezer_normal": "Freezer", + "storage_freezer_cold": "Freezer, strong", + "storage_freezer_warm": "Freezer, weak", + "kimchi_ripe_low_temp": "Kimchi ripening, low temperature", + "kimchi_ripe_normal_temp": "Kimchi ripening, room temperature", + "kimchi_ripe_kkakdugi": "Kkakdugi ripening", + "kimchi_ripe_dongchimi": "Dongchimi ripening", + "meat_ripe_normal": "Meat ripening", + "storage_meat": "Meat & fish", + "storage_fridge_vegetables_fruit": "Fruit & vegetables", + "storage_fresh_cereal": "Grains", + "storage_fridge_drink": "Beverages", + "storage_fresh_wine": "Wine", + "storage_fresh_potato_banana": "Potato & banana" + } + }, "pantry_zone_mode": { "name": "Pantry zone mode", "state": { @@ -516,6 +579,9 @@ } }, "sensor": { + "after_run_progress": { + "name": "After run progress" + }, "air_filter_usage": { "name": "Filter usage" }, @@ -559,9 +625,15 @@ "current_limit_level": { "name": "Current limit level" }, + "filter_time": { + "name": "Filter time" + }, "hepa_filter_usage": { "name": "HEPA filter usage" }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, "panel_status": { "name": "Panel status" }, @@ -668,6 +740,15 @@ "fridge_temperature": { "name": "Fridge temperature" }, + "kimchi_ripening_status": { + "name": "{instance_name} ripening status" + }, + "kimchi_ripening_remaining": { + "name": "{instance_name} ripening time remaining" + }, + "kimchi_rack_count": { + "name": "{instance_name} rack count" + }, "hood_filter_capacity": { "name": "Filter capacity" }, @@ -710,6 +791,12 @@ "oven_state": { "name": "Cavity state" }, + "cavity_state": { + "name": "Cavity state" + }, + "power_level": { + "name": "Power level" + }, "paired_hood_fan_speed": { "name": "Paired hood fan speed" }, @@ -801,6 +888,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Air monitoring" + }, "air_purify": { "name": "Air purification" }, @@ -822,6 +912,9 @@ "dustbin_auto_close": { "name": "Dustbin auto-close" }, + "spi": { + "name": "Purifying ion" + }, "uvc_intensive_mode": { "name": "UV-C intensive mode" }, @@ -870,9 +963,6 @@ "favorite_coffee_enabled": { "name": "Favorite coffee" }, - "favorite_hotwater_enabled": { - "name": "Favorite hot water" - }, "fridge_sound": { "name": "Sound" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b74eb7d..fdccfc1 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1,6 +1,9 @@ { "entity": { "binary_sensor": { + "after_run_active": { + "name": "Nadraaien actief" + }, "any_burner_active": { "name": "Een brander actief" }, @@ -93,6 +96,9 @@ } }, "button": { + "after_run_cancel": { + "name": "Nadraaien annuleren" + }, "diagnosis_start": { "name": "Diagnose starten" }, @@ -128,7 +134,8 @@ "longwind": "Lange wind", "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", - "drycomfort": "Droog comfort" + "drycomfort": "Droog comfort", + "2step": "2-Step" } } } @@ -150,6 +157,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Zoemervolume" + }, "cook_time": { "name": "Bereidingstijd" }, @@ -159,12 +169,18 @@ "dispense_capacity": { "name": "Schenkhoeveelheid" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, "oven_setpoint": { "name": "Instelpunt" }, + "setpoint": { + "name": "Instelpunt" + }, "sound_volume": { "name": "Geluidsvolume" }, @@ -350,6 +366,22 @@ "high": "Hoog" } }, + "cooking_mode": { + "name": "Bereidingsmodus", + "state": { + "no_operation": "Niet actief", + "micro_wave": "Magnetron", + "micro_wave_grill": "Magnetron + grill", + "micro_wave_convection": "Magnetron + hetelucht", + "convection": "Hetelucht", + "air_fryer": "Airfryen", + "grill": "Grillen", + "autocook": "Automatisch koken", + "autocook_custom": "Automatisch koken (aangepast)", + "deodorization": "Geurverwijdering", + "keep_warm": "Warmhouden" + } + }, "oven_mode": { "name": "Bereidingsmodus", "state": { @@ -368,6 +400,37 @@ "operating_mode": { "name": "Bedrijfsmodus" }, + "kimchi_zone_mode": { + "name": "{instance_name} opslagmodus", + "state": { + "off": "Uit", + "kimchi_storage_normal": "Kimchi", + "kimchi_storage_cold": "Kimchi, sterk", + "kimchi_storage_warm": "Kimchi, zwak", + "kimchi_storage_low_salt_normal": "Kimchi met weinig zout", + "kimchi_storage_low_salt_cold": "Kimchi met weinig zout, sterk", + "kimchi_storage_low_salt_warm": "Kimchi met weinig zout, zwak", + "kimchi_storage_crunfch": "Knapperige kimchi", + "kimchi_storage_buy": "Gekochte kimchi", + "storage_fridge_normal": "Koelkast", + "storage_fridge_cold": "Koelkast, sterk", + "storage_fridge_warm": "Koelkast, zwak", + "storage_freezer_normal": "Vriezer", + "storage_freezer_cold": "Vriezer, sterk", + "storage_freezer_warm": "Vriezer, zwak", + "kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur", + "kimchi_ripe_normal_temp": "Kimchi rijpen, kamertemperatuur", + "kimchi_ripe_kkakdugi": "Kkakdugi rijpen", + "kimchi_ripe_dongchimi": "Dongchimi rijpen", + "meat_ripe_normal": "Vlees rijpen", + "storage_meat": "Vlees en vis", + "storage_fridge_vegetables_fruit": "Groenten en fruit", + "storage_fresh_cereal": "Granen", + "storage_fridge_drink": "Dranken", + "storage_fresh_wine": "Wijn", + "storage_fresh_potato_banana": "Aardappelen en bananen" + } + }, "pantry_zone_mode": { "name": "Modus voorraadzone", "state": { @@ -516,6 +579,9 @@ } }, "sensor": { + "after_run_progress": { + "name": "Voortgang nadraaien" + }, "air_filter_usage": { "name": "Filterverbruik" }, @@ -559,9 +625,15 @@ "current_limit_level": { "name": "Niveau stroombegrenzing" }, + "filter_time": { + "name": "Filtertijd" + }, "hepa_filter_usage": { "name": "HEPA-filtergebruik" }, + "outdoor_temperature": { + "name": "Buitentemperatuur" + }, "panel_status": { "name": "Paneelstatus" }, @@ -668,6 +740,15 @@ "fridge_temperature": { "name": "Koelkasttemperatuur" }, + "kimchi_ripening_status": { + "name": "{instance_name} rijpingsstatus" + }, + "kimchi_ripening_remaining": { + "name": "{instance_name} resterende rijptijd" + }, + "kimchi_rack_count": { + "name": "{instance_name} aantal rekken" + }, "hood_filter_capacity": { "name": "Filtercapaciteit" }, @@ -710,6 +791,12 @@ "oven_state": { "name": "Status ovenruimte" }, + "cavity_state": { + "name": "Status ovenruimte" + }, + "power_level": { + "name": "Vermogensniveau" + }, "paired_hood_fan_speed": { "name": "Ventilatorsnelheid gekoppelde afzuigkap" }, @@ -801,6 +888,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Luchtmonitoring" + }, "air_purify": { "name": "Luchtzuivering" }, @@ -822,6 +912,9 @@ "dustbin_auto_close": { "name": "Stofreservoir automatisch sluiten" }, + "spi": { + "name": "Ionisatie" + }, "uvc_intensive_mode": { "name": "Intensieve UV-C-modus" }, @@ -870,9 +963,6 @@ "favorite_coffee_enabled": { "name": "Favoriete koffie" }, - "favorite_hotwater_enabled": { - "name": "Favoriet warm water" - }, "fridge_sound": { "name": "Geluid" }, diff --git a/tests/fixtures/airconditioner_artik051_krac_18k_device.json b/tests/fixtures/airconditioner_artik051_krac_18k_device.json new file mode 100644 index 0000000..a607a2c --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_krac_18k_device.json @@ -0,0 +1,232 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/0", + "rep": { + "speed": 3, + "direction": "Fix" + } + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "3", + "x.com.samsung.da.direction": "Fix" + } + }, + { + "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-28T00:18:15", + "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-28T00:18:15", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000", + "x.com.samsung.da.description": "ARTIK051_KRAC_18K", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02016A200825", + "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": "18020800,17120500", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Comode_Off", + "Sleep_0", + "OutdoorTemp_74", + "CoolCapa_25", + "WarmCapa_32", + "Spi_Off", + "Autoclean_Off", + "Light_Off", + "Volume_100", + "AirMonitoring_Off", + "AutocleanProgress_1", + "StopAutoClean_Idle", + "FilterTime_1715", + "FilterAlarmTime_500", + "OptionCode_35882", + "ExtendOptionCode_7", + "RacInfo_None", + "UpdateAllow_NotAllowed" + ] + } + }, + { + "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": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 21.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 20.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "20", + "x.com.samsung.da.current": "21", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json index 91ce610..05e3410 100644 --- a/tests/fixtures/golden/air_purifier.json +++ b/tests/fixtures/golden/air_purifier.json @@ -1,24 +1,18 @@ { "state_keys": [ + "airflow_fan", "alarm_code", "clean_level", "device_active", "diagnosis_status", "display_light", "dust", - "energy_kwh", - "energy_last_month_kwh", - "energy_saved_kwh", - "energy_this_month_kwh", "fan_direction", - "fan_speed_level", "filter_progress", "fine_dust", "odor", "operating_mode", - "power_energy_kwh", "power_switch", - "power_watts", "super_fine_dust" ] } diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json new file mode 100644 index 0000000..c7fabfb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_monitoring", + "alarm_code", + "auto_clean_legacy", + "buzzer_volume", + "climate", + "current_temperature_c", + "diagnosis_status", + "display_light", + "energy_kwh", + "filter_time", + "good_sleep", + "humidity", + "outdoor_temperature", + "spi" + ] +} diff --git a/tests/fixtures/golden/microwave_me7500d.json b/tests/fixtures/golden/microwave_me7500d.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "cavity_state", + "child_lock", + "cloud_connected", + "cook_time", + "cooking_mode", + "cycle_active", + "door_open", + "energy_kwh", + "fan", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "operation_time_minutes", + "power_level", + "progress_percentage", + "sound" + ] +} diff --git a/tests/fixtures/golden/oven_mw7300b.json b/tests/fixtures/golden/microwave_mw7300b.json similarity index 75% rename from tests/fixtures/golden/oven_mw7300b.json rename to tests/fixtures/golden/microwave_mw7300b.json index 435dec4..f648bde 100644 --- a/tests/fixtures/golden/oven_mw7300b.json +++ b/tests/fixtures/golden/microwave_mw7300b.json @@ -1,25 +1,23 @@ { "state_keys": [ "alarm_code", + "cavity_state", "child_lock", "cloud_connected", "cook_time", + "cooking_mode", "current_temp_c", "cycle_active", "door_open", "energy_kwh", - "fast_preheat", "finish_time", "firmware_update", - "lamp", "machine_state", - "natural_steam", "operation_time_minutes", - "oven_mode", - "oven_setpoint", - "oven_state", + "power_level", "progress_percentage", "remote_control", + "setpoint", "sound" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/golden/range_hood.json b/tests/fixtures/golden/range_hood.json index 2753cb3..f7f0d8b 100644 --- a/tests/fixtures/golden/range_hood.json +++ b/tests/fixtures/golden/range_hood.json @@ -1,5 +1,7 @@ { "state_keys": [ + "after_run_active", + "after_run_progress", "air_sensing_state", "alarm_code", "auto_ventilation_action", diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json index 6174968..6c50fe9 100644 --- a/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json +++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json @@ -5,16 +5,10 @@ "defrost_delay", "diagnosis_status", "door_onedoorfreezer_open", - "energy_kwh", - "energy_last_month_kwh", - "energy_saved_kwh", - "energy_this_month_kwh", "firmware_update", "freezer_setpoint", "freezer_temperature", "ice_maker_enabled", - "power_energy_kwh", - "power_watts", "rapid_freezing", "rapid_fridge", "sabbath_mode" diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json index 984569c..44d2d96 100644 --- a/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json +++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json @@ -8,14 +8,8 @@ "diagnosis_status", "door_cooler_open", "door_onedoorfreezer_open", - "energy_kwh", - "energy_last_month_kwh", - "energy_saved_kwh", - "energy_this_month_kwh", "firmware_update", "ice_maker_enabled", - "power_energy_kwh", - "power_watts", "rapid_freezing", "rapid_fridge", "sabbath_mode" diff --git a/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json new file mode 100644 index 0000000..e456a64 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "bottom_mode", + "bottom_rack_count", + "bottom_ripening_remaining", + "bottom_ripening_status", + "diagnosis_status", + "door_open", + "energy_kwh", + "firmware_update", + "middle_mode", + "middle_rack_count", + "middle_ripening_remaining", + "middle_ripening_status", + "power_energy_kwh", + "power_watts", + "top_mode", + "top_open", + "top_rack_count", + "top_ripening_remaining", + "top_ripening_status" + ] +} diff --git a/tests/fixtures/golden/water_purifier_coffee.json b/tests/fixtures/golden/water_purifier_coffee.json index 3b8003b..42a5035 100644 --- a/tests/fixtures/golden/water_purifier_coffee.json +++ b/tests/fixtures/golden/water_purifier_coffee.json @@ -9,7 +9,6 @@ "favorite_capacity", "favorite_capacity_enabled", "favorite_coffee_enabled", - "favorite_hotwater_enabled", "favorite_hotwater_temperature", "filter_clean_remain_time", "filter_door_status", diff --git a/tests/fixtures/microwave_me7500d_device.json b/tests/fixtures/microwave_me7500d_device.json new file mode 100644 index 0000000..d37c591 --- /dev/null +++ b/tests/fixtures/microwave_me7500d_device.json @@ -0,0 +1,201 @@ +{ + "device0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-23-MICROWAVE-OTR", + "versions": ["40241114"], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "07424047334140473241", + "versions": ["24071000", "23120700"], + "visVersion": "240710" + }, + { + "type": "Micom", + "modelId": "074240475443FFFFFFFF", + "versions": ["23120800", "FFFFFFFF"], + "visVersion": "231208" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": ["x.com.samsung.da.connected"], + "if": ["oic.if.baseline", "oic.if.s"] + } + }, + { + "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" + } + ], + "rt": ["x.com.samsung.da.doors"], + "if": ["oic.if.baseline", "oic.if.s"] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000", + "x.com.samsung.da.description": "ME7500D-/AA1", + "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": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "24111400", + "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": "04733A24071000, 04732A23120700", + "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.number": "04754C23120800", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "KM5", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"], + "x.com.samsung.da.modes": ["NoOperation"], + "x.com.samsung.da.options": [ + "DeviceType_ME7500D-/AA1", + "TimeAutoSync_On", + "weight_LBS", + "TimeSystem_12", + "Sound_On", + "RemindBeep_On", + "FilterRemind_Off", + "Lamp_Off" + ], + "x.com.samsung.da.defaultMode": "MicroWave", + "rt": ["x.com.samsung.da.mode"], + "if": ["oic.if.baseline", "oic.if.a"] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "0", + "rt": ["x.com.samsung.da.operation"], + "if": ["oic.if.baseline", "oic.if.a"] + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": ["x.com.samsung.da.alarms"], + "if": ["oic.if.baseline", "oic.if.s"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-27T20:03:10" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0", + "rt": ["x.com.samsung.da.oven"], + "if": ["oic.if.baseline", "oic.if.s"] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/New_York", + "offset": "-04:00", + "DST": "ON" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "3400", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/hood/fanspeed/vs/0", + "rep": { + "x.com.samsung.da.hood.fanSpeed": "0", + "x.com.samsung.da.hood.supportedFanSpeed": ["0", "1", "2", "3", "4"], + "x.com.samsung.da.hood.settableMaxFanSpeed": "4", + "x.com.samsung.da.hood.settableMinFanSpeed": "0" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/oven_mw7300b_device.json b/tests/fixtures/microwave_mw7300b_device.json similarity index 100% rename from tests/fixtures/oven_mw7300b_device.json rename to tests/fixtures/microwave_mw7300b_device.json diff --git a/tests/fixtures/range_hood_device.json b/tests/fixtures/range_hood_device.json index 88607e6..cea8e2b 100644 --- a/tests/fixtures/range_hood_device.json +++ b/tests/fixtures/range_hood_device.json @@ -160,6 +160,14 @@ "href": "/da/softreset/vs/0", "rep": {"x.com.samsung.da.softwarereset": "false"} }, + { + "href": "/afterrun/vs/0", + "rep": { + "x.com.samsung.da.runningProgress": "0", + "x.com.samsung.da.runningCancel": "Cancel", + "x.com.samsung.da.activationState": "Off" + } + }, { "href": "/filter/hoodfilter/vs/0", "rep": { diff --git a/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json new file mode 100644 index 0000000..a1d4a28 --- /dev/null +++ b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json @@ -0,0 +1,274 @@ +{ + "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": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "5", + "x.com.samsung.da.cumulativePower": "1733313", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "61", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "x.com.samsung.da.modes": [ + "KIMCHIT_STORAGE_FREEZER_NORMAL", + "KIMCHIT_RIPE_REMAIN_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL", + "KIMCHIM_RIPE_REMAIN_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL", + "KIMCHIB_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[6]", + "KIMCHIM_BOX_COUNT_[4]", + "KIMCHIB_BOX_COUNT_[2]", + "KIMCHI_BOX_COUNT_[0]" + ], + "x.com.samsung.da.supportedOptions": [ + "KIMCHIT_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_NORMAL_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_COLD_[0]:[0]", + "KIMCHIT_STORAGE_FRIDGE_WARM_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_COLD_[0]:[0]", + "KIMCHIT_STORAGE_FREEZER_WARM_[0]:[0]", + "KIMCHIT_OFF_[0]:[0]", + "KIMCHIT_KIMCHI_RIPE_LOW_TEMP_[5]:[17]", + "KIMCHIT_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_CRUNFCH_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_BUY_[0]:[0]", + "KIMCHIM_MEAT_RIPE_NORMAL_[3]:[0]", + "KIMCHIM_STORAGE_MEAT_[0]:[0]", + "KIMCHIM_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]", + "KIMCHIM_STORAGE_FRESH_CEREAL_[0]:[0]", + "KIMCHIM_OFF_[0]:[0]", + "KIMCHIM_KIMCHI_RIPE_LOW_TEMP_[5]:[17]", + "KIMCHIM_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_CRUNFCH_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_BUY_[0]:[0]", + "KIMCHIB_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]", + "KIMCHIB_STORAGE_FRIDGE_DRINK_[0]:[0]", + "KIMCHIB_STORAGE_FRESH_WINE_[0]:[0]", + "KIMCHIB_STORAGE_FRESH_POTATO_BANANA_[0]:[0]", + "KIMCHIB_OFF_[0]:[0]", + "KIMCHIB_KIMCHI_RIPE_KKAKDUGI_[4]:[5]", + "KIMCHIB_KIMCHI_RIPE_DONGCHIMI_[7]:[6]" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "modes": [ + "KIMCHIT_STORAGE_FREEZER_NORMAL", + "KIMCHIT_RIPE_REMAIN_[0]:[0]", + "KIMCHIM_KIMCHI_STORAGE_NORMAL", + "KIMCHIM_RIPE_REMAIN_[0]:[0]", + "KIMCHIB_KIMCHI_STORAGE_NORMAL", + "KIMCHIB_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[6]", + "KIMCHIM_BOX_COUNT_[4]", + "KIMCHIB_BOX_COUNT_[2]", + "KIMCHI_BOX_COUNT_[0]" + ] + } + }, + { + "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|00135941|10010221011411010103642021000000", + "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": "20082109,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/kimchidoors/top/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/status/kimchi/top/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "STORAGE_FREEZER_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "6", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "STORAGE_FRIDGE_NORMAL", + "STORAGE_FRIDGE_COLD", + "STORAGE_FRIDGE_WARM", + "STORAGE_FREEZER_NORMAL", + "STORAGE_FREEZER_COLD", + "STORAGE_FREEZER_WARM", + "OFF", + "KIMCHI_RIPE_LOW_TEMP", + "KIMCHI_RIPE_NORMAL_TEMP" + ] + } + }, + { + "href": "/status/kimchi/middle/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "4", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "KIMCHI_STORAGE_CRUNFCH", + "KIMCHI_STORAGE_BUY", + "MEAT_RIPE_NORMAL", + "STORAGE_MEAT", + "STORAGE_FRIDGE_VEGETABLES_FRUIT", + "STORAGE_FRESH_CEREAL", + "OFF", + "KIMCHI_RIPE_LOW_TEMP", + "KIMCHI_RIPE_NORMAL_TEMP" + ] + } + }, + { + "href": "/status/kimchi/bottom/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "2", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_LOW_SALT_NORMAL", + "KIMCHI_STORAGE_LOW_SALT_COLD", + "KIMCHI_STORAGE_LOW_SALT_WARM", + "KIMCHI_STORAGE_CRUNFCH", + "KIMCHI_STORAGE_BUY", + "STORAGE_FRIDGE_VEGETABLES_FRUIT", + "STORAGE_FRIDGE_DRINK", + "STORAGE_FRESH_WINE", + "STORAGE_FRESH_POTATO_BANANA", + "OFF", + "KIMCHI_RIPE_KKAKDUGI", + "KIMCHI_RIPE_DONGCHIMI" + ] + } + } + ] +} diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 924933d..e9e6f45 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -11,9 +11,12 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( - CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, + CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE, + SUMMARY_INTERVAL_S, +) +from custom_components.localthings.coordinator import ( + LocalThingsCoordinator, _local_source_port, ) -from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.common import ( remote_control_enabled, remote_control_required_for_write, @@ -1174,3 +1177,32 @@ async def test_send_command_operational_still_blocked_when_without_sc( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == 'remote_control_disabled' assert posted is False + + +def test_local_source_port_stable_and_in_range() -> None: + """Same host always maps to the same port (stability is the whole point: + a reconnect must reuse the 5-tuple), and it stays in the documented window.""" + for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'): + port = _local_source_port(host) + assert port == _local_source_port(host) + assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF + + +def test_local_source_port_unique_per_host_on_a_24() -> None: + """Distinct last octets -> distinct ports, so two devices on the same HA + host never share a source port (the lib's socket is unconnected, so a + shared port would cross-deliver datagrams).""" + ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)} + assert len(ports) == 254 + + +def test_local_source_port_ipv4_uses_last_octet() -> None: + """The IPv4 fast path is the last octet, not a hash.""" + assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217 + + +def test_local_source_port_non_ipv4_falls_back_to_hash() -> None: + """A non-IPv4 host still yields a deterministic in-range port.""" + port = _local_source_port('some-hostname') + assert port == _local_source_port('some-hostname') + assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF diff --git a/tests/test_air_purifier_airflow_fan.py b/tests/test_air_purifier_airflow_fan.py new file mode 100644 index 0000000..f92b0d3 --- /dev/null +++ b/tests/test_air_purifier_airflow_fan.py @@ -0,0 +1,105 @@ +"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56).""" + +from custom_components.localthings.fan import LocalThingsAirflowFan +from custom_components.localthings.registry.by_type import air_purifier +from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import FanDesc +from tests.conftest import _load_device + + +class _FakeCoordinator: + device_serial = 'TEST-AIRFLOW-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _entity(resources, coordinator=None): + bound = discover( + resources, + air_purifier.REGISTRY.capabilities, + air_purifier.REGISTRY.pattern_capabilities, + ) + fan_bound = next( + item for item in bound + if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW + ) + return LocalThingsAirflowFan( + coordinator or _FakeCoordinator(resources), fan_bound, + ) + + +def test_power_off_maps_to_zero_percent_and_five_retained_speeds(): + entity = _entity(_load_device('air_purifier')) + assert entity.is_on is False + assert entity.speed_count == 5 + assert entity.percentage == 0 + + +def test_active_codes_map_to_ordered_percentages(): + """Confirmed via issue #56's second, properly-spaced diagnostics round: + /airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High.""" + resources = _load_device('air_purifier') + resources['/power/0']['value'] = True + + for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)): + resources['/airflow/0']['speed'] = code + assert _entity(resources).percentage == expected_percentage + + +async def test_power_write_prefers_standard_resource_when_both_exist(): + """Both /power/0 and /power/vs/0 are present on this family's dumps -- + /power/0 must win, matching common.POWER_GENERIC's own preference (the + power_switch entity is unconditionally bound to /power/0 whenever it's + present, so writing here to /power/vs/0 first would leave the two + entities disagreeing until the next poll).""" + resources = _load_device('air_purifier') + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands[-1][1] == ('power', True, '/power/0') + + +async def test_power_write_falls_back_to_vendor_resource(): + resources = _load_device('air_purifier') + resources.pop('/power/0') + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0') + + +async def test_set_percentage_writes_raw_speed_code(): + resources = _load_device('air_purifier') + resources['/power/0']['value'] = True + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_set_percentage(60) + + assert coordinator.commands[-1][1] == ('speed', 2) + + +async def test_set_percentage_zero_turns_off(): + resources = _load_device('air_purifier') + resources['/power/0']['value'] = True + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_set_percentage(0) + + assert coordinator.commands[-1][1] == ('power', False, '/power/0') diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 7f483c8..8e85228 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -41,7 +41,7 @@ def test_expected_entities_present(): for key in ( 'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust', 'odor', 'clean_level', 'filter_progress', 'device_active', - 'diagnosis_status', 'fan_speed_level', 'fan_direction', + 'diagnosis_status', 'airflow_fan', 'fan_direction', 'display_light', 'operating_mode', ): assert key in state, key @@ -114,3 +114,27 @@ def test_airflow_vs_fallback_only_binds_without_generic(): {}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}}, ) is False assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True + + +def test_airflow_fan_write_contract(): + """Confirmed via issue #56's second, properly-spaced diagnostics round + (two independent units, 60-90s apart per setting): /airflow/0's `speed` + is a clean, monotonic 0-4 code, so the write is a plain int passthrough + -- no named-preset table needed (see fan.py's LocalThingsAirflowFan).""" + fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'airflow_fan') + assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3}) + assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}, + ) + assert fan_desc.write_fn(('power', False, '/power/0'), {}) == ( + ['power', '0'], {'value': False}, + ) + + +def test_airflow_speed_reads_from_dump(): + """The fixture's /airflow/0 (power-off snapshot) reads speed=0, and + fan_direction stays a plain diagnostic (every dump seen reads 'Off' + regardless of fan setting).""" + state = _state() + assert state['airflow_fan'] == 0 + assert state['fan_direction'] == 'Off' diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py new file mode 100644 index 0000000..2e71e89 --- /dev/null +++ b/tests/test_airconditioner_artik051_krac.py @@ -0,0 +1,234 @@ +"""ARTIK051_KRAC_18K room air conditioner (issue #136). + +This board generation predates every AC dump the registry was built from and +differs from all of them in three ways, each covered below: + +* No ``/wind/*`` resources at all -- fan speed and vane direction share one + ``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as + ``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``). +* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*`` + token in ``/mode/vs/0``'s ``options`` array. +* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep, + outdoor temperature, filter time) are ``options`` tokens too, where newer + boards have dedicated resources. + +Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware +by read-back on the unit this fixture is dumped from. The issue #136 unit is +the same model with a slightly different token set (no ``Spi``, +``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence +gating handles the same way it handles newer boards. +""" +from custom_components.localthings.climate import LocalThingsClimate +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import ( + airconditioner, for_device_by_model, +) +from custom_components.localthings.registry.capabilities.airconditioner import ( + HREF_AIRFLOW, _option_number_write, _option_switch_write, +) +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_artik051_krac_18k' +MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000' + + +class _FakeCoordinator: + device_serial = 'TEST-KRAC-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _discover(resources, registry=airconditioner.REGISTRY): + unbound = [] + bound = discover(resources, registry.capabilities, + registry.pattern_capabilities, log=unbound.append) + return bound, unbound + + +def _state(fixture=FIXTURE): + resources = _load_device(fixture) + bound, _ = _discover(resources) + return flatten(bound, resources) + + +def _climate(resources, coordinator=None): + bound, _ = _discover(resources) + climate_bound = next( + item for item in bound if isinstance(item.desc, ClimateDesc) + ) + return LocalThingsClimate( + coordinator or _FakeCoordinator(resources), climate_bound, + ) + + +# -- device type -------------------------------------------------------------- + +def test_krac_model_resolves_to_the_airconditioner_registry(): + """The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the + underscore and 'RAC' -- and the consumer-prefix fallback only covers + washers/dryers/dishwashers, so this model resolved to 'unknown' and + exposed nothing but power.""" + registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K') + assert registry is not None + assert registry.name == 'airconditioner' + + +def test_no_unbound_hrefs(): + _, unbound = _discover(_load_device(FIXTURE)) + assert unbound == [] + + +# -- option-token entities ---------------------------------------------------- + +def test_token_entities_present_with_calibrated_values(): + state = _state() + # token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the + # official app on this unit, which pins the scale (the .5 here is a later + # reading). Whether it counts up or down is deliberately not asserted -- + # see the descriptor comment. + assert state['filter_time'] == 171.5 + # token - 55 == 19 C, against a 19.4 C forecast at the time of the dump. + assert state['outdoor_temperature'] == 19.0 + assert state['buzzer_volume'] == 100.0 + assert state['good_sleep'] == 0.0 + assert state['spi'] is False + assert state['auto_clean_legacy'] is False + assert state['air_monitoring'] is False + + +def test_token_entities_stay_off_newer_boards(): + """Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too, + while also exposing those settings as dedicated resources -- ungated, the + token entities would duplicate them (auto clean) or apply a scale + calibrated on another board generation (outdoor temperature).""" + state = _state('airconditioner_tp1x_rac') + for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume', + 'good_sleep', 'outdoor_temperature', 'filter_time'): + assert key not in state, key + + +def test_absent_token_yields_no_entity(): + """The issue #136 unit of this same model reports no Spi token.""" + resources = _load_device(FIXTURE) + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + option for option in options if not option.startswith('Spi_') + ] + bound, _ = _discover(resources) + assert 'spi' not in flatten(bound, resources) + + +def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown(): + """This board has no fivepercentHumidity field; the plain humidity field + only carries a reading while Air monitoring is on, and the unit switches + that back off by itself after about a minute.""" + resources = _load_device(FIXTURE) + assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] is None + + resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] == 51.0 + + +def test_option_writes_carry_one_token(): + """Both go through option_write's single-token merge, the same mechanism + the display light already uses on this href. Confirmed on hardware by + read-back: Spi_On/Spi_Off, and the volume token surviving a write.""" + assert _option_switch_write('Spi')('On', {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']}, + ) + # The number platform hands over a float; the device wants an integer token. + assert _option_number_write('Volume')(70.0, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']}, + ) + + +# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------ + +def test_fan_mode_reads_the_airflow_speed_level(): + entity = _climate(_load_device(FIXTURE)) + assert entity.fan_mode == 'high' # speedLevel 3 in the fixture + # No supportedModes on this resource, so the full 0-4 scale is offered. + assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo'] + + +def test_swing_mode_reads_the_airflow_direction(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.swing_mode == 'off' # 'Fix' in the fixture + + resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All' + assert _climate(resources).swing_mode == 'both' + assert 'both' in _climate(resources).swing_modes + + +async def test_fan_and_swing_writes_target_the_airflow_resource(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('turbo') + await entity.async_set_swing_mode('both') + + assert [payload for _, payload in coordinator.commands] == [ + ('fan_legacy', '4'), ('swing_legacy', 'All'), + ] + + +def test_preset_comes_from_the_comode_token(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.preset_mode == 'none' # Comode_Off in the fixture + # Codes learned by driving this unit through its cloud integration and + # reading the token back. They go through the same dynamic resolver as a + # real convenient resource's supportedModes, so 'Nano' resolves to the + # existing 'nano' preset -- already labelled WindFree in the catalog. + assert entity.preset_modes == [ + 'none', 'nano', 'quiet', 'comfort', '2step', 'speed', + ] + + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + 'Comode_Nano' if option.startswith('Comode_') else option + for option in options + ] + assert _climate(resources).preset_mode == 'nano' + + +async def test_preset_write_uses_the_token_path(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_preset_mode('nano') + + assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano') + + +async def test_newer_boards_keep_the_resource_paths(): + """The legacy fallbacks are gated on this board's resource shape, so a + board with /wind/* and /mode/convenient/vs/0 must be untouched by them.""" + resources = _load_device('airconditioner_tp1x_rac') + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('high') + await entity.async_set_swing_mode('off') + await entity.async_set_preset_mode('quiet') # from its own supportedModes + + kinds = [payload[0] for _, payload in coordinator.commands] + assert kinds == ['fan', 'swing', 'preset'] diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 630ccb2..15f1a47 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -405,7 +405,17 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent(): def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): desc = airconditioner.HUMIDITY.entities[0] rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} - assert desc.value_fn(rep.get(desc.field)) == 42.0 + assert desc.rep_fn(rep) == 42.0 + + +def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent(): + """ARTIK051 boards (issue #136) have no fivepercentHumidity field at all. + Their plain field is not stuck -- it carries a reading while Air monitoring + is on -- so 0 means "not measuring" on both generations, not 0% humidity.""" + desc = airconditioner.HUMIDITY.entities[0] + assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0 + assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None + assert desc.rep_fn({}) is None # --------------------------------------------------------------------------- @@ -634,3 +644,17 @@ def test_sensor_item_value_picks_first_value(): assert airconditioner._sensor_item_value(items, 'Odor') is None assert airconditioner._sensor_item_value(items, 'Missing') is None assert airconditioner._sensor_item_value(None, 'Dust') is None + + +def test_beep_and_tropical_night_stay_off_legacy_krac_board(): + """ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_ + option token, but they're already modeled as buzzer_volume/good_sleep + (see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also + bind there, or the same options[] slot would surface as two entities.""" + reg, resources = _resolve('airconditioner_artik051_krac_18k') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert 'beep' not in state + assert 'tropical_night_mode' not in state + assert state['buzzer_volume'] == 100.0 + assert state['good_sleep'] == 0.0 diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 0000000..d707662 --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,56 @@ +"""Tests for registry.batch — the /device/0 sweep parser and its stub marker. + +issue #127: a device whose /energy/consumption/vs/0 is permanently +unsupported reports a genuinely empty {} rep for it. The previous parser +collapsed /device/0's own {"href": "..."} "not fetched yet" marker to that +same {} shape, so downstream exists_fn checks couldn't tell "confirmed +empty" apart from "haven't polled it yet" and created phantom always- +"unknown" entities either way. is_stub_rep/parse_device0_batch now keep the +two shapes distinct. +""" +from custom_components.localthings.registry.batch import is_stub_rep, parse_device0_batch + + +class TestIsStubRep: + def test_true_for_bare_href_marker(self): + assert is_stub_rep({'href': '/energy/consumption/vs/0'}) is True + + def test_false_for_genuinely_empty_rep(self): + assert is_stub_rep({}) is False + + def test_false_for_populated_rep(self): + assert is_stub_rep({'x.com.samsung.da.cumulativePower': '58900'}) is False + + def test_false_for_href_plus_data(self): + """A real, populated rep may legitimately echo 'href' alongside + actual fields -- only a rep with *no other keys* is the stub.""" + assert is_stub_rep({'href': '/x/0', 'value': True}) is False + + +class TestParseDevice0Batch: + def test_stub_rep_kept_distinct_from_genuine_empty(self): + device0 = [ + {}, + {'href': '/energy/consumption/vs/0', 'rep': {'href': '/energy/consumption/vs/0'}}, + {'href': '/sabbath/vs/0', 'rep': {}}, + ] + resources = parse_device0_batch(device0) + assert is_stub_rep(resources['/energy/consumption/vs/0']) is True + assert is_stub_rep(resources['/sabbath/vs/0']) is False + assert resources['/sabbath/vs/0'] == {} + + def test_populated_rep_passes_through_unchanged(self): + device0 = [ + {}, + {'href': '/door/cooler/0', 'rep': {'openState': 'Close'}}, + ] + resources = parse_device0_batch(device0) + assert resources['/door/cooler/0'] == {'openState': 'Close'} + + def test_skips_entries_without_href(self): + device0 = [{}, {'rep': {'a': 1}}] + assert parse_device0_batch(device0) == {} + + def test_skips_non_dict_rep(self): + device0 = [{}, {'href': '/x/0', 'rep': 'not-a-dict'}] + assert parse_device0_batch(device0) == {} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 15ac1e2..b7a476e 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -329,19 +329,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' - def test_oven_via_microwave_token(self): - """Issue #121: a combi microwave (MW7300B-/EU1) reports no + def test_microwave_via_microwave_token(self): + """Issue #121/#66: a combi microwave (MW7300B-/EU1) reports no oneUiVersion and an unrecognized consumer token; falls back to the - '-MICROWAVE-' token in modelNum onto the *existing* oven registry - (same '/oven/vs/0' cavity + cook-mode shape), not a new device - type.""" + '-MICROWAVE-' token in modelNum onto its own microwave registry + (initially folded into 'oven' for issue #121, split out into a + distinct device type per user feedback).""" from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model( 'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000', 'MW7300B-/EU1', ) assert reg is not None - assert reg.name == 'oven' + assert reg.name == 'microwave' def test_vacuum_station_via_vskr_token(self): """Issue #131: a stick-vacuum clean/auto-empty station diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 8f7bcad..2050a70 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -253,13 +253,24 @@ class TestEnergyMeter: kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True - def test_both_entities_included_on_empty_stub(self): - """An empty {} rep means the resource exists but data isn't fetched yet - (see entity._is_included) -- include both so sub-polls populate them.""" + def test_both_entities_included_on_true_stub(self): + """A true stub -- /device/0's {"href": "..."} "not fetched yet" + marker (see registry.batch.is_stub_rep) -- means the resource exists + but data isn't fetched yet; include both so sub-polls populate them.""" pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') - assert pw.exists_fn({}, {}) is True - assert kwh.exists_fn({}, {}) is True + stub = {'href': '/energy/consumption/vs/0'} + assert pw.exists_fn(stub, {}) is True + assert kwh.exists_fn(stub, {}) is True + + def test_both_entities_hidden_on_genuinely_empty_rep(self): + """A real {} rep (no 'href' key) is the device's confirmed -- if + empty -- answer, not a stub, so a model that never populates this + resource doesn't get a phantom always-"unknown" entity (issue #127).""" + pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') + kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') + assert pw.exists_fn({}, {}) is False + assert kwh.exists_fn({}, {}) is False def test_power_watts_hidden_when_field_absent_in_populated_rep(self): """A populated rep that lacks instantaneousPower must not spawn a @@ -388,13 +399,20 @@ class TestAiEnergyLevelStubDoesNotDecideThePlatform: 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 -- + expecting a string option). Neither side gets an is_stub_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): + def test_neither_widget_exists_on_true_stub_rep(self): + switch = _ai_energy_level_desc('SwitchDesc') + select = _ai_energy_level_desc('SelectDesc') + stub = {'href': '/energy/ailevel/vs/0'} + assert switch.exists_fn(stub, {}) is False + assert select.exists_fn(stub, {}) is False + + def test_neither_widget_exists_on_genuinely_empty_rep(self): switch = _ai_energy_level_desc('SwitchDesc') select = _ai_energy_level_desc('SelectDesc') assert switch.exists_fn({}, {}) is False @@ -416,11 +434,17 @@ class TestSelfCheckError: 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.""" + def test_exists_for_true_stub_rep(self): + """A true stub ({"href": "..."}) is /device/0's not-yet-fetched + marker -- must be included-for-now, same as ENERGY_METER's fields.""" desc = self._desc() - assert desc.exists_fn({}, {}) is True + assert desc.exists_fn({'href': '/selfcheck/vs/0'}, {}) is True + + def test_hidden_for_genuinely_empty_rep(self): + """A real {} rep is the device's confirmed empty answer, not a stub -- + must NOT be force-included (issue #127's phantom-entity pattern).""" + desc = self._desc() + assert desc.exists_fn({}, {}) is False def test_value_joins_list(self): desc = self._desc() diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 0000000..1e0538b --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,79 @@ +"""Tests for entity._is_included -- the one-time entity-creation gate run +per platform at async_setup_entry (see sensor.py etc.), separate from +adapter.flatten()'s per-poll state values. issue #127 review: this default +gate has to stay permissive on a genuinely empty {} rep, not just a true +is_stub_rep stub -- a resource like /alarms/vs/0 is validly empty in its +normal, no-alarm state (fridge._active_alarm_codes), so excluding the +entity there would silently drop working sensors on first-poll timing, +not just fix phantom ones. +""" +from custom_components.localthings.entity import _is_included +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import SensorDesc + + +class _FakeCoordinator: + def __init__(self, last_resources): + self.last_resources = last_resources + + +def _bound(desc, href): + capability = Capability(href=href, entities=(desc,)) + return BoundEntity(href=href, capability=capability, desc=desc) + + +class TestDefaultFieldGate: + """No explicit exists_fn -- the field-presence default in entity.py.""" + + def _bound(self): + return _bound(SensorDesc(key='x', field='x.com.samsung.da.value'), '/x/vs/0') + + def test_included_when_field_present(self): + bound = self._bound() + coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.value': '1'}}) + assert _is_included(bound, coord) is True + + def test_excluded_when_field_absent_from_populated_rep(self): + bound = self._bound() + coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.other': '1'}}) + assert _is_included(bound, coord) is False + + def test_included_on_true_stub(self): + bound = self._bound() + coord = _FakeCoordinator({'/x/vs/0': {'href': '/x/vs/0'}}) + assert _is_included(bound, coord) is True + + def test_included_on_genuinely_empty_rep(self): + """The default gate stays permissive on a real {} -- see + /alarms/vs/0, whose empty rep is the normal no-alarm state, not a + signal the hardware is unsupported. Only a capability-specific + exists_fn (e.g. common.ENERGY_METER) opts into excluding on + confirmed-empty, after verifying that's actually safe for its field.""" + bound = self._bound() + coord = _FakeCoordinator({'/x/vs/0': {}}) + assert _is_included(bound, coord) is True + + def test_excluded_when_href_missing_from_resources(self): + bound = self._bound() + coord = _FakeCoordinator({}) + assert _is_included(bound, coord) is False + + +class TestExplicitExistsFnGate: + def test_explicit_exists_fn_overrides_default_field_gate(self): + desc = SensorDesc(key='x', field='x.com.samsung.da.value', + exists_fn=lambda rep, resources: rep.get('flag') is True) + bound = _bound(desc, '/x/vs/0') + coord = _FakeCoordinator({'/x/vs/0': {'flag': True}}) + assert _is_included(bound, coord) is True + coord = _FakeCoordinator({'/x/vs/0': {'flag': False, 'x.com.samsung.da.value': '1'}}) + assert _is_included(bound, coord) is False + + +class TestNoFieldEntities: + def test_rep_fn_entity_always_included(self): + desc = SensorDesc(key='x', rep_fn=lambda rep: rep.get('x.com.samsung.da.value')) + bound = _bound(desc, '/x/vs/0') + coord = _FakeCoordinator({'/x/vs/0': {}}) + assert _is_included(bound, coord) is True diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 94f7d96..3b6399e 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -163,6 +163,26 @@ class TestFlexZone: assert 'CV_FDR_MEAT' not in modes assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes + def test_exists_only_when_a_current_value_resolves(self): + """Issue #26's kimchi-refrigerator family also populates /mode/vs/0's + modes/supportedOptions, but its supportedOptions tokens carry a + "_[n]:[n]" suffix modes never repeats, so no item ever overlaps -- + the entity used to bind anyway (supportedOptions is nonempty) and + get stuck on "unknown" forever.""" + no_overlap_rep = { + 'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'], + 'x.com.samsung.da.supportedOptions': [ + 'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'], + } + desc = fridge.FLEX_ZONE.entities[0] + assert desc.exists_fn(no_overlap_rep, {}) is False + + overlap_rep = { + 'x.com.samsung.da.modes': ['CV_FDR_MEAT'], + 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'], + } + assert desc.exists_fn(overlap_rep, {}) is True + class TestTp1xNativeDuplicateResources: """The US TP1X_REF_21K publishes two native mirrors in addition to the @@ -210,6 +230,97 @@ class TestPantryZone: assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} +class TestKimchiZone: + """Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) -- + top/middle/bottom each report an identically-shaped currentMode/ + supportMode resource under /status/kimchi//vs/0.""" + + def test_href_prefix(self): + assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/' + assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/' + + def test_write_derives_path_from_href(self): + desc = fridge.KIMCHI_ZONE.entities[0] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + path, body = desc.write_fn( + 'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0') + assert path == ['status', 'kimchi', 'middle', 'vs', '0'] + assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'} + + def test_write_without_href_is_rejected(self): + desc = fridge.KIMCHI_ZONE.entities[0] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None + + def test_write_rejects_value_outside_supportmode(self): + """A value the compartment never advertised is rejected rather than + written blind -- this write path is unconfirmed against real + hardware (module docstring above KIMCHI_ZONE), so a bad value here + is a food-safety-adjacent outcome, not just a cosmetic one.""" + desc = fridge.KIMCHI_ZONE.entities[0] + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn( + 'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0', + ) is None + + def test_ripening_status_passes_through_device_value(self): + """No device_class='enum' catalog entry exists for this sensor, so + lowercasing it would only make the raw device token un-translatable + by HA -- pass the device's own casing straight through instead.""" + desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status') + assert desc.value_fn('Off') == 'Off' + + def test_door_reuses_open_state_helper(self): + desc = fridge.KIMCHI_DOOR_GENERIC.entities[0] + assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True + assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + + async def test_zone_mode_select_round_trips_through_display_casing(self): + """kimchi_zone_mode's displayed value (lowercase, catalog-translated) + and the raw device code it writes back can silently drift apart -- + this is the one place that casing conversion could break. Runs + through the real discovery/select pipeline against the tp2x_ref_20k + kimchi fixture rather than a hand-built descriptor, so it also + catches use_instance_name key derivation going wrong.""" + 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 custom_components.localthings.registry.entities import SelectDesc + from custom_components.localthings.select import LocalThingsSelect + from tests.conftest import _load_device + + resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + bound = discover( + resources, refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + ) + mode_bound = next( + b for b in bound + if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0' + ) + + class _FakeCoordinator: + device_serial = 'TEST-SERIAL' + + def __init__(self, resources, data): + self.last_resources = resources + self.data = data + self.commands = [] + + async def async_send_command(self, bound, value): + self.commands.append(value) + + coordinator = _FakeCoordinator(resources, flatten(bound, resources)) + entity = LocalThingsSelect(coordinator, mode_bound) + + assert entity.current_option == 'kimchi_storage_normal' + assert 'kimchi_storage_cold' in entity.options + + await entity.async_select_option('kimchi_storage_cold') + + assert coordinator.commands == ['KIMCHI_STORAGE_COLD'] + + class TestArtik051AndTp2xFixturesHaveCompleteCoverage: """issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered the incomplete-capability-coverage repair; both must resolve to zero @@ -253,6 +364,33 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: state = flatten(bound, resources) assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + def test_tp2x_ref_20k_kimchi(self): + """A different physical unit sharing the same modelNum string (issue + #26, second reporter) -- a 3-compartment kimchi refrigerator instead + of the flex-zone fridge above. /kimchidoors/top/vs/0 and + /status/kimchi/{top,middle,bottom}/vs/0 were previously unbound.""" + 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_kimchi') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + state = flatten(bound, resources) + assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL' + assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL' + assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL' + assert state['top_open'] is False + assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported + assert 'flex_zone_mode' not in state # no resolvable overlap on this family + class TestArtik051DongleRefFixtureCoverage: """Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 96f7c32..917e12c 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -300,6 +300,23 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): ) +def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi(): + """A different physical unit reporting the same "TP2X_REF_20K" modelNum + string as the fixture above (issue #26's second reporter) -- a + 3-compartment kimchi refrigerator with no flex zone, doors/icemaker, or + freezer/cooler split, but its own /status/kimchi//vs/0 and + /kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC).""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text()) + state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', 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 @@ -568,19 +585,44 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil ) -def test_registry_reproduces_golden_state_keys_for_oven_mw7300b(): +def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b(): """TP1X_DA-KS-MICROWAVE-01041 combi microwave (model MW7300B, issue #121) -- reports no oneUiVersion; resolved via the '-MICROWAVE-' - modelNum token fallback onto the *existing* oven registry rather than - a new device type, since it shares the same '/oven/vs/0' cavity and - '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*). - The only href the oven registry didn't already cover was - /recipe/cook/vs/0 (oven.OVEN_RECIPE_COOK, an empty quick-recipe-display - blob with no entity).""" + modelNum token fallback onto its own microwave registry. Shares the + oven board family's operational-state/door/connected/recipe-cook + Capability objects (reused directly from oven.py), but has its own + cooking-mode vocabulary, setpoint bounds, and cavity power-level sensor + (capabilities/microwave.py) -- this device's initial routing folded it + into the oven registry (issue #121); split into its own device type per + user feedback that microwaves shouldn't show up as ovens.""" from tests.conftest import _load_device - resources = _load_device('oven_mw7300b') - golden = json.loads((GOLDEN / 'oven_mw7300b.json').read_text()) - state_keys = _new_state_keys('oven_mw7300b', resources) + resources = _load_device('microwave_mw7300b') + golden = json.loads((GOLDEN / 'microwave_mw7300b.json').read_text()) + state_keys = _new_state_keys('microwave_mw7300b', 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_microwave_me7500d(): + """TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues + #137/#142) -- the same microwave registry as MW7300B above, but this + board also reports the built-in vent fan's `/hood/fanspeed/vs/0` + resource, previously unbound. Bound via range_hood.HOOD_FAN (reused + directly, same resource shape a standalone range hood reports); unlike + a standalone hood this board has no sibling `/power/0` or + `/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats + fan speed 0 as the off state instead of writing a separate power + resource -- see its `_speed_zero_is_off` check. This board also has + no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation` + field, unlike MW7300B, so `setpoint`/`current_temp_c` and + `automatic_operation` are correctly absent here.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d') + golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text()) + state_keys = _new_state_keys('microwave_me7500d', 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" @@ -629,6 +671,27 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station(): ) +def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): + """ARTIK051_KRAC_18K (issue #136) -- reports no oneUiVersion, and its + '_KRAC_' token was invisible to for_device_by_model's '_RAC_' check (the + 'K' sits between the underscore and 'RAC'), so it fell back to 'unknown' + and exposed nothing but power. Same ARTIK051 board generation as the + '_TVTL_' air purifier: no /wind/* resources at all (fan and vane live in + /airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token), + and several settings carried as /mode/vs/0 options[] tokens.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_artik051_krac_18k') + golden = json.loads( + (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_artik051_krac_18k', 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_microwave_capabilities.py b/tests/test_microwave_capabilities.py new file mode 100644 index 0000000..05f0b91 --- /dev/null +++ b/tests/test_microwave_capabilities.py @@ -0,0 +1,177 @@ +"""Unit tests for the microwave-family capabilities (issue #121/#66 split +into their own device type instead of being folded into oven.py).""" +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import microwave +from custom_components.localthings.registry.discovery import discover + + +# --------------------------------------------------------------------------- +# Device-type detection + full-dump coverage +# --------------------------------------------------------------------------- + +def test_microwave_fixture_resolves_and_has_no_unbound_hrefs(): + from tests.conftest import _load_device + resources = _load_device('microwave_mw7300b') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None + assert reg.name == 'microwave' + + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs(): + """Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in + vent fan) was previously unbound on this family.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None + assert reg.name == 'microwave' + + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +# --------------------------------------------------------------------------- +# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics +# --------------------------------------------------------------------------- + +def test_microwave_setpoint_write_is_read_modify_write(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + path, body = desc.write_fn(180, rep) + assert path == ['temperatures', 'vs', '0'] + assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + + +def test_microwave_setpoint_rmw_preserves_other_item_fields(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{ + 'x.com.samsung.da.current': '150', + 'x.com.samsung.da.desired': '150', + }]} + path, body = desc.write_fn(180, rep) + item = body['x.com.samsung.da.items'][0] + assert item['x.com.samsung.da.desired'] == '180' + assert item['x.com.samsung.da.current'] == '150' + + +def test_microwave_setpoint_clamps_to_step(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + _, body = desc.write_fn(182, rep) # nearest 5 = 180 + assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + + +def test_microwave_setpoint_rejects_out_of_range(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]} + assert desc.write_fn(20, rep) is None # below min (40) + assert desc.write_fn(210, rep) is None # above max (200) + + +def test_microwave_setpoint_rejects_missing_items(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + assert desc.write_fn(180, {}) is None + + +def test_microwave_setpoint_exists_only_for_celsius(): + """No Fahrenheit dump exists for this family (unlike oven.py's, verified + against issue #44) -- the writable setpoint stays hidden rather than + showing unverified bounds under the wrong unit.""" + desc = microwave.MICROWAVE_SETPOINT.entities[0] + celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} + fahrenheit_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Fahrenheit'}]} + assert desc.exists_fn(celsius_rep, {}) is True + assert desc.exists_fn(fahrenheit_rep, {}) is False + + +# --------------------------------------------------------------------------- +# MICROWAVE_CAVITY — power_level sensor +# --------------------------------------------------------------------------- + +def test_power_level_parses_watt_suffix(): + """Issue #121's combi dump reports e.g. '0W'.""" + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn('900W') == 900 + + +def test_power_level_parses_bare_number(): + """Issue #137's plain microwave reports the bare number, no 'W' suffix.""" + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn('0') == 0 + + +def test_power_level_handles_missing_value(): + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn(None) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE — SelectDesc with non-empty, family-specific options +# --------------------------------------------------------------------------- + +def test_microwave_mode_options_nonempty(): + desc = microwave.MICROWAVE_MODE.entities[0] + assert len(desc.options) > 0 + assert 'MicroWave' in desc.options + assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry' + + +def test_microwave_mode_write_round_trips(): + desc = microwave.MICROWAVE_MODE.entities[0] + path, body = desc.write_fn('MicroWave', {}) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['MicroWave'] + + +def test_microwave_mode_rejects_unknown(): + desc = microwave.MICROWAVE_MODE.entities[0] + assert desc.write_fn('SpaghettiMode', {}) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE — lamp/sound options-array writes +# --------------------------------------------------------------------------- + +def test_sound_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') + rep = {'x.com.samsung.da.options': ['Sound_On']} + path, body = desc.write_fn('Off', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Sound_Off']} + + +def test_lamp_gated_absent_when_no_lamp_option(): + """Issue #121's combi dump has no 'Lamp_*' token at all -- unlike + oven.py's lamp switch (assumed universal), this one self-gates off.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + assert desc.exists_fn(rep, {}) is False + + +def test_lamp_gated_present_when_lamp_option_reported(): + """Issue #137's plain microwave reports 'Lamp_Off'.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']} + assert desc.exists_fn(rep, {}) is True + + +def test_lamp_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['Lamp_Off']} + path, body = desc.write_fn('On', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Lamp_On']} + + +def test_lamp_write_requires_existing_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.write_fn('On', {}) is None diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py index 3892036..7727d49 100644 --- a/tests/test_range_hood_capabilities.py +++ b/tests/test_range_hood_capabilities.py @@ -61,6 +61,8 @@ def test_range_hood_fixture_values(): assert state['periodic_air_sensing'] is False assert state['air_sensing_state'] == 'NonProcessing' assert state['last_air_sensing_level'] == 'Kr2' + assert state['after_run_active'] is False + assert state['after_run_progress'] == 0 def test_one_composite_fan_is_bound(): @@ -114,3 +116,19 @@ def test_lamp_write_contract(): ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'}, ) assert brightness.write_fn('Unsupported', rep) is None + + +def test_after_run_cancel_write_contract(): + """issue #147: /afterrun/vs/0 was previously unbound. runningCancel's + only observed value is the command name itself, so cancel is modeled as + a ButtonDesc that writes that value back.""" + active, progress, cancel = range_hood.AFTER_RUN.entities + assert cancel.write_fn('Cancel', {}) == ( + ['afterrun', 'vs', '0'], {'x.com.samsung.da.runningCancel': 'Cancel'}, + ) + + +def test_after_run_active_reads_on_off(): + active, progress, cancel = range_hood.AFTER_RUN.entities + assert active.value_fn('On') is True + assert active.value_fn('Off') is False diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 3f52d78..a1fbf4d 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -1,7 +1,7 @@ """HA fan-entity mapping tests for the range hood.""" from custom_components.localthings.fan import LocalThingsRangeHoodFan -from custom_components.localthings.registry.by_type import range_hood +from custom_components.localthings.registry.by_type import microwave, range_hood from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import FanDesc from tests.conftest import _load_device @@ -23,11 +23,11 @@ class _FakeCoordinator: self.commands.append((bound, payload)) -def _entity(resources, coordinator=None): +def _entity(resources, coordinator=None, registry=range_hood.REGISTRY): bound = discover( resources, - range_hood.REGISTRY.capabilities, - range_hood.REGISTRY.pattern_capabilities, + registry.capabilities, + registry.pattern_capabilities, ) fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc)) return LocalThingsRangeHoodFan( @@ -35,6 +35,10 @@ def _entity(resources, coordinator=None): ) +def _microwave_entity(resources, coordinator=None): + return _entity(resources, coordinator, registry=microwave.REGISTRY) + + def test_power_off_maps_to_zero_percent_and_four_retained_speeds(): entity = _entity(_load_device('range_hood')) assert entity.is_on is False @@ -86,3 +90,131 @@ async def test_power_write_falls_back_to_vendor_resource(): await entity.async_turn_off() assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0') + + +# --------------------------------------------------------------------------- +# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this +# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is +# itself the off state. +# --------------------------------------------------------------------------- + +def test_standalone_hood_has_separate_power(): + """Explicit converse of the microwave case below: guards the + discriminator itself, not just its downstream effects, so a future + change to it fails loudly here instead of only via behavioral drift.""" + entity = _entity(_load_device('range_hood')) + assert entity._has_separate_power() is True + assert entity._speed_zero_is_off() is False + + +def test_microwave_vent_fan_has_no_separate_power_resource(): + resources = _load_device('microwave_me7500d') + assert '/power/0' not in resources + assert '/power/vs/0' not in resources + entity = _microwave_entity(resources) + assert entity._has_separate_power() is False + assert entity._speed_zero_is_off() is True + + +async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off(): + """A combi over-the-range microwave can report a /power/0 resource for + the cavity while the vent fan still has no power resource of its own + (settableMinFanSpeed '0' -- same board shape as microwave_me7500d). + _speed_zero_is_off must key off the hood resource itself, not merely + "some power resource exists on this device", so turning the fan off + writes fan speed rather than the shared cavity power resource.""" + resources = _load_device('microwave_me7500d') + resources['/power/0'] = {'value': True} + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + assert entity._has_separate_power() is True + assert entity._speed_zero_is_off() is True + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('speed', '0') + + +def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes(): + """supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the + standalone hood's codes, which never include an off entry.""" + resources = _load_device('microwave_me7500d') + entity = _microwave_entity(resources) + assert entity.is_on is False + assert entity.percentage == 0 + assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded + + +def test_microwave_vent_fan_nonzero_speed_reads_as_on(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + entity = _microwave_entity(resources) + assert entity.is_on is True + assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4'] + + +async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_off() + + assert coordinator.commands[-1][1] == ('speed', '0') + + +async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands[-1][1] == ('speed', '1') + + +async def test_microwave_vent_fan_set_percentage_writes_speed_only(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_set_percentage(100) + + assert coordinator.commands == [(entity._bound, ('speed', '4'))] + + +async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly(): + resources = _load_device('microwave_me7500d') + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on(percentage=75) + + assert coordinator.commands == [(entity._bound, ('speed', '3'))] + + +async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop(): + """A scene or automation calling fan.turn_on on an already-running vent + fan must not reset it to the lowest speed.""" + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_turn_on() + + assert coordinator.commands == [] + + +async def test_microwave_vent_fan_set_percentage_zero_turns_off(): + resources = _load_device('microwave_me7500d') + resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + coordinator = _FakeCoordinator(resources) + entity = _microwave_entity(resources, coordinator) + + await entity.async_set_percentage(0) + + assert coordinator.commands[-1][1] == ('speed', '0') diff --git a/tests/test_translations.py b/tests/test_translations.py index d70a6d9..94c879a 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -126,8 +126,10 @@ def test_no_catalog_carries_unresolved_core_references(): # The hood fan is its device's primary feature: fan.py sets _attr_name = None -# so it presents as the device itself, and never reads a catalog name. -UNNAMED_DESCRIPTORS = {("fan", "fan")} +# so it presents as the device itself, and never reads a catalog name. Same +# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed +# levels, no presets, same _attr_name = None treatment. +UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")} def test_every_descriptor_has_an_entity_catalog_entry(): @@ -208,3 +210,27 @@ def test_every_ac_convenient_mode_code_has_a_preset_label(): missing.append((path.name, code)) assert missing == [] + +def test_every_kimchi_zone_supportmode_code_has_a_state_label(): + """Same guard as the AC preset one above, for KIMCHI_ZONE's + kimchi_zone_mode select (fridge.py, issue #26): the write path resolved + from options_field is fully dynamic too, so an unlabelled supportMode + code across any /status/kimchi//vs/0 resource would silently + render as its raw device token instead of the translated state. + """ + state_labels = set( + _load("en")["entity"]["select"]["kimchi_zone_mode"]["state"] + ) + fixtures_dir = Path(__file__).parent / "fixtures" + missing = [] + for path in sorted(fixtures_dir.glob("*_device.json")): + dump = json.loads(path.read_text()) + for item in dump.get("device0", []): + href = item.get("href", "") + if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")): + continue + for code in item["rep"].get("x.com.samsung.da.supportMode", []): + if code.lower() not in state_labels: + missing.append((path.name, href, code)) + assert missing == [] + diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index cd1f2cd..0e2ef53 100644 --- a/tests/test_water_purifier_capabilities.py +++ b/tests/test_water_purifier_capabilities.py @@ -169,6 +169,14 @@ def _desc_coffee(key): return next(b.desc for b in bound if b.desc.key == key) +def _desc_coffee_by_href(key, href): + """Like _desc_coffee, but disambiguates descriptors that share a key + across hrefs (hotwater_lock: LOCK's hotwaterLock field and + FAVORITE_HOTWATER's switchHotwater fallback, issue #144).""" + bound, _ = _bound_coffee() + return next(b.desc for b in bound if b.desc.key == key and b.href == href) + + def test_coffee_variant_no_unbound_hrefs(): """Every resource in the issue #107 dump binds or is covered, including the four coffee-recipe hrefs not present in issue #90's dump.""" @@ -181,7 +189,7 @@ def test_coffee_variant_no_unbound_hrefs(): def test_coffee_variant_expected_state_keys_present(): state = _state_coffee() for key in ('favorite_coffee_enabled', 'coffee_brew_status', - 'favorite_hotwater_enabled', 'favorite_hotwater_temperature'): + 'hotwater_lock', 'favorite_hotwater_temperature'): assert key in state, key @@ -193,12 +201,84 @@ def test_favorite_coffee_write_contract(): ['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'}) -def test_favorite_hotwater_write_contract(): - enabled = _desc_coffee('favorite_hotwater_enabled') - assert enabled.write_fn('On', {}) == ( - ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'}) - assert enabled.write_fn('Off', {}) == ( +def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag(): + """issue #144: switchHotwater's value domain is Locked/Unlocked, not an + enable flag, and it's misspelled as "Favorite hot water" -- it's the same + hot-water lock as LOCK.hotwater_lock, just surfaced through this href on + boards (like this fixture's) that don't populate /status/lock/vs/0's + hotwaterLock field.""" + lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') + assert lock.write_fn('On', {}) == ( ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'}) + assert lock.write_fn('Off', {}) == ( + ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'}) + assert lock.value_fn('Unlocked') is False + assert lock.value_fn('Locked') is True + + +def test_favorite_hotwater_lock_wins_in_flattened_state(): + """adapter.flatten() -- the actual source of coordinator.data every + switch's is_on reads -- only honours exists_fn, never entity.py's + implicit own-field-presence default. So it's not enough for the + *registered* entity to resolve correctly (test_expected_state_keys_present + territory); the shared 'hotwater_lock' key in the flattened dict itself + must reflect the live switchHotwater value, not a stale phantom from + LOCK's ungated hotwaterLock read (issue #144). This fixture's + switchHotwater reads 'Unlocked'; without exists_fn on *both* sides of the + pair, LOCK's descriptor computes None != 'Unlocked' == True regardless, + and flatten() would pick whichever of the two entities happens to be + processed last.""" + state = _state_coffee() + assert state['hotwater_lock'] is False + + +def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state(): + """Both LOCK.hotwater_lock and FAVORITE_HOTWATER's switchHotwater + fallback are always bound on this fixture (their hrefs are both always + present) -- discrimination happens entirely in exists_fn. Exactly one of + the two must ever pass, regardless of iteration order, or two switch + entities would be registered with the same unique_id.""" + bound, resources = _bound_coffee() + candidates = [b for b in bound if b.desc.key == 'hotwater_lock'] + assert len(candidates) == 2 + included = [b for b in candidates + if b.desc.exists_fn(resources.get(b.href) or {}, resources)] + assert len(included) == 1 + assert included[0].href == '/favorite/hotwater/vs/0' + + +def test_hotwater_lock_fallback_gating_across_status_lock_states(): + """The fallback (FAVORITE_HOTWATER's switchHotwater descriptor) must + activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock -- + never while that resource is an unfetched stub ({}), and never when it + does carry the field. LOCK's own descriptor is the mirror image.""" + fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') + primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0') + own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'} + + # /status/lock/vs/0 absent entirely -- device genuinely lacks it. + assert fallback.exists_fn(own_rep, {}) is True + + # /status/lock/vs/0 present but not yet fetched (a stub): outcome + # pending, so the fallback must defer to LOCK rather than assume absence. + stub_resources = {'/status/lock/vs/0': {}} + assert fallback.exists_fn(own_rep, stub_resources) is False + assert primary.exists_fn({}, stub_resources) is True + + # /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this + # fixture's actual shape) -- the fallback wins. + absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}} + assert fallback.exists_fn(own_rep, absent_resources) is True + assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False + + # /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins. + present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}} + assert fallback.exists_fn(own_rep, present_resources) is False + assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True + + # The fallback also re-asserts its own field, since it no longer gets + # that check for free once it shares LOCK's key (issue #144 review). + assert fallback.exists_fn({}, {}) is False def test_favorite_hotwater_temperature_options_come_from_live_supported_list():