diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index a44fc2d..93b96bf 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -372,6 +372,33 @@ 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 _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" @@ -782,13 +809,34 @@ 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), 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 + # 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=lambda rep, resources: ( + _has_option_token_any_board("OutdoorTemp")(rep, resources) + and _reports_celsius(resources) + ), 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_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 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)