From f4b4a04628a5aad0d263086604fa3792909399ca Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 21 Jul 2026 23:59:03 +0000 Subject: [PATCH] refactor: tidy the AC climate entity (/simplify pass) Quality-only cleanups on the air-conditioner support, no behavior change: - climate.py: reuse common.normalize_temp_unit for the C/F unit read (also handles the "Celsius"/"Fahrenheit" long forms); collapse the three fan/swing/preset read properties into _read_mode/_read_modes and the three write setters into _set_mapped, removing the copy-paste. - Single source of truth for the climate-consumed hrefs: they lived both as constants in climate.py and as a list in airconditioner.py. Declare them once in airconditioner.py (HREF_* + CLIMATE_CONSUMED_HREFS, which also builds the COVERAGE caps) and import them into climate.py, so a new sibling read can't drift out of sync with its coverage entry. --- custom_components/localthings/climate.py | 76 +++++++++++-------- .../registry/capabilities/airconditioner.py | 50 +++++++----- 2 files changed, 74 insertions(+), 52 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 6e6b3b8..08dbf3a 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -30,24 +30,27 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import ClimateDesc +# The AC's canonical resource hrefs live in the capability module (the single +# source of truth shared with its COVERAGE caps); power prefers the OCF-standard +# href, falling back to the vendor one, mirroring common.POWER_GENERIC / +# POWER_VS_FALLBACK. +from .registry.capabilities.airconditioner import ( + HREF_MODE as MODE_HREF, + HREF_POWER as POWER_HREF, + HREF_POWER_VS as POWER_VS_HREF, + HREF_TEMP_CURRENT as TEMP_CURRENT_HREF, + HREF_TEMP_DESIRED as TEMP_DESIRED_HREF, + HREF_TEMP_CONTROL as TEMP_CONTROL_HREF, + HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, + HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, + HREF_CONVENIENT as CONVENIENT_HREF, +) +from .registry.capabilities.common import normalize_temp_unit from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included -# Sibling OCF resources the climate entity reads (the primary bound href is -# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor -# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK. -POWER_HREF = '/power/0' -POWER_VS_HREF = '/power/vs/0' -MODE_HREF = '/mode/vs/0' -TEMP_CURRENT_HREF = '/temperature/current/0' -TEMP_DESIRED_HREF = '/temperature/desired/0' -TEMP_CONTROL_HREF = '/temperature/control/vs/0' -WIND_STRENGTH_HREF = '/wind/strength/vs/0' -WIND_DIRECTION_HREF = '/wind/direction/vs/0' -CONVENIENT_HREF = '/mode/convenient/vs/0' - _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes' @@ -159,12 +162,22 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _supported(self, href: str) -> list[str]: return list(self._rep(href).get(_SUPPORTED_FIELD) or []) + def _read_mode(self, href: str, mapping: dict): + """Current mode of a wind/convenient resource, mapped to its HA value.""" + return mapping.get(_first(self._rep(href).get(_MODES_FIELD))) + + def _read_modes(self, href: str, mapping: dict) -> list[str]: + """Supported modes of a resource, mapped to HA values (unknowns dropped).""" + return [mapping[c] for c in self._supported(href) if c in mapping] + # -- temperature -------------------------------------------------------- @property def temperature_unit(self) -> str: - units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper() - return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS + raw = self._rep(TEMP_DESIRED_HREF).get('units') + return (UnitOfTemperature.FAHRENHEIT + if normalize_temp_unit(raw, '°C') == '°F' + else UnitOfTemperature.CELSIUS) @property def current_temperature(self): @@ -214,30 +227,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def fan_mode(self): - return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def fan_modes(self) -> list[str]: - return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF) - if c in _DEVICE_TO_FAN] + return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def swing_mode(self): - return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD))) + return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def swing_modes(self) -> list[str]: - return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF) - if c in _DEVICE_TO_SWING] + return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) @property def preset_mode(self): - return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))) + return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET) @property def preset_modes(self) -> list[str]: - return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF) - if c in _DEVICE_TO_PRESET] + return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET) # -- writes ------------------------------------------------------------- @@ -263,17 +273,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): async def async_turn_off(self) -> None: await self.coordinator.async_send_command(self._bound, ('power', False)) - async def async_set_fan_mode(self, fan_mode: str) -> None: - device = _FAN_TO_DEVICE.get(fan_mode) + async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None: + """Map an HA fan/swing/preset value back to its device code and write it.""" + device = mapping.get(value) if device is not None: - await self.coordinator.async_send_command(self._bound, ('fan', device)) + await self.coordinator.async_send_command(self._bound, (kind, device)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: - device = _SWING_TO_DEVICE.get(swing_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('swing', device)) + await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: - device = _PRESET_TO_DEVICE.get(preset_mode) - if device is not None: - await self.coordinator.async_send_command(self._bound, ('preset', device)) + await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index b085d70..7454b09 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -17,6 +17,29 @@ by_type registry. from ..capability import Capability from ..entities import ClimateDesc, SensorDesc, SwitchDesc +# --------------------------------------------------------------------------- +# Canonical AC resource hrefs. The climate entity (climate.py) binds the +# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS +# siblings off the coordinator snapshot; those siblings are marked covered +# (no-entity caps) so discover() reports no gap. Declared once here and +# imported by climate.py, so a new sibling read can't drift out of sync with +# its coverage entry. +# --------------------------------------------------------------------------- +HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE) +HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF +HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off +HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature +HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target) +HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step +HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode +HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode +HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode + +CLIMATE_CONSUMED_HREFS = [ + HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, + HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT, +] + def _num(v): try: @@ -71,7 +94,7 @@ def _climate_write(payload, rep, href=None): CLIMATE = Capability( - href='/mode/vs/0', + href=HREF_MODE, poll_tier='warm', entities=( ClimateDesc(key='climate', translation_key='airconditioner', @@ -124,24 +147,13 @@ AIR_FILTER = Capability( ) # --------------------------------------------------------------------------- -# AC-scoped coverage: hrefs consumed by the composite climate entity, and -# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in -# the global ignored.IGNORED because several of them (/mode/vs/0 handled above, -# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A -# no-entity Capability still marks the href as bound so discover() reports no -# coverage gap. +# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate +# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. +# These are NOT in the global ignored.IGNORED because several of them +# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with +# other families' schemas. A no-entity Capability still marks the href as +# bound so discover() reports no coverage gap. # --------------------------------------------------------------------------- -_CONSUMED_BY_CLIMATE = [ - '/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF - '/power/vs/0', # vendor fallback for on/off - '/temperature/current/0', # climate current_temperature - '/temperature/desired/0', # climate target_temperature (write target) - '/temperature/control/vs/0', # climate target_temperature_step - '/wind/strength/vs/0', # climate fan_mode - '/wind/direction/vs/0', # climate swing_mode - '/mode/convenient/vs/0', # climate preset_mode -] - _AC_IGNORED = [ # Vendor superset that duplicates the OCF /temperature/current+desired pair. '/temperatures/vs/0', @@ -155,4 +167,4 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). -COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)] +COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)]