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/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 dc92b91..ca6ab24 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Beep volume" + }, "cook_time": { "name": "Cook time" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Dispense capacity" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -569,9 +575,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" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Air monitoring" + }, "air_purify": { "name": "Air purification" }, @@ -838,6 +853,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 b23b93f..80becb6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Zoemervolume" + }, "cook_time": { "name": "Bereidingstijd" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Schenkhoeveelheid" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -569,9 +575,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" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Luchtmonitoring" + }, "air_purify": { "name": "Luchtzuivering" }, @@ -838,6 +853,9 @@ "dustbin_auto_close": { "name": "Stofreservoir automatisch sluiten" }, + "spi": { + "name": "Ionisatie" + }, "uvc_intensive_mode": { "name": "Intensieve UV-C-modus" },