From fe0db8586f493d6bb5f8f05548c936e7eabc5f0d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 21:38:56 +0000 Subject: [PATCH] airconditioner: ungate outdoor_temperature from is_legacy_board The OutdoorTemp_ options token was only surfaced on legacy boards (is_legacy_board), even though 14 of 17 fixtures carrying the token are non-legacy. issue #367 confirmed with a 48h field capture (r=0.92 against weather.forecast_home) that the token tracks real outdoor temperature independent of board generation, and that no non-legacy board exposes an alternative outdoor-temperature resource. Split a token-presence-only exists_fn (_has_option_token_any_board) for this token, leaving _has_option_token's legacy gate untouched for the other options[] settings that still need it. The -55 offset itself was only field-validated on Celsius-locale boards, so a second gate (_reports_celsius, reading the board's own /temperatures/vs/0) keeps the sensor off the one Fahrenheit-locale fixture on record rather than guess whether the same offset and unit still apply there. Ships enabled_default=False since multi-split installs report the same token on every indoor head, which would otherwise create one duplicate active sensor per head. Updates the golden fixtures for the 13 affected Celsius-locale boards and the artik051_krac test that had asserted outdoor_temperature stays off newer boards; adds coverage for the Fahrenheit-locale gate. --- .../registry/capabilities/airconditioner.py | 35 ++++++++++++++----- .../airconditioner_lnx_rac_heatpump.json | 1 - tests/test_airconditioner_capabilities.py | 16 +++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index b502a63..93b96bf 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -384,6 +384,21 @@ def _has_option_token_any_board(prefix): return lambda rep, resources: _option_token(rep, prefix) is not None +def _reports_celsius(resources): + """Whether this subdevice's own /temperatures/vs/0 declares Celsius (or + says nothing at all, `_temps_vs_unit`'s default). + + Guards `OutdoorTemp_`'s -55 offset below: issue #367's field validation + (48h against weather.forecast_home, r=0.92) ran on Celsius-locale boards + only -- 13 of the 14 non-legacy fixtures that carry the token declare + Celsius, and the offset's own calibration comment is a Celsius reading + too. The one Fahrenheit-locale exception on record + (`airconditioner_lnx_rac_heatpump`) has nothing to confirm the same + additive constant, or degrees C rather than F, still hold -- so this + stays off boards that declare anything else, rather than guess.""" + return _temps_vs_unit(resources.get(HREF_TEMPS_VS) or {}) == "°C" + + def _option_token_on(prefix): return lambda rep: _option_token(rep, prefix) == "On" @@ -795,13 +810,14 @@ CLIMATE = Capability( # Outdoor temperature, offset by 55 -- calibrated against an # independent thermometer (token 75 while it read 20.3°C). # - # exists_fn is token-presence-only (issue #367): the token was - # previously gated behind is_legacy_board for no reason tied to the - # token itself, which silently dropped the sensor on every - # non-legacy board that reports it -- 14 of 17 fixtures carrying the - # token in the issue's own survey. A 48h/289-sample field capture - # correlated it against weather.forecast_home at r=0.92, ruling out - # a firmware constant. + # exists_fn is token-presence-only (issue #367), not is_legacy_board: + # gating it there dropped the sensor on every non-legacy board that + # reports it -- 14 of 17 fixtures carrying the token in the issue's + # own survey. A 48h/289-sample field capture correlated it against + # weather.forecast_home at r=0.92, ruling out a firmware constant. + # `_reports_celsius` keeps that presence check from also claiming + # the -55 Celsius offset for board generations it was never + # validated on -- see its own docstring. # # enabled_default=False: multi-split installs (multiple indoor heads # on one outdoor condenser) report the same token on every head, so @@ -813,7 +829,10 @@ CLIMATE = Capability( SensorDesc( key="outdoor_temperature", rep_fn=_option_token_num("OutdoorTemp", offset=55), - exists_fn=_has_option_token_any_board("OutdoorTemp"), + exists_fn=lambda rep, resources: ( + _has_option_token_any_board("OutdoorTemp")(rep, resources) + and _reports_celsius(resources) + ), device_class="temperature", state_class="measurement", unit="°C", diff --git a/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json index 1264892..9cbc8d2 100644 --- a/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json +++ b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json @@ -22,7 +22,6 @@ "mute_once", "odor_controller_active", "odor_controller_progress", - "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 023a24a..49e7526 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -654,6 +654,22 @@ def test_lnx_rac_heatpump_no_unbound_hrefs(): assert unbound == [] +def test_lnx_rac_heatpump_outdoor_temperature_stays_off_fahrenheit_boards(): + """issue #367's -55 offset was field-validated on Celsius-locale boards + only; this fixture's own /temperatures/vs/0 declares Fahrenheit despite + carrying an OutdoorTemp_ token, so the sensor stays off rather than + apply an unvalidated offset/unit to it (see _reports_celsius).""" + reg, resources = _ac_lnx_rac_heatpump() + temps_item = resources["/temperatures/vs/0"]["x.com.samsung.da.items"][0] + assert temps_item["x.com.samsung.da.unit"] == "Fahrenheit" + options = resources["/mode/vs/0"]["x.com.samsung.da.options"] + assert any(o.startswith("OutdoorTemp_") for o in options) + + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert "outdoor_temperature" not in state + + def test_lnx_rac_heatpump_absence_power_saving_state(): reg, resources = _ac_lnx_rac_heatpump() bound = discover(resources, reg.capabilities, reg.pattern_capabilities)