diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py new file mode 100644 index 0000000..6e6b3b8 --- /dev/null +++ b/custom_components/localthings/climate.py @@ -0,0 +1,279 @@ +"""Climate platform for Local Things. + +The first composite entity in this integration: a single HA climate card that +unifies several OCF resources of a Samsung air conditioner. Unlike every other +platform here (one descriptor -> one resource field), a climate entity reads +power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind +direction) and the convenient-mode preset from *different* resources. + +It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry +still tracks it, and reads the sibling resources straight from the coordinator +snapshot via `coordinator.resource(href)` -- the same cross-resource read that +`number.py` (live range/unit) and `select.py` (options callable) already do. + +Writes go through `coordinator.async_send_command(bound, (kind, value))`: the +CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right +`(path_segs, body)`, and `async_send_command` POSTs to those path_segs (the +bound href is only used for logging), so one descriptor drives writes to power, +mode, temperature and wind resources. +""" +from __future__ import annotations + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .registry.entities import ClimateDesc + +from .const import DOMAIN +from .coordinator import LocalThingsCoordinator +from .entity import LocalThingsEntity, _is_included + +# Sibling OCF resources the climate entity reads (the primary bound href is +# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor +# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK. +POWER_HREF = '/power/0' +POWER_VS_HREF = '/power/vs/0' +MODE_HREF = '/mode/vs/0' +TEMP_CURRENT_HREF = '/temperature/current/0' +TEMP_DESIRED_HREF = '/temperature/desired/0' +TEMP_CONTROL_HREF = '/temperature/control/vs/0' +WIND_STRENGTH_HREF = '/wind/strength/vs/0' +WIND_DIRECTION_HREF = '/wind/direction/vs/0' +CONVENIENT_HREF = '/mode/convenient/vs/0' + +_MODES_FIELD = 'x.com.samsung.da.modes' +_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' + +# --- device code <-> HA value maps ----------------------------------------- +# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is +# driven by the power resource). +_DEVICE_TO_HVAC: dict[str, HVACMode] = { + 'Cool': HVACMode.COOL, + 'Dry': HVACMode.DRY, + 'Wind': HVACMode.FAN_ONLY, + 'Auto': HVACMode.HEAT_COOL, + 'Heat': HVACMode.HEAT, +} +_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} + +# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where +# a clean match exists so they auto-localize; "turbo" is custom (translated). +_DEVICE_TO_FAN: dict[str, str] = { + '0': 'auto', + '1': 'low', + '2': 'medium', + '3': 'high', + '4': 'turbo', +} +_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()} + +# Swing (wind direction): all map onto HA standard swing constants (auto-localize). +_DEVICE_TO_SWING: dict[str, str] = { + 'Fix': 'off', + 'All': 'both', + 'Up_And_Low': 'vertical', +} +_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} + +# Preset (convenient mode): Off/Sleep map onto HA standard presets; Quiet/Smart/ +# Speed are custom (translated). +_DEVICE_TO_PRESET: dict[str, str] = { + 'Off': 'none', + 'Sleep': 'sleep', + 'Quiet': 'quiet', + 'Smart': 'smart', + 'Speed': 'speed', +} +_PRESET_TO_DEVICE = {v: k for k, v in _DEVICE_TO_PRESET.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + LocalThingsClimate(coordinator, b) + for b in coordinator.bound + if isinstance(b.desc, ClimateDesc) and _is_included(b, coordinator) + ) + + +def _first(value): + """Samsung `modes` is a single-element list on some resources, a scalar on + others. Return the first element of a list, else the value itself.""" + if isinstance(value, (list, tuple)): + return value[0] if value else None + return value + + +def _num(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +class LocalThingsClimate(LocalThingsEntity, ClimateEntity): + """Composite climate entity for a Samsung air conditioner.""" + + # translation_key comes from the ClimateDesc (base __init__ sets + # _attr_translation_key from bound.desc), resolving the state_attributes + # translations under entity.climate.airconditioner. + # Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF. + _enable_turn_on_off_backwards_compatibility = False + + def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: + super().__init__(coordinator, bound) + # Primary/main entity for the device: no name suffix, just the device name. + self._attr_name = None + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + # -- resource helpers --------------------------------------------------- + + def _rep(self, href: str) -> dict: + return self.coordinator.resource(href) or {} + + def _is_on(self) -> bool: + rep = self._rep(POWER_HREF) + if 'value' in rep: + return bool(rep.get('value')) + vs = self._rep(POWER_VS_HREF) + return str(vs.get('x.com.samsung.da.power', '')).lower() == 'on' + + def _supported(self, href: str) -> list[str]: + return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + + # -- temperature -------------------------------------------------------- + + @property + def temperature_unit(self) -> str: + units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper() + return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS + + @property + def current_temperature(self): + return _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + + @property + def target_temperature(self): + return _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + + def _range(self) -> list | None: + r = self._rep(TEMP_DESIRED_HREF).get('range') + return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None + + @property + def min_temp(self) -> float: + r = self._range() + return float(r[0]) if r else super().min_temp + + @property + def max_temp(self) -> float: + r = self._range() + return float(r[1]) if r else super().max_temp + + @property + def target_temperature_step(self) -> float: + return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0 + + # -- hvac mode ---------------------------------------------------------- + + @property + def hvac_mode(self) -> HVACMode: + if not self._is_on(): + return HVACMode.OFF + device = _first(self._rep(MODE_HREF).get(_MODES_FIELD)) + return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO) + + @property + def hvac_modes(self) -> list[HVACMode]: + modes = [HVACMode.OFF] + for m in self._supported(MODE_HREF): + mapped = _DEVICE_TO_HVAC.get(m) + if mapped is not None and mapped not in modes: + modes.append(mapped) + return modes + + # -- fan / swing / preset ---------------------------------------------- + + @property + def fan_mode(self): + return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD))) + + @property + def fan_modes(self) -> list[str]: + return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF) + if c in _DEVICE_TO_FAN] + + @property + def swing_mode(self): + return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD))) + + @property + def swing_modes(self) -> list[str]: + return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF) + if c in _DEVICE_TO_SWING] + + @property + def preset_mode(self): + return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))) + + @property + def preset_modes(self) -> list[str]: + return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF) + if c in _DEVICE_TO_PRESET] + + # -- writes ------------------------------------------------------------- + + async def async_set_temperature(self, **kwargs) -> None: + temp = kwargs.get('temperature') + if temp is not None: + await self.coordinator.async_send_command(self._bound, ('temperature', temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await self.coordinator.async_send_command(self._bound, ('power', False)) + return + device = _HVAC_TO_DEVICE.get(hvac_mode) + if device is None: + return + if not self._is_on(): + await self.coordinator.async_send_command(self._bound, ('power', True)) + await self.coordinator.async_send_command(self._bound, ('mode', device)) + + async def async_turn_on(self) -> None: + await self.coordinator.async_send_command(self._bound, ('power', True)) + + async def async_turn_off(self) -> None: + await self.coordinator.async_send_command(self._bound, ('power', False)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + device = _FAN_TO_DEVICE.get(fan_mode) + if device is not None: + await self.coordinator.async_send_command(self._bound, ('fan', device)) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + device = _SWING_TO_DEVICE.get(swing_mode) + if device is not None: + await self.coordinator.async_send_command(self._bound, ('swing', device)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + device = _PRESET_TO_DEVICE.get(preset_mode) + if device is not None: + await self.coordinator.async_send_command(self._bound, ('preset', device)) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 368d83d..8502bc9 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -1,6 +1,6 @@ DOMAIN = "localthings" -PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"] +PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"] CONF_HOST = "host" CONF_PORT = "port" diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 207c0e1..8c9021e 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -2,12 +2,14 @@ from typing import Optional from ._base import DeviceRegistry -from . import dishwasher, dryer, oven, refrigerator, washer +from . import airconditioner, dishwasher, dryer, oven, refrigerator, washer __all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model'] _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'airconditioner': airconditioner.REGISTRY, + 'air_conditioner': airconditioner.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, @@ -88,4 +90,8 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper()) if key is None and '_REF_' in (model_num or ''): key = 'refrigerator' + # Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and + # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. + if key is None and '_PRAC_' in (model_num or ''): + key = 'airconditioner' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py new file mode 100644 index 0000000..7d27cc8 --- /dev/null +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -0,0 +1,29 @@ +"""Air-conditioner device registry (Samsung ARTIK051_PRAC-class, issue #17). + +The first device whose core controls surface as a single composite HA `climate` +entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind +are consumed by that entity rather than exposed as separate switches/selects, so +this registry deliberately does NOT include the common POWER caps -- on/off is +the climate entity's HVACMode.OFF / TURN_ON/OFF. + +Reuses common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every +registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0. +""" +from ..capabilities import airconditioner, common, dishwasher, fridge, ignored +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='airconditioner', + capabilities=_build([ + *ignored.IGNORED, + common.ALARMS, + common.ENERGY_METER, + fridge.FIRMWARE_UPDATE, + dishwasher.DIAGNOSIS, + airconditioner.CLIMATE, + airconditioner.AIR_PURIFY, + airconditioner.AUTO_CLEAN, + airconditioner.AIR_FILTER, + *airconditioner.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py new file mode 100644 index 0000000..b085d70 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -0,0 +1,158 @@ +"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class). + +Resources verified against the issue #17 diagnostics dump (model +ARTIK051_PRAC_20K). This is the first family whose core controls surface as a +single composite HA `climate` entity rather than a scatter of switches/selects: +power (on/off), HVAC mode, current/target temperature, fan (wind) strength, +swing (wind direction), and the convenient-mode preset all live on one climate +card. The climate platform (climate.py) reads those sibling resources from the +coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the +`ClimateDesc` and mark the consumed siblings as covered. + +None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`, +`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a +different schema (see capabilities/__init__.py). They live only in the AC +by_type registry. +""" +from ..capability import Capability +from ..entities import ClimateDesc, SensorDesc, SwitchDesc + + +def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _filter_usage_percent(rep): + """Filter usage as a percentage of rated capacity. The device reports + `filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g. + 100 of a 500 capacity), so a plain value with a '%' unit would be wrong -- + normalize to used/capacity. Returns None when capacity is missing/zero.""" + used = _num(rep.get('x.com.samsung.da.filterUsage')) + cap = _num(rep.get('x.com.samsung.da.filterCapacity')) + if used is None or not cap: + return None + return round(used / cap * 100) + + +def _first_mode(rep): + """Representative scalar for the climate entity in the flattened state + (golden/regression). The real entity computes hvac_mode from power + mode.""" + modes = rep.get('x.com.samsung.da.modes') + if isinstance(modes, (list, tuple)): + return modes[0] if modes else None + return modes + + +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 + code (the platform maps HA<->device). async_send_command POSTs to path_segs, + so a single desc drives writes across power/mode/temperature/wind resources. + Read-modify-write safe: each write sends only its own field, leaving the + resource's other fields (e.g. /mode/vs/0's opaque `options` blob) untouched. + """ + kind, value = payload + if kind == 'power': + return (['power', '0'], {'value': bool(value)}) + if kind == 'mode': + return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}) + if kind == 'temperature': + return (['temperature', 'desired', '0'], {'temperature': int(round(float(value)))}) + if kind == 'fan': + return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}) + if kind == 'swing': + return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value}) + if kind == 'preset': + return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value}) + return None + + +CLIMATE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + ClimateDesc(key='climate', translation_key='airconditioner', + rep_fn=_first_mode, write_fn=_climate_write), + ), +) + +AIR_PURIFY = Capability( + href='/option/airpurify/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='air_purify', field='x.com.samsung.da.modes', + name='Air purification', icon='mdi:air-purifier', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['option', 'airpurify', 'vs', '0'], + {'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})), + ), +) + +AUTO_CLEAN = Capability( + href='/option/autoclean/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus', + name='Auto clean', icon='mdi:spray-bottle', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['option', 'autoclean', 'vs', '0'], + {'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})), + ), +) + +AIR_FILTER = Capability( + href='/filter/airdustfilter/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent, + name='Filter usage', unit='%', state_class='measurement', + icon='mdi:air-filter', entity_category='diagnostic'), + SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus', + name='Filter status', device_class='enum', + options=('normal', 'wash', 'replace'), + translation_key='air_filter_status', + icon='mdi:air-filter', entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + ), +) + +# --------------------------------------------------------------------------- +# AC-scoped coverage: hrefs consumed by the composite climate entity, and +# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in +# the global ignored.IGNORED because several of them (/mode/vs/0 handled above, +# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A +# no-entity Capability still marks the href as bound so discover() reports no +# coverage gap. +# --------------------------------------------------------------------------- +_CONSUMED_BY_CLIMATE = [ + '/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF + '/power/vs/0', # vendor fallback for on/off + '/temperature/current/0', # climate current_temperature + '/temperature/desired/0', # climate target_temperature (write target) + '/temperature/control/vs/0', # climate target_temperature_step + '/wind/strength/vs/0', # climate fan_mode + '/wind/direction/vs/0', # climate swing_mode + '/mode/convenient/vs/0', # climate preset_mode +] + +_AC_IGNORED = [ + # Vendor superset that duplicates the OCF /temperature/current+desired pair. + '/temperatures/vs/0', + # All-zero and ambiguously encoded on this model (2-value arrays); the + # 'don't guess' rule -- leave unmodeled rather than invent entities. + '/sensors/vs/0', + '/humidity/0', + '/humidity/vs/0', + # Presence-personalization plumbing (empty item list here). + '/personality/presence/vs/0', +] + +# Built as bare no-entity caps; folded into the AC registry (not global). +COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)] diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index caba9e1..a22d056 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -86,6 +86,16 @@ class TimeDesc(SamsungEntityDescription): write_fn: WriteFn = None +@dataclass(frozen=True, kw_only=True) +class ClimateDesc(SamsungEntityDescription): + # A composite entity: it binds one *primary* resource (its href) but the + # climate platform reads sibling resources (power, temperature, wind) from + # the coordinator snapshot and writes to several of them. write_fn takes a + # (kind, value) payload from the platform and returns the (path_segs, body) + # for that one sub-write, so a single desc drives multi-resource writes. + write_fn: WriteFn = None + + PLATFORM_OF: dict[type, str] = { SensorDesc: 'sensor', BinarySensorDesc: 'binary_sensor', @@ -94,4 +104,5 @@ PLATFORM_OF: dict[type, str] = { ButtonDesc: 'button', NumberDesc: 'number', TimeDesc: 'time', + ClimateDesc: 'climate', } diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 459efc4..2647ab0 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -140,6 +140,44 @@ "active": "Active", "pause": "Paused" } + }, + "air_filter_status": { + "state": { + "normal": "Normal", + "wash": "Wash", + "replace": "Replace" + } + } + }, + "climate": { + "airconditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Auto", + "low": "Low", + "medium": "Medium", + "high": "High", + "turbo": "Turbo" + } + }, + "swing_mode": { + "state": { + "off": "Fixed", + "both": "All directions", + "vertical": "Up and down" + } + }, + "preset_mode": { + "state": { + "none": "Off", + "sleep": "Sleep", + "quiet": "Quiet", + "smart": "Smart", + "speed": "Speed" + } + } + } } } }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 459efc4..2647ab0 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -140,6 +140,44 @@ "active": "Active", "pause": "Paused" } + }, + "air_filter_status": { + "state": { + "normal": "Normal", + "wash": "Wash", + "replace": "Replace" + } + } + }, + "climate": { + "airconditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Auto", + "low": "Low", + "medium": "Medium", + "high": "High", + "turbo": "Turbo" + } + }, + "swing_mode": { + "state": { + "off": "Fixed", + "both": "All directions", + "vertical": "Up and down" + } + }, + "preset_mode": { + "state": { + "none": "Off", + "sleep": "Sleep", + "quiet": "Quiet", + "smart": "Smart", + "speed": "Speed" + } + } + } } } }, diff --git a/tests/fixtures/airconditioner_device.json b/tests/fixtures/airconditioner_device.json new file mode 100644 index 0000000..8c4f224 --- /dev/null +++ b/tests/fixtures/airconditioner_device.json @@ -0,0 +1,400 @@ +{ + "meta": { + "model": "ARTIK051_PRAC_20K", + "device_type": "airconditioner", + "source": "issue #17 diagnostics (scrubbed)", + "note": "First air-conditioner /device/0 dump; first composite HA climate entity." + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "Off", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "1", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low" + ] + } + }, + { + "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-21T07:20:30" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm", + "x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30", + "x.com.samsung.da.state": "Created" + } + ] + } + }, + { + "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": "24.0", + "x.com.samsung.da.current": "27.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 24.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.cumulativePower": "1686632", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_79", + "CoolCapa_50", + "WarmCapa_60", + "Light_Off", + "Volume_100", + "OptionCode_53304", + "ExtendOptionCode_197256", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000", + "x.com.samsung.da.description": "ARTIK051_PRAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02181A230313", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "22112400,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "0000,0000" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_HEATPUMP_3.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/timezone/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json new file mode 100644 index 0000000..afe8a6d --- /dev/null +++ b/tests/fixtures/golden/airconditioner.json @@ -0,0 +1,13 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "diagnosis_status", + "energy_kwh", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py new file mode 100644 index 0000000..efcdbce --- /dev/null +++ b/tests/test_airconditioner_capabilities.py @@ -0,0 +1,108 @@ +"""Tests for Samsung air-conditioner support (issue #17). + +These stay HA-free like the rest of the suite: they exercise the registry, +discovery/flatten, and the CLIMATE capability's write contract. The composite +climate entity itself lives in climate.py (imports homeassistant) and is not +importable here -- consistent with how the other HA platform files are untested. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import airconditioner +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc + +from tests.conftest import _load_device + + +def _ac(): + resources = _load_device('airconditioner') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound(): + reg, resources = _ac() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def test_ac_model_resolves_to_airconditioner_registry(): + reg, _ = _ac() + assert reg is not None and reg.name == 'airconditioner' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #17 dump binds or is covered -- clears the + coverage-gap repair.""" + reg, resources = _ac() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_climate_entity_is_bound(): + """The composite climate entity binds the primary /mode/vs/0 resource.""" + bound, _ = _bound() + climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] + assert len(climate) == 1 + assert climate[0].href == '/mode/vs/0' + + +def test_expected_state_keys_present(): + state = _state() + for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status', + 'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'): + assert key in state, key + + +def test_power_and_convenient_folded_into_climate(): + """On/off is the climate entity's HVACMode.OFF and convenient mode is its + preset_mode -- neither surfaces as a standalone switch/select.""" + state = _state() + assert 'power_switch' not in state + assert 'convenient_mode' not in state + + +def test_air_filter_usage_is_percentage_of_capacity(): + """filterUsage is a raw count in the capacity unit (100 of 500), surfaced as + a percentage rather than the misleading raw value.""" + assert _state()['air_filter_usage'] == 20 + + +def test_climate_write_targets(): + """The CLIMATE write_fn maps each (kind, value) command to the right OCF + POST target and body. `value` is already the raw device code.""" + write = airconditioner.CLIMATE.entities[0].write_fn + assert write(('power', True), {}) == (['power', '0'], {'value': True}) + assert write(('power', False), {}) == (['power', '0'], {'value': False}) + assert write(('mode', 'Heat'), {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']}) + assert write(('temperature', 23.6), {}) == ( + ['temperature', 'desired', '0'], {'temperature': 24}) + assert write(('fan', '2'), {}) == ( + ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'}) + assert write(('swing', 'All'), {}) == ( + ['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'}) + assert write(('preset', 'Sleep'), {}) == ( + ['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'}) + assert write(('bogus', 1), {}) is None + + +def test_climate_consumed_hrefs_declared_as_coverage(): + """The climate-consumed and ambiguous hrefs are declared in the AC registry + (as no-entity coverage caps) so they don't leak as gaps -- but produce no + standalone entities.""" + reg, _ = _ac() + for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', + '/wind/strength/vs/0', '/mode/convenient/vs/0', + '/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'): + caps = reg.capabilities.get(href) + assert caps, href + assert all(c.entities == () for c in caps), href diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 4b50ad4..ff2caaa 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -168,6 +168,18 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'refrigerator' + def test_airconditioner_via_prac_token(self): + """Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and + an unrecognized consumer token ('20K'); it falls back to the '_PRAC_' + (Package Room Air Conditioner) token in modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000', + 'ARTIK051_PRAC_20K', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') diff --git a/tests/test_entities.py b/tests/test_entities.py index f28fa4a..fce7295 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,6 +1,6 @@ from custom_components.localthings.registry.entities import ( SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc, - NumberDesc, PLATFORM_OF, + NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF, ) @@ -26,6 +26,8 @@ def test_platform_mapping_covers_all_subclasses(): assert PLATFORM_OF[SwitchDesc] == 'switch' assert PLATFORM_OF[ButtonDesc] == 'button' assert PLATFORM_OF[NumberDesc] == 'number' + assert PLATFORM_OF[TimeDesc] == 'time' + assert PLATFORM_OF[ClimateDesc] == 'climate' def test_select_carries_options_and_write_fn(): diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 26f439c..e9a6cfb 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -69,6 +69,18 @@ def test_registry_reproduces_golden_state_keys_for_dryer(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner(): + from tests.conftest import _load_device + resources = _load_device('airconditioner') + golden = json.loads((GOLDEN / 'airconditioner.json').read_text()) + state_keys = _new_state_keys('airconditioner', 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 = {