fix(wslink): compute heat and wind chill index when the station omits them

The WU/PWS platform derives both indices from temperature, humidity and wind
(value_from_data_fn=heat_index / chill_index), but the WSLink descriptions only
read the station's own t1heat / t1chill via value_fn=to_float. Stations that do
not report those fields still got both entities, because
_auto_enable_derived_sensors creates them as soon as the raw inputs arrive, so
they stayed Unavailable forever.

Add wslink_heat_index / wslink_chill_index: prefer the station's reading, compute
it otherwise. The conversions are the subtle part. The WSLink payload is metric
and both entities are declared in Celsius, while the NWS formulas are defined for
Fahrenheit, and chill_index converts the temperature but not the wind speed even
though its formula needs mph. So the temperature goes C -> F in, the wind m/s ->
mph, and the result F -> C back out. For the reported payload (6.2 C, 40 %,
30.6 m/s) that is -1.94 C; leaving the wind in m/s would have reported 0.34 C.

The native unit stays Celsius on purpose: switching it to Fahrenheit to match the
WU platform would misread the values of stations that do send t1heat / t1chill.

Also guard on missing temperature/humidity/wind before delegating, so a payload
without those fields no longer logs an error on every push.
ecowitt_support
SchiZzA 2026-07-26 01:20:56 +02:00
parent 7c9343f90d
commit 0e5a8ce10c
No known key found for this signature in database
3 changed files with 223 additions and 4 deletions

View File

@ -69,7 +69,15 @@ from .const import (
VOCLevel, VOCLevel,
) )
from .sensors_common import WeatherSensorEntityDescription 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, ...] = ( SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
@ -457,6 +465,9 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
replacement_entity_key=CH8_BATTERY, replacement_entity_key=CH8_BATTERY,
entity_registry_enabled_default=False, 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( WeatherSensorEntityDescription(
key=HEAT_INDEX, key=HEAT_INDEX,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
@ -466,7 +477,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2, suggested_display_precision=2,
icon="mdi:weather-sunny", icon="mdi:weather-sunny",
translation_key=HEAT_INDEX, translation_key=HEAT_INDEX,
value_fn=to_float, value_from_data_fn=wslink_heat_index,
), ),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=CHILL_INDEX, key=CHILL_INDEX,
@ -477,7 +488,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2, suggested_display_precision=2,
icon="mdi:weather-sunny", icon="mdi:weather-sunny",
translation_key=CHILL_INDEX, translation_key=CHILL_INDEX,
value_fn=to_float, value_from_data_fn=wslink_chill_index,
), ),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=OUTSIDE_BATTERY, key=OUTSIDE_BATTERY,

View File

@ -15,7 +15,7 @@ from __future__ import annotations
import logging import logging
import math import math
from typing import Any from typing import Any, Final
from py_typecheck.core import checked_or from py_typecheck.core import checked_or
@ -26,8 +26,10 @@ from homeassistant.helpers.translation import async_get_translations
from .const import ( from .const import (
AZIMUT, AZIMUT,
CHILL_INDEX,
CONNECTION_GATED_SENSORS, CONNECTION_GATED_SENSORS,
DEV_DBG, DEV_DBG,
HEAT_INDEX,
OUTSIDE_HUMIDITY, OUTSIDE_HUMIDITY,
OUTSIDE_TEMP, OUTSIDE_TEMP,
REMAP_ITEMS, REMAP_ITEMS,
@ -392,3 +394,56 @@ def battery_5step_to_pct(value: Any) -> int | None:
return None return None
return round(min(max(step, 0), 5) / 5 * 100) 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)

View File

@ -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