diff --git a/custom_components/sws12500/sensors_wslink.py b/custom_components/sws12500/sensors_wslink.py index ae4df0f..f5c8623 100644 --- a/custom_components/sws12500/sensors_wslink.py +++ b/custom_components/sws12500/sensors_wslink.py @@ -69,7 +69,15 @@ from .const import ( VOCLevel, ) from .sensors_common import WeatherSensorEntityDescription -from .utils import battery_level, to_float, to_int, voc_level_to_text, wind_dir_to_text +from .utils import ( + battery_level, + to_float, + to_int, + voc_level_to_text, + wind_dir_to_text, + wslink_chill_index, + wslink_heat_index, +) SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( WeatherSensorEntityDescription( @@ -457,6 +465,9 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( replacement_entity_key=CH8_BATTERY, entity_registry_enabled_default=False, ), + # `value_from_data_fn` takes precedence in `WeatherSensor.native_value`, so these + # use the station's own t1heat/t1chill when present and compute them otherwise - + # stations that do not report them used to leave both entities Unavailable forever. WeatherSensorEntityDescription( key=HEAT_INDEX, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -466,7 +477,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( suggested_display_precision=2, icon="mdi:weather-sunny", translation_key=HEAT_INDEX, - value_fn=to_float, + value_from_data_fn=wslink_heat_index, ), WeatherSensorEntityDescription( key=CHILL_INDEX, @@ -477,7 +488,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( suggested_display_precision=2, icon="mdi:weather-sunny", translation_key=CHILL_INDEX, - value_fn=to_float, + value_from_data_fn=wslink_chill_index, ), WeatherSensorEntityDescription( key=OUTSIDE_BATTERY, diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 017fe63..4396909 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -15,7 +15,7 @@ from __future__ import annotations import logging import math -from typing import Any +from typing import Any, Final from py_typecheck.core import checked_or @@ -26,8 +26,10 @@ from homeassistant.helpers.translation import async_get_translations from .const import ( AZIMUT, + CHILL_INDEX, CONNECTION_GATED_SENSORS, DEV_DBG, + HEAT_INDEX, OUTSIDE_HUMIDITY, OUTSIDE_TEMP, REMAP_ITEMS, @@ -392,3 +394,56 @@ def battery_5step_to_pct(value: Any) -> int | None: return None return round(min(max(step, 0), 5) / 5 * 100) + + +# The NWS wind-chill formula is defined for mph; WSLink reports wind in m/s. +_MS_TO_MPH: Final = 2.236936 + + +def wslink_heat_index(data: dict[str, Any]) -> float | None: + """Heat index for a WSLink payload, in Celsius. + + Prefers the station's own `t1heat` reading. Not every WSLink station sends one, + and without a fallback the entity - which `_auto_enable_derived_sensors` creates + as soon as temperature and humidity arrive - stays Unavailable forever. + + `heat_index` takes Celsius via `convert=True` but always returns Fahrenheit, so + the result is converted back to match this entity's native Celsius unit. + """ + if (reported := to_float(data.get(HEAT_INDEX))) is not None: + return reported + + # Guard here rather than letting heat_index log an error per push: a payload + # without these fields is normal, not a fault. + if to_float(data.get(OUTSIDE_TEMP)) is None or to_float(data.get(OUTSIDE_HUMIDITY)) is None: + return None + + value_f = heat_index(data, convert=True) + return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2) + + +def wslink_chill_index(data: dict[str, Any]) -> float | None: + """Wind chill for a WSLink payload, in Celsius. + + Prefers the station's own `t1chill` reading; see `wslink_heat_index` for why a + fallback is needed. + + `chill_index` converts the temperature but *not* the wind speed, and its formula + expects mph, so the m/s reading is converted here before the Fahrenheit result is + turned back into Celsius. + """ + if (reported := to_float(data.get(CHILL_INDEX))) is not None: + return reported + + temp_c = to_float(data.get(OUTSIDE_TEMP)) + wind_ms = to_float(data.get(WIND_SPEED)) + if temp_c is None or wind_ms is None: + return None + + value_f = chill_index( + { + OUTSIDE_TEMP: celsius_to_fahrenheit(temp_c), + WIND_SPEED: wind_ms * _MS_TO_MPH, + } + ) + return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2) diff --git a/tests/test_wslink_derived_indices.py b/tests/test_wslink_derived_indices.py new file mode 100644 index 0000000..f817606 --- /dev/null +++ b/tests/test_wslink_derived_indices.py @@ -0,0 +1,153 @@ +"""Heat / wind-chill indices must work on WSLink too. + +`_auto_enable_derived_sensors` creates both entities as soon as temperature and +humidity (resp. wind speed) arrive, but the WSLink descriptions only ever read the +station's own ``t1heat`` / ``t1chill``. A station that does not report those left both +entities Unavailable forever, while the WU/PWS platform computed them. + +The payload is metric, the NWS formulas are Fahrenheit-based (and mph for wind chill), +and the WSLink entities are declared in Celsius - so the conversions matter as much as +the fallback itself. +""" + +from __future__ import annotations + +import pytest + +from custom_components.sws12500.const import ( + CHILL_INDEX, + HEAT_INDEX, + OUTSIDE_HUMIDITY, + OUTSIDE_TEMP, + WIND_SPEED, +) +from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK +from custom_components.sws12500.utils import ( + celsius_to_fahrenheit, + chill_index, + fahrenheit_to_celsius, + heat_index, + wslink_chill_index, + wslink_heat_index, +) + +# The remapped form of a real payload from a station that sends neither t1heat nor +# t1chill (t1tem/t1hum/t1ws in metric units). +LIVE_PAYLOAD: dict[str, str] = { + OUTSIDE_TEMP: "6.2", + OUTSIDE_HUMIDITY: "40", + WIND_SPEED: "30.6", +} + + +def _description(key: str): + return next(desc for desc in SENSOR_TYPES_WSLINK if desc.key == key) + + +# --------------------------------------------------------------------------- +# Fallback +# --------------------------------------------------------------------------- + + +def test_indices_are_computed_when_the_station_omits_them() -> None: + """The reported bug: both entities stayed empty for this exact payload.""" + assert wslink_heat_index(LIVE_PAYLOAD) is not None + assert wslink_chill_index(LIVE_PAYLOAD) is not None + + +def test_computed_values_are_celsius_and_plausible() -> None: + """6.2 C at 40% RH with strong wind: heat index near ambient, chill below it.""" + heat = wslink_heat_index(LIVE_PAYLOAD) + chill = wslink_chill_index(LIVE_PAYLOAD) + assert heat is not None and chill is not None + + # Sanity-check the magnitude - a Fahrenheit result leaking through would be ~39/28. + assert -10 < heat < 15, heat + assert -10 < chill < 15, chill + # Wind chill must be colder than the still-air temperature at 30.6 m/s. + assert chill < 6.2 + + +def test_wind_is_converted_to_mph() -> None: + """`chill_index` converts temperature but not wind, and its formula needs mph. + + Feeding m/s straight in understates the wind and yields a warmer chill. + """ + correct = wslink_chill_index(LIVE_PAYLOAD) + + naive_f = chill_index( + {OUTSIDE_TEMP: celsius_to_fahrenheit(6.2), WIND_SPEED: 30.6}, # m/s, unconverted + ) + assert naive_f is not None + naive = round(fahrenheit_to_celsius(naive_f), 2) + + assert correct is not None + assert correct < naive, f"unconverted wind gives {naive}, converted gives {correct}" + + +# --------------------------------------------------------------------------- +# The station's own reading still wins +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("fn", "key"), + [(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)], + ids=["heat", "chill"], +) +def test_station_reported_value_takes_precedence(fn, key) -> None: + """t1heat / t1chill are already Celsius and must be passed through untouched.""" + assert fn({**LIVE_PAYLOAD, key: "3.5"}) == 3.5 + + +@pytest.mark.parametrize( + ("fn", "key"), + [(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)], + ids=["heat", "chill"], +) +def test_empty_reported_value_falls_back_to_computing(fn, key) -> None: + """The station sends "" for absent fields; that must not shadow the fallback.""" + assert fn({**LIVE_PAYLOAD, key: ""}) is not None + + +# --------------------------------------------------------------------------- +# Missing inputs stay quiet +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, OUTSIDE_HUMIDITY]) +def test_heat_index_needs_temp_and_humidity(missing) -> None: + payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing} + assert wslink_heat_index(payload) is None + + +@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, WIND_SPEED]) +def test_chill_index_needs_temp_and_wind(missing) -> None: + payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing} + assert wslink_chill_index(payload) is None + + +def test_no_inputs_at_all_is_none() -> None: + assert wslink_heat_index({}) is None + assert wslink_chill_index({}) is None + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("key", "expected"), [(HEAT_INDEX, wslink_heat_index), (CHILL_INDEX, wslink_chill_index)]) +def test_descriptions_use_the_wslink_helpers(key, expected) -> None: + """`value_from_data_fn` wins in WeatherSensor.native_value, so it must be set.""" + desc = _description(key) + assert desc.value_from_data_fn is expected + + +def test_wu_platform_still_computes_in_fahrenheit() -> None: + """The WU descriptions are Fahrenheit-native; they must keep the raw helpers.""" + from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API + + by_key = {d.key: d for d in SENSOR_TYPES_WEATHER_API} + assert by_key[HEAT_INDEX].value_from_data_fn is heat_index + assert by_key[CHILL_INDEX].value_from_data_fn is chill_index