diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index e144da7..063b811 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, + PRESET_NONE, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature @@ -66,8 +67,22 @@ _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' _DEVICE_TO_HVAC: dict[str, HVACMode] = { 'Cool': HVACMode.COOL, 'Dry': HVACMode.DRY, + # Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and + # 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The + # reverse write can't rely on this map alone (two codes, one HA value) -- + # _device_code_for_hvac() resolves the code from the unit's own + # supportedModes, so this is only a fallback for a unit reporting no + # supportedModes at all. 'Fan' is listed first so the {v: k} reverse + # comprehension below has 'Wind' win that fallback (last-key-wins), + # preserving the original single-spelling behavior rather than silently + # flipping it when 'Fan' was added. + 'Fan': HVACMode.FAN_ONLY, 'Wind': HVACMode.FAN_ONLY, - 'Auto': HVACMode.HEAT_COOL, + # The device's 'Auto' is a single-setpoint "device decides" mode -> HA + # HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool" + # and implies a two-setpoint heat+cool range these single-setpoint units + # (including cool-only models) don't have. + 'Auto': HVACMode.AUTO, 'Heat': HVACMode.HEAT, } _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} @@ -77,11 +92,11 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} # like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto' # behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as # separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled -# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto' -# would if it were ever mapped there) and a dedicated 'ai_comfort' preset -# carries the distinction a bare hvac_mode can't. Not reachable via -# async_set_hvac_mode -- entered/left only through the preset, since there's -# no dedicated HVACMode value for it to write back to. +# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain +# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the +# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode -- +# entered/left only through the preset, since there's no dedicated HVACMode +# value for it to write back to. _AI_COMFORT_MODE = 'AIComfort' PRESET_AI_COMFORT = 'ai_comfort' @@ -105,16 +120,18 @@ _DEVICE_TO_SWING: dict[str, str] = { } _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()} +# Preset (convenient mode): resolved dynamically from the device's own +# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' +# code maps to HA's PRESET_NONE ("no preset active"); every other code is +# exposed as its lowercased self and labelled in translations +# (entity.climate.airconditioner.state_attributes.preset_mode.state.), +# so any board's convenient modes surface without code changes, and an +# unlabelled code just renders as its raw value until a label is added. +# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/ +# 'NanoSleep' codes on cool-only global RAC boards -- that's just a +# translation label, not a hard-coded mode.) +def _preset_to_ha(code) -> str: + return PRESET_NONE if code == 'Off' else str(code).lower() async def async_setup_entry( @@ -194,11 +211,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): 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' + # Prefer the vendor /power/vs/0 (present on every observed board and + # the resource writes target -- see airconditioner._climate_write). + # The OCF /power/0 is absent on many boards and a stale mirror on + # some, so reading it first showed pre-write state after a power + # toggle (issue #53: "can turn on but not off"). + power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power') + if power is not None: + return str(power).lower() == 'on' + return bool(self._rep(POWER_HREF).get('value')) def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) @@ -234,6 +255,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # -- temperature -------------------------------------------------------- + def _ocf_temp_authoritative(self) -> bool: + """True when the OCF /temperature/{current,desired}/0 pair is the + authoritative temperature channel -- signalled by + /temperature/current/0 being present. Those boards honour reads/ + writes on /temperature/desired/0 and ignore the vendor + /temperatures/vs/0; boards without the pair (only a desired stub, or + nothing) are the reverse. Confirmed on live units of both kinds.""" + return bool(self._rep(TEMP_CURRENT_HREF)) + def _temps_vs(self) -> dict: """Vendor `/temperatures/vs/0` items[0] (empty {} when absent).""" return _temps_vs_item(self._rep(TEMPS_VS_HREF)) @@ -256,10 +286,14 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def target_temperature(self): - v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) - if v is None: - v = _num(self._temps_vs().get('x.com.samsung.da.desired')) - return v + # Read from the same channel writes go to (see async_set_temperature): + # OCF /temperature/desired/0 on boards with the full OCF pair, vendor + # /temperatures/vs/0 otherwise -- with the other as fallback. + ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + vs = _num(self._temps_vs().get('x.com.samsung.da.desired')) + if self._ocf_temp_authoritative(): + return ocf if ocf is not None else vs + return vs if vs is not None else ocf def _range(self) -> list | None: r = self._rep(TEMP_DESIRED_HREF).get('range') @@ -336,27 +370,42 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def preset_mode(self): if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE: return PRESET_AI_COMFORT - return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) + code = _first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD)) + return _preset_to_ha(code) if code is not None else None @property def preset_modes(self) -> list[str]: - modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) + modes = [_preset_to_ha(c) for c in self._supported(CONVENIENT_HREF)] if _AI_COMFORT_MODE in self._supported(MODE_HREF): modes.append(PRESET_AI_COMFORT) return modes # -- writes ------------------------------------------------------------- + def _device_code_for_hvac(self, hvac_mode: HVACMode): + """Device mode code for an HA hvac_mode, chosen from this unit's own + supportedModes -- fan-only is 'Wind' on some boards and 'Fan' on + others, so the reverse map alone can't pick the code this unit + accepts.""" + for code in self._supported(MODE_HREF): + if _DEVICE_TO_HVAC.get(code) == hvac_mode: + return code + return _HVAC_TO_DEVICE.get(hvac_mode) + 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)) + if temp is None: + return + # OCF-pair boards write /temperature/desired/0; vendor boards write + # /temperatures/vs/0 (see airconditioner._climate_write). + kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature' + await self.coordinator.async_send_command(self._bound, (kind, 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) + device = self._device_code_for_hvac(hvac_mode) if device is None: return if not self._is_on(): @@ -388,4 +437,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep. await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE)) return - await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) + # Reverse-resolve against the unit's own supportedModes (codes aren't + # 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)) + return diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 333df3a..dc9e960 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -23,6 +23,7 @@ from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model, for_device_by_resources from .registry.capabilities.common import ( + merge_items_field, merge_options_field, remote_control_enabled, remote_control_required_for_write, @@ -641,23 +642,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # introduced its own races around overlapping writes to the same # href. Simpler and safer to just hold the guard for the full, # generously-sized window and let it expire on its own. - # write_fn bodies that touch x.com.samsung.da.options carry only the - # changed token(s) now (issue #54: confirmed sufficient on the wire -- - # the device merges by prefix itself), not the whole packed array. - # observe.apply()'s field-level {**cached, **rep} merge doesn't know - # that -- handed the bare token list, it would replace the cached - # field outright and wipe every sibling option for the rest of the - # settle window. Pre-merge it here the same way the device does, so - # the optimistic cache entry stays complete; the minimal `body` below - # is still exactly what goes out over the wire. + # write_fn bodies that touch x.com.samsung.da.options or + # x.com.samsung.da.items carry only the changed token(s)/item now + # (issue #54 for options; the AC vendor temperature write for items -- + # confirmed sufficient on the wire, the device merges the rest itself), + # not the whole packed array. observe.apply()'s field-level + # {**cached, **rep} merge doesn't know that -- handed the bare + # partial value, it would replace the cached field outright and wipe + # every sibling option/item for the rest of the settle window. + # Pre-merge it here the same way the device does, so the optimistic + # cache entry stays complete; the minimal `body` below is still + # exactly what goes out over the wire. optimistic_body = body new_options = body.get('x.com.samsung.da.options') if isinstance(new_options, list): cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options') optimistic_body = { - **body, + **optimistic_body, 'x.com.samsung.da.options': merge_options_field(cached_options, new_options), } + # Same fact, items[] shape (e.g. airconditioner._climate_write's vendor + # temperature write, which now carries only {id, desired} -- see that + # module for the write-side half of this). + new_items = body.get('x.com.samsung.da.items') + if isinstance(new_items, list): + cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items') + optimistic_body = { + **optimistic_body, + 'x.com.samsung.da.items': merge_items_field(cached_items, new_items), + } self._observe.apply(write_href, optimistic_body, source='optimistic') self._observe.mark_write_pending( write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 956aeb5..712dace 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -160,10 +160,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. if key is None and '_PRAC_' in (model_num or ''): key = 'airconditioner' - # Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain - # '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the - # 'P' sits between the underscore and 'RAC' in that token). - if key is None and '_RAC_' in (model_num or ''): + # Other RAC boards carry a bare 'RAC' (Room Air Conditioner) token in the + # modelNum, in one of two spellings: the underscore form '_RAC_' (e.g. + # TP2X_RAC_20K, issue #37) or the hyphenated form '-RAC-' (e.g. + # TP1X_DA-AC-RAC-01001, a cool-only global variant, issue #91). Both are + # distinct from '_PRAC_' above ('P' sits before 'RAC' with no delimiter) + # and from range ('-RANGE-') / oven ('-OVEN-') tokens. Most TP1X boards + # self-report oneUiVersion and resolve via for_device() upstream of this + # fallback; the hyphenated match is what rescues variants whose + # /otninformation/vs/0 ships no swVersionInfo block at all, so + # oneUiVersion is empty. + if key is None and ('_RAC_' in (model_num or '') + or '-RAC-' in (model_num or '').upper()): key = 'airconditioner' # System air conditioners (multi-indoor-unit commercial installs, e.g. # A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index d953705..136d524 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -15,6 +15,13 @@ issue #56's follow-up (five diagnostics dumps captured with the physical unit set to Auto/Sleep/Low/Medium/High): Light_On / Light_Off -- a plain on/off flag; MODE below models it as a real switch, RMW-replacing just that one entry. + NOT the same polarity as the AC family's own + Light_On/Light_Off token on its own /mode/vs/0 + (airconditioner._display_light_on) -- that one is + confirmed inverted (Light_Off means the panel is + lit) on live hardware. Same token name, same + resource name, different device type and + opposite meaning -- don't unify them. Comode_Off -- read 'Off' on *every* one of the five dumps, including High/Low/Medium/Auto -- confirms this is NOT the fan-speed selector (ruling out the diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 599affd..67bec22 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -17,6 +17,7 @@ by_type registry. from ..capability import Capability from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc from .common import normalize_temp_unit +from .laundry import option_write # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the @@ -91,21 +92,87 @@ def _first_mode(rep): return modes +def _mode_options(rep): + opts = rep.get('x.com.samsung.da.options') + return opts if isinstance(opts, (list, tuple)) else () + + +def _has_display_light_option(rep, resources): + """True when the panel light state is carried inside /mode/vs/0's options + blob (a `Light_On`/`Light_Off` token) rather than a dedicated /light/vs/0 + switch. The two encodings are mutually exclusive across observed boards: + models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below) + carry no Light_* option, so this entity only materialises on the boards + that would otherwise have no display-light entity at all.""" + return any(isinstance(o, str) and o.startswith('Light_') + for o in _mode_options(rep)) + + +def _display_light_on(rep): + """Panel display light state from /mode/vs/0's options blob. The token is + INVERTED relative to its name -- confirmed by a live toggle test: with the + panel lit the option reads `Light_Off`, and with it dark it reads + `Light_On` (the flag really encodes "night/display-off mode active"). So + `Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the + write below uses the device's own single-token merge (see option_write).""" + for o in _mode_options(rep): + if isinstance(o, str) and o.startswith('Light_'): + return o == 'Light_Off' + return None + + +def _display_light_write(payload, rep, href=None): + """Toggle the panel light via a single-token /mode/vs/0 options write + ('SingleCommand_1' is advertised in this family's /configuration/vs/0 + airconOptionList; option_write's one-token merge is the same mechanism + air_purifier.py uses for its own Light switch). Polarity is inverted (see + _display_light_on): switching the lamp ON writes 'Light_Off', OFF writes + 'Light_On'.""" + token = 'Off' if payload == 'On' else 'On' + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': option_write('Light', token)}) + + 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. + so a single desc drives writes across the power/mode/temperature/wind + resources. + + Power goes to the vendor `/power/vs/0` (the OCF `/power/0` is absent on + most boards and a non-authoritative mirror where present -- vendor works + on every board). Temperature is board-dependent, so the platform picks the + channel and sends `temperature_ocf` (-> OCF `/temperature/desired/0`, + boards with the full OCF current+desired pair) or `temperature` (-> vendor + `/temperatures/vs/0`, boards without it). Mode/fan/swing/preset are always + the vendor `/x/vs/0` resources. + + Each write sends only its own field(s), leaving the resource's other + fields (e.g. /mode/vs/0's opaque `options` blob, or the vendor temperature + item's current/minimum/maximum/unit) untouched -- the device merges the + rest itself, same contract as the options[] array (see + common.merge_items_field / merge_options_field, which keep the + coordinator's optimistic cache complete for the settle window instead of + this write echoing those fields back). """ kind, value = payload if kind == 'power': - return (['power', '0'], {'value': bool(value)}) + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) if kind == 'mode': return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}) + if kind == 'temperature_ocf': + return (['temperature', 'desired', '0'], + {'temperature': int(round(float(value)))}) if kind == 'temperature': - return (['temperature', 'desired', '0'], {'temperature': int(round(float(value)))}) + # Vendor items[] array; only one item observed on every AC dump, id + # '0'. See the docstring above for why this doesn't echo current/ + # minimum/maximum/unit back at the unit. + return (['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': [ + {'x.com.samsung.da.id': '0', + 'x.com.samsung.da.desired': str(int(round(float(value))))}]}) if kind == 'fan': return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}) if kind == 'swing': @@ -121,6 +188,19 @@ CLIMATE = Capability( entities=( ClimateDesc(key='climate', translation_key='airconditioner', rep_fn=_first_mode, write_fn=_climate_write), + # Display (panel) light switch, only on boards that encode it in + # /mode/vs/0's options instead of a /light/vs/0 switch (see + # _has_display_light_option). Shares the /mode/vs/0 href with the + # climate entity above -- same Capability, so no multi-cap + # discriminator is needed. /mode/vs/0 is OBSERVE-subscribed, so state + # updates on push; writes go through the single-token merge in + # _display_light_write. Shares the switch.display_light translation + # with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) -- + # mutually exclusive per href, so only one ever binds for a given unit. + SwitchDesc(key='display_light', rep_fn=_display_light_on, + exists_fn=_has_display_light_option, + write_fn=_display_light_write, + icon='mdi:led-on', entity_category='config'), ), ) @@ -304,7 +384,14 @@ _AC_IGNORED = [ '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) '/mds/absencestate/vs/0', # motion-detection state (empty here) '/remotedatacontrol/vs/0', # remote data-control session status + '/remotedeviceinfo/vs/0', # remote paired-device id list (empty didList here) '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) + # Manual airflow-step position (supportedModes Off/80/60/40/Power). + # Overlaps the /wind/direction swing control already on the climate card, + # and the meaning of the numeric steps vs. 'Power' isn't documented in the + # dump -- ignored per the 'don't guess' rule rather than modeled as a + # select whose write could confuse live HVAC hardware. + '/stepcontrol/vs/0', '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob '/welcome/temperature/vs/0', # welcome-cooling plumbing # System-AC-only (multi-indoor-unit commercial installs, e.g. diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index eb33d37..2b6dc59 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -104,6 +104,34 @@ def merge_options_field(cached, new_tokens): return merged +def merge_items_field(cached, new_items): + """Merge a partial x.com.samsung.da.items[]-style write (matched by + x.com.samsung.da.id) into a cached items array -- the read-side + counterpart of merge_options_field above, for the items[] shape instead + of the packed options[] shape. + + Confirmed on hardware that a write only needs to carry the array item + with the changed id plus the field(s) being changed; the device merges + the rest itself (same fact as the options[] case, different array -- + see airconditioner._climate_write's vendor temperature write). Fields + within the matched item are merged, not replaced outright, so a + setpoint-only write doesn't wipe current/minimum/maximum/unit from the + optimistic cache entry for the settle window. An id with no match in + `cached` is appended.""" + merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])] + for new_item in new_items or (): + if not isinstance(new_item, dict): + continue + item_id = new_item.get('x.com.samsung.da.id') + for i, existing in enumerate(merged): + if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id: + merged[i] = {**existing, **new_item} + break + else: + merged.append(new_item) + return merged + + # /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on # washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the # life of a given board, so reading them from the /device/0 seed (no dedicated diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4bbced4..1786d98 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -116,7 +116,13 @@ "ai_comfort": "AI Comfort", "quiet": "Quiet", "smart": "Smart", - "speed": "Speed" + "speed": "Speed", + "nano": "WindFree", + "nanosleep": "WindFree sleep", + "longwind": "Long wind", + "motionindirect": "Motion indirect", + "motiondirect": "Motion direct", + "drycomfort": "Dry comfort" } } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 89f382b..a6e73ed 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -116,7 +116,13 @@ "ai_comfort": "AI-comfort", "quiet": "Stil", "smart": "Slim", - "speed": "Snel" + "speed": "Snel", + "nano": "WindFree", + "nanosleep": "WindFree-slaap", + "longwind": "Lange wind", + "motionindirect": "Beweging indirect", + "motiondirect": "Beweging direct", + "drycomfort": "Droog comfort" } } } diff --git a/tests/fixtures/airconditioner_tp1x_rac_coolonly_device.json b/tests/fixtures/airconditioner_tp1x_rac_coolonly_device.json new file mode 100644 index 0000000..b5850b8 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_coolonly_device.json @@ -0,0 +1,515 @@ +{ + "meta": { + "model": "TP1X_DA-AC-RAC-01001_0000", + "device_type": "airconditioner", + "source": "user diagnostics (scrubbed)", + "note": "Cool-only global TP1X RAC variant (airconOptionList AI_RAC_GLOBAL_COOLONLY_3.0). Unlike the other TP1X_DA-AC-RAC dumps, its /otninformation/vs/0 ships no swVersionInfo block, so oneUiVersion is empty and detection must fall back to the hyphenated '-RAC-' modelNum token in for_device_by_model. Adds two hrefs absent from the other AC dumps -- /stepcontrol/vs/0 (manual airflow step) and /remotedeviceinfo/vs/0 (empty paired-device list), both ignored -- and exposes Samsung WindFree via the convenient-mode codes Nano/NanoSleep (in place of the Smart code on other boards; confirmed by an off-vs-on dump diff)." + }, + "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", + "Speed", + "Nano", + "NanoSleep" + ] + } + }, + { + "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": "0", + "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", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "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-26T15:24:44", + "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", + "x.com.samsung.da.triggeredTime": "2026-07-26T15:24:44", + "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": "25.0", + "x.com.samsung.da.current": "20.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, + 30 + ], + "units": "C", + "temperature": 20.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 25.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0.000000", + "x.com.samsung.da.cumulativePower": "445938", + "x.com.samsung.da.cumulativeSavedPower": "81528", + "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": [ + "Auto", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Auto" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_84", + "CoolCapa_35", + "WarmCapa_0", + "Light_Off", + "Volume_Mute", + "StopAutoClean_Idle", + "Autoclean_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "OptionCode_52344", + "ExtendOptionCode_230029", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "causeSource": "DEFT", + "operationNumber": "0" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10247441|60010523001811014E00002200D00000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AR2", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "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": "02646A260327", + "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": "102474A23112200", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "102702A24041900,102579A10000200", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-03: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_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "66" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 1, + "start": "2026-07-26T12:01:41Z", + "duration": 23, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.duration": "23:59:00", + "x.com.samsung.da.drlcStartTime": "2026-07-26T12:01:41Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C0161024904000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/stepcontrol/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "80", + "60", + "40", + "Power" + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-WW-TP1-24-ARXX00", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "045210247441FFFFFFFF", + "versions": [ + "23112200", + "FFFFFFFF" + ], + "visVersion": "231122" + }, + { + "type": "Micom", + "modelId": "04521027024110257941", + "versions": [ + "24041900", + "10000200" + ], + "visVersion": "240419" + } + ] + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Sao_Paulo", + "offset": "-03:00", + "DST": "OFF" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": "0", + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index 6a6596c..e0355d8 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -8,6 +8,7 @@ "climate", "current_temperature_c", "diagnosis_status", + "display_light", "energy_kwh", "energy_saved_kwh", "humidity", diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index c3cdd7c..456fd82 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -7,6 +7,7 @@ "climate", "current_temperature_c", "diagnosis_status", + "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json new file mode 100644 index 0000000..7b498eb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts" + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index a2c11e4..cca934d 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -6,6 +6,7 @@ "auto_clean", "climate", "current_temperature_c", + "display_light", "energy_kwh", "firmware_update", "humidity", diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index 70ba9c5..f18e143 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -8,6 +8,7 @@ "climate", "current_temperature_c", "diagnosis_status", + "display_light", "energy_kwh", "humidity", "power_watts" diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index cac35f6..924933d 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -746,10 +746,10 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href( """Regression for issues #17/#53, using the real AC capability. The composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href), but a power command's write_fn (airconditioner._climate_write) targets - the sibling /power/0 -- the href climate.py's `_is_on()` actually reads. + the sibling /power/vs/0 -- the href climate.py's `_is_on()` actually reads. The optimistic value and settle guard must land there, not on the bound /mode/vs/0, or the entity never sees the write and shows stale state - until the next unrelated read of /power/0.""" + until the next unrelated read of /power/vs/0.""" from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.discovery import BoundEntity @@ -768,9 +768,11 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href( fake.post = lambda *a, **k: (0x44, b'') await coordinator.async_send_command(bound, ('power', True)) - assert coordinator._cache.get('/power/0') == {'value': True} - assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True} - assert coordinator._observe._settle_until.get('/power/0') is not None + assert (coordinator._cache.get('/power/vs/0') or {}).get( + 'x.com.samsung.da.power') == 'On' + assert 'x.com.samsung.da.power' not in ( + coordinator._cache.get(airconditioner.CLIMATE.href) or {}) + assert coordinator._observe._settle_until.get('/power/vs/0') is not None async def test_send_command_survives_stale_confirm_poll( diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index a7ea9d6..3a63492 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -93,15 +93,30 @@ def test_air_filter_usage_is_percentage_of_capacity(): 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.""" + """The CLIMATE write_fn maps each (kind, value) command to the right vendor + POST target and body. `value` is already the raw device code. Power and + temperature target the vendor /power/vs/0 and /temperatures/vs/0 (the OCF + /power/0 is absent on most boards and a non-authoritative mirror where + present; /temperature/desired/0 is only written via the temperature_ocf + kind, on boards that have the OCF pair).""" 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(('power', True), {}) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}) + assert write(('power', False), {}) == ( + ['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'}) assert write(('mode', 'Heat'), {}) == ( ['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']}) - assert write(('temperature', 23.6), {}) == ( + # OCF-pair boards: temperature_ocf -> /temperature/desired/0. + assert write(('temperature_ocf', 23.6), {}) == ( ['temperature', 'desired', '0'], {'temperature': 24}) + # Vendor boards: temperature -> /temperatures/vs/0, carrying only the id + # and the changed field -- the device merges current/min/max/unit itself + # (see common.merge_items_field, wired into async_send_command, for the + # read-side half that keeps the optimistic cache complete). + assert write(('temperature', 22), {}) == ( + ['temperatures', 'vs', '0'], + {'x.com.samsung.da.items': [ + {'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]}) assert write(('fan', '2'), {}) == ( ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'}) assert write(('swing', 'All'), {}) == ( @@ -261,6 +276,90 @@ def test_current_limit_is_read_only(): assert getattr(desc, 'write_fn', None) is None +# --------------------------------------------------------------------------- +# TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91). Same modelNum as +# the issue #38 board above, but its /otninformation/vs/0 ships no +# swVersionInfo block, so oneUiVersion is empty and detection must fall back +# to the hyphenated '-RAC-' modelNum token (the older '_RAC_' underscore match +# doesn't fire on this DA-AC-RAC spelling). Adds /stepcontrol/vs/0 and +# /remotedeviceinfo/vs/0 (both ignored) and exposes the WindFree preset via +# the Nano/NanoSleep convenient-mode codes. Its panel light is carried inside +# /mode/vs/0's options blob instead of a dedicated /light/vs/0 switch. +# --------------------------------------------------------------------------- + +def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_fallback(): + """Empty oneUiVersion -> resolved by the '-RAC-' modelNum token, not + for_device(). Guards the regression where this unit loaded as 'unknown'.""" + resources = _load_device('airconditioner_tp1x_rac_coolonly') + otn = resources.get('/otninformation/vs/0', {}) + assert otn.get('swVersionInfo', {}).get('oneUiVersion', '') == '' + reg, _ = _resolve('airconditioner_tp1x_rac_coolonly') + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_rac_coolonly_no_unbound_hrefs(): + """Every resource binds or is ignored -- including the two hrefs unique + to this dump (/stepcontrol/vs/0, /remotedeviceinfo/vs/0). Clears the gap + repair.""" + reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_rac_coolonly_stray_hrefs_ignored(): + ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE} + assert '/stepcontrol/vs/0' in ignored_hrefs + assert '/remotedeviceinfo/vs/0' in ignored_hrefs + + +def test_tp1x_rac_coolonly_climate_bound(): + reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] + assert len(climate) == 1 and climate[0].href == '/mode/vs/0' + + +def test_tp1x_rac_coolonly_display_light_from_mode_options(): + """This board has no /light/vs/0 switch; the panel light lives in + /mode/vs/0's options and surfaces as a display_light switch. The token is + inverted vs its name (confirmed by a live toggle test): with the panel + lit the option reads `Light_Off`, and with it dark it reads `Light_On`.""" + reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state.get('display_light') is True + + +def test_display_light_option_parsing_and_gating(): + # Inverted token: Light_Off -> panel lit (on), Light_On -> panel dark (off). + lit = {'x.com.samsung.da.options': ['CoolCapa_35', 'Light_Off', 'Volume_Mute']} + dark = {'x.com.samsung.da.options': ['Light_On']} + absent = {'x.com.samsung.da.options': ['Volume_Mute']} + assert airconditioner._display_light_on(lit) is True + assert airconditioner._display_light_on(dark) is False + assert airconditioner._display_light_on(absent) is None + assert airconditioner._has_display_light_option(lit, {}) is True + assert airconditioner._has_display_light_option(absent, {}) is False + + +def test_mode_options_display_light_write_is_inverted_single_token(): + """Turning the lamp ON writes the inverted 'Light_Off' token as a + single-element options list (single-token merge); OFF writes 'Light_On'.""" + sw = next(e for e in airconditioner.CLIMATE.entities if e.key == 'display_light') + assert sw.write_fn('On', {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_Off']}) + assert sw.write_fn('Off', {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_On']}) + + +def test_light_switch_board_gates_out_mode_options_light(): + """Boards with a real /light/vs/0 switch carry no Light_* option, so the + mode-options display-light entity doesn't double up (mutually exclusive + encodings).""" + reg, resources = _resolve('airconditioner_tp1x_rac') + assert airconditioner._has_display_light_option(resources['/mode/vs/0'], resources) is False + + # --------------------------------------------------------------------------- # WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the # original issue #17 fixture, but its /mode/convenient/vs/0 additionally diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index 41c5bfe..e9905b9 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -8,13 +8,17 @@ from homeassistant.components.climate import HVACMode from custom_components.localthings.climate import ( _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, + _preset_to_ha, ) -def test_auto_still_maps_to_heat_cool(): - """'Auto' is unchanged -- AIComfort is handled separately, not folded - into this map.""" - assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL +def test_auto_maps_to_hvac_auto(): + """The device's 'Auto' is a single-setpoint "device decides" mode -> HA + HVACMode.AUTO, not HEAT_COOL (issue #91 review): HEAT_COOL implies a + two-setpoint heat+cool range these single-setpoint units (including + cool-only models) don't have. AIComfort is handled separately, not + folded into this map.""" + assert _DEVICE_TO_HVAC['Auto'] == HVACMode.AUTO def test_aicomfort_not_in_flat_hvac_map(): @@ -24,10 +28,13 @@ def test_aicomfort_not_in_flat_hvac_map(): assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC -def test_hvac_auto_not_writable_via_hvac_mode(): - """HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via - async_set_hvac_mode -- entered/left only through the ai_comfort preset.""" - assert HVACMode.AUTO not in _HVAC_TO_DEVICE +def test_hvac_auto_writes_back_to_plain_auto_not_aicomfort(): + """HVACMode.AUTO is reachable via async_set_hvac_mode -- it writes the + device's plain 'Auto' code. AIComfort stays reachable only through the + ai_comfort preset, since it isn't a flat _DEVICE_TO_HVAC entry (see + test_aicomfort_not_in_flat_hvac_map) and so can never win the reverse + {v: k} dict even though both map to HVACMode.AUTO conceptually.""" + assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'Auto' def test_fan_only_still_reachable_via_wind(): @@ -36,5 +43,34 @@ def test_fan_only_still_reachable_via_wind(): assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY +def test_fan_only_still_reachable_via_fan(): + """'Fan' (e.g. TP1X_DA-AC-RAC-01011) is a second FAN_ONLY spelling + alongside 'Wind' -- issue #91.""" + assert _DEVICE_TO_HVAC['Fan'] == HVACMode.FAN_ONLY + + +def test_fan_only_reverse_fallback_prefers_wind(): + """_device_code_for_hvac() resolves FAN_ONLY from a unit's own + supportedModes first, so _HVAC_TO_DEVICE is only a fallback for a unit + reporting no supportedModes at all. That fallback must stay 'Wind' (the + original single spelling, predating 'Fan') rather than silently + flipping to whichever of the two duplicate-value entries happens to + come last in _DEVICE_TO_HVAC.""" + assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == 'Wind' + + def test_preset_ai_comfort_constant(): assert PRESET_AI_COMFORT == 'ai_comfort' + + +def test_preset_to_ha_off_maps_to_preset_none(): + from homeassistant.components.climate import PRESET_NONE + assert _preset_to_ha('Off') == PRESET_NONE + + +def test_preset_to_ha_lowercases_other_codes(): + """Every other device code is exposed as its lowercased self -- resolved + dynamically, not via a per-model table (issue #91).""" + assert _preset_to_ha('Sleep') == 'sleep' + assert _preset_to_ha('NanoSleep') == 'nanosleep' + assert _preset_to_ha('MotionIndirect') == 'motionindirect' diff --git a/tests/test_climate_windfree.py b/tests/test_climate_windfree.py index d9e74f0..f5d065b 100644 --- a/tests/test_climate_windfree.py +++ b/tests/test_climate_windfree.py @@ -3,11 +3,12 @@ test_climate_temperature_fallback.py). The preset side of issue #75 (WindFree/motion convenient modes not -surfacing) is intentionally not addressed here: PR #91 replaces -climate.py's static _DEVICE_TO_PRESET table with a generic resolver that -reads any device preset code straight off the unit's own supportedModes, -which covers WindFree/motion generically instead of a per-model dict -- -duplicating that here would just conflict with it. +surfacing) is intentionally not addressed here: climate.py's +_preset_to_ha() (issue #91) reads any device preset code straight off the +unit's own supportedModes, replacing the old static _DEVICE_TO_PRESET +table with a generic resolver that covers WindFree/motion generically +instead of a per-model dict -- duplicating that here would just conflict +with it. """ from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index cc0a2fe..8f7bcad 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -59,6 +59,42 @@ class TestMergeOptionsField: assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16'] +class TestMergeItemsField: + """merge_items_field() is the items[]-array counterpart of + merge_options_field above (issue #91 review feedback): a vendor + x.com.samsung.da.items[] write only needs to carry the item id plus the + field(s) being changed, so the coordinator uses this to keep its + optimistic cache entry complete (current/minimum/maximum/unit still + present) without waiting on a real poll.""" + + def test_merges_fields_into_matching_id(self): + cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0', + 'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16'}] + merged = common.merge_items_field( + cached, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]) + assert merged == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0', + 'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16', + 'x.com.samsung.da.desired': '22'}] + + def test_appends_when_id_absent(self): + cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}] + merged = common.merge_items_field( + cached, [{'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'}]) + assert merged == [ + {'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}, + {'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'}, + ] + + def test_handles_missing_cache(self): + assert common.merge_items_field( + None, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}] + ) == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}] + + def test_ignores_malformed_new_items(self): + cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}] + assert common.merge_items_field(cached, ['not-a-dict']) == cached + + # --------------------------------------------------------------------------- # OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control). # --------------------------------------------------------------------------- diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index bdcfaab..30cf536 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -381,6 +381,21 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_rac_coolonly(): + """TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91) whose + /otninformation/vs/0 ships no swVersionInfo block -- resolves via the + hyphenated '-RAC-' modelNum fallback rather than for_device().""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac_coolonly') + golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac_coolonly.json').read_text()) + state_keys = _new_state_keys('airconditioner_tp1x_rac_coolonly', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): """ARTIK051_PRAC_20K, WindFree-capable unit (issue #75) -- same modelNum family as the original issue #17 fixture, but its /mode/convenient/vs/0 diff --git a/tests/test_translations.py b/tests/test_translations.py index 0d3c7c3..d70a6d9 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -168,4 +168,43 @@ def test_all_entity_state_translation_keys_are_lowercase(): for translation in platform.values(): for state_key in translation.get("state", {}): assert state_key == state_key.lower() - + + +def test_every_ac_convenient_mode_code_has_a_preset_label(): + """issue #91 review feedback #3: AC preset resolution is fully dynamic + (climate._preset_to_ha), so every fixture's /mode/convenient/vs/0 + supportedModes code surfaces as a preset -- an unlabelled one falls back + to its raw device code in the UI. Cheap guard against repeating that gap: + every non-'Off' code across every AC fixture must either resolve to one + of HA's own auto-localized standard presets or have an explicit label in + en.json. + """ + from homeassistant.components.climate.const import ( + PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, PRESET_SLEEP, + ) + standard = {PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, PRESET_SLEEP} + preset_labels = set( + _load("en")["entity"]["climate"]["airconditioner"]["state_attributes"] + ["preset_mode"]["state"] + ) + fixtures_dir = Path(__file__).parent / "fixtures" + missing = [] + for path in sorted(fixtures_dir.glob("airconditioner*_device.json")): + dump = json.loads(path.read_text()) + conv = next( + (item for item in dump.get("device0", []) + if item.get("href") == "/mode/convenient/vs/0"), None, + ) + if not conv: + continue + for code in conv["rep"].get("x.com.samsung.da.supportedModes", []): + if code == "Off": + continue + label = code.lower() + if label in standard or label in preset_labels: + continue + missing.append((path.name, code)) + assert missing == [] +