From 8814fffa4f58e36ed94118ca1e6de2030aecd6a5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 21:22:09 +0000 Subject: [PATCH 1/2] 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. 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 14 affected boards and the artik051_krac test that had asserted outdoor_temperature stays off newer boards. --- .../registry/capabilities/airconditioner.py | 31 ++++++++++++++++++- tests/fixtures/golden/airconditioner.json | 1 + .../golden/airconditioner_ailp_fac.json | 1 + .../golden/airconditioner_ara_ww_tp1_22.json | 1 + tests/fixtures/golden/airconditioner_cac.json | 1 + .../golden/airconditioner_caww_tp2.json | 1 + .../airconditioner_lnx_rac_heatpump.json | 1 + .../airconditioner_tp1x_da_ac_rac_01011.json | 1 + .../golden/airconditioner_tp1x_rac.json | 1 + .../golden/airconditioner_tp1x_rac_01001.json | 1 + .../airconditioner_tp1x_rac_coolonly.json | 1 + ...rconditioner_tp1x_rac_odor_controller.json | 1 + .../golden/airconditioner_tp2x_rac_20k.json | 1 + .../golden/airconditioner_windfree.json | 1 + .../golden/airconditioner_window_ac.json | 1 + tests/test_airconditioner_artik051_krac.py | 23 +++++++++++--- 16 files changed, 62 insertions(+), 6 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index a44fc2d..b502a63 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -372,6 +372,18 @@ def _has_option_token(prefix): ) +def _has_option_token_any_board(prefix): + """Token-presence test with no board-generation gate. + + `_has_option_token` above requires `is_legacy_board`, which was right for + the settings it guards but wrong for a token whose presence is itself the + only signal that needs checking -- issue #367 found `OutdoorTemp_` live + on 14 of 23 recorded fixtures despite none of them being legacy boards. + Same shape as `beep`'s Volume_ test and `_has_display_light_option`, + which already treat token presence as sufficient across generations.""" + return lambda rep, resources: _option_token(rep, prefix) is not None + + def _option_token_on(prefix): return lambda rep: _option_token(rep, prefix) == "On" @@ -782,13 +794,30 @@ 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. + # + # enabled_default=False: multi-split installs (multiple indoor heads + # on one outdoor condenser) report the same token on every head, so + # a fix here creates one identical sensor per head rather than one + # per physical unit (same duplication as the shared energy/power + # counters in issue #329). Left disabled so a user with several + # heads can enable just one instead of getting N duplicates active + # by default. SensorDesc( key="outdoor_temperature", rep_fn=_option_token_num("OutdoorTemp", offset=55), - exists_fn=_has_option_token("OutdoorTemp"), + exists_fn=_has_option_token_any_board("OutdoorTemp"), device_class="temperature", state_class="measurement", unit="°C", + enabled_default=False, icon="mdi:home-thermometer-outline", ), # Filter time in tenths of an hour, counting UP since last filter diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index fcafcf5..086a9b2 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -20,6 +20,7 @@ "fine_dust", "humidity", "odor", + "outdoor_temperature", "power_watts", "super_fine_dust", "tropical_night_mode" diff --git a/tests/fixtures/golden/airconditioner_ailp_fac.json b/tests/fixtures/golden/airconditioner_ailp_fac.json index 67d3199..893ea3c 100644 --- a/tests/fixtures/golden/airconditioner_ailp_fac.json +++ b/tests/fixtures/golden/airconditioner_ailp_fac.json @@ -25,6 +25,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "power_energy_kwh", "power_watts", "selfcheck_error", diff --git a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json index 10810e8..a6979b6 100644 --- a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json +++ b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json @@ -18,6 +18,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index a8819ff..c479a41 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -36,6 +36,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "periodic_air_sensing", "periodic_sensing_skip_status", "power_watts", diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index a8d9f69..971da54 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -17,6 +17,7 @@ "firmware_update", "humidity", "mute_once", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json index 9cbc8d2..1264892 100644 --- a/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json +++ b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json @@ -22,6 +22,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json index b0d26cd..1fbc07b 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -23,6 +23,7 @@ "last_air_sensing_level", "last_air_sensing_time", "mute_once", + "outdoor_temperature", "periodic_air_sensing", "periodic_sensing_skip_status", "selfcheck_error", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index 445a6f9..84b5dd9 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -19,6 +19,7 @@ "energy_saved_kwh", "firmware_update", "mute_once", + "outdoor_temperature", "selfcheck_error", "selfcheck_result", "selfcheck_status", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json index 10810e8..a6979b6 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json @@ -18,6 +18,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json index 9fd5d96..de360bf 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -16,6 +16,7 @@ "firmware_update", "humidity", "mute_once", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_odor_controller.json b/tests/fixtures/golden/airconditioner_tp1x_rac_odor_controller.json index 99e808b..a4f5938 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_odor_controller.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_odor_controller.json @@ -20,6 +20,7 @@ "mute_once", "odor_controller_active", "odor_controller_progress", + "outdoor_temperature", "selfcheck_error", "selfcheck_result", "selfcheck_status", diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index c419541..b1adb33 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -15,6 +15,7 @@ "firmware_update", "humidity", "mute_once", + "outdoor_temperature", "power_watts", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index 95f4c8d..53f1a45 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -19,6 +19,7 @@ "fine_dust", "humidity", "odor", + "outdoor_temperature", "power_watts", "super_fine_dust", "tropical_night_mode" diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index 58ac32d..40d121f 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -16,6 +16,7 @@ "firmware_update", "humidity", "mute_once", + "outdoor_temperature", "power_watts", "selfcheck_error", "selfcheck_result", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 7ab0154..4f887d2 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -129,22 +129,35 @@ def test_token_entities_present_with_calibrated_values(): def test_token_entities_stay_off_newer_boards(): - """Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too, - while also exposing those settings as dedicated resources -- ungated, the - token entities would duplicate them (auto clean) or apply a scale - calibrated on another board generation (outdoor temperature).""" + """Newer families carry Volume/Sleep/Autoclean tokens too, while also + exposing those settings as dedicated resources -- ungated, the token + entities would duplicate them.""" state = _state("airconditioner_tp1x_rac") for key in ( "spi", "auto_clean_legacy", "air_monitoring", "good_sleep", - "outdoor_temperature", "filter_time", ): assert key not in state, key +def test_outdoor_temperature_is_not_gated_to_legacy_boards(): + """issue #367: OutdoorTemp_ is not paired with any dedicated resource on + newer boards -- /temperatures/vs/0 carries indoor temperature only, no + outdoor equivalent exists anywhere in this fixture's 23 hrefs -- so unlike + auto_clean_legacy et al. above, gating it to is_legacy_board only dropped + a real reading. A 48h field capture correlated the token against + weather.forecast_home at r=0.92 across several non-legacy boards, + confirming it is live per-site data rather than a firmware constant.""" + resources = _load_device("airconditioner_tp1x_rac") + assert is_legacy_board(resources) is False + bound, _ = _discover(resources) + state = flatten(bound, resources) + assert state["outdoor_temperature"] == 37.0 # OutdoorTemp_92, the fixture's own value + + def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board(): """issue #161: climate.py's _legacy_airflow() delegates to capabilities/airconditioner.py's is_legacy_board() instead of From fe0db8586f493d6bb5f8f05548c936e7eabc5f0d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 21:38:56 +0000 Subject: [PATCH 2/2] 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)