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/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 4803a26..1a9b9aa 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -212,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() diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 91f0466..5958018 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,7 +15,9 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc, +) from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -43,11 +45,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, ] @@ -127,6 +136,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 @@ -180,6 +274,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 @@ -204,6 +308,58 @@ CLIMATE = Capability( exists_fn=_has_display_light_option, write_fn=_display_light_write, icon='mdi:led-on', 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'), ), ) @@ -357,15 +513,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='%'), ), ) @@ -397,6 +561,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/translations/en.json b/custom_components/localthings/translations/en.json index 07ab5b7..c875314 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -134,7 +134,8 @@ "longwind": "Long wind", "motionindirect": "Motion indirect", "motiondirect": "Motion direct", - "drycomfort": "Dry comfort" + "drycomfort": "Dry comfort", + "2step": "2-Step" } } } @@ -156,6 +157,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Beep volume" + }, "cook_time": { "name": "Cook time" }, @@ -165,6 +169,9 @@ "dispense_capacity": { "name": "Dispense capacity" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -609,9 +616,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" }, @@ -869,6 +882,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Air monitoring" + }, "air_purify": { "name": "Air purification" }, @@ -887,6 +903,9 @@ "dustbin_auto_close": { "name": "Dustbin auto-close" }, + "spi": { + "name": "Purifying ion" + }, "uvc_intensive_mode": { "name": "UV-C intensive mode" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 3b8a1ea..693e806 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -134,7 +134,8 @@ "longwind": "Lange wind", "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", - "drycomfort": "Droog comfort" + "drycomfort": "Droog comfort", + "2step": "2-Step" } } } @@ -156,6 +157,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Zoemervolume" + }, "cook_time": { "name": "Bereidingstijd" }, @@ -165,6 +169,9 @@ "dispense_capacity": { "name": "Schenkhoeveelheid" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -609,9 +616,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" }, @@ -869,6 +882,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Luchtmonitoring" + }, "air_purify": { "name": "Luchtzuivering" }, @@ -887,6 +903,9 @@ "dustbin_auto_close": { "name": "Stofreservoir automatisch sluiten" }, + "spi": { + "name": "Ionisatie" + }, "uvc_intensive_mode": { "name": "Intensieve UV-C-modus" }, 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/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/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 3a63492..f513be7 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -404,4 +404,14 @@ 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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 09650ff..917e12c 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -671,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 = {