From 2c6b698ca944062f44c27e98eef15b8ac281ed10 Mon Sep 17 00:00:00 2001 From: SchiZzA Date: Sun, 26 Jul 2026 13:13:05 +0200 Subject: [PATCH] fix(review): clamp heat index, unify Windy uv, bound forward timeouts --- custom_components/sws12500/const.py | 26 ++++-- custom_components/sws12500/pocasti_cz.py | 13 ++- custom_components/sws12500/utils.py | 26 +++--- custom_components/sws12500/windy_func.py | 29 +++++-- tests/test_pocasi_ecowitt.py | 104 ++++++++++++++++------- tests/test_pocasi_push.py | 31 ++++++- tests/test_windy_more.py | 2 +- tests/test_windy_push.py | 89 +++++++++++++++++-- tests/test_wslink_derived_indices.py | 32 +++++++ 9 files changed, 291 insertions(+), 61 deletions(-) diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 29b57dd..0ad3ca3 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -195,26 +195,40 @@ LEGACY_ENABLED: Final = "legacy_enabled" WINDY_MAX_RETRIES: Final = 3 WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT" +# Forwarding to Windy / Pocasi Meteo is awaited inline in the station's own request +# path, so an upstream that accepts the connection and never answers would hold the +# webhook open. aiohttp's default is a 5 minute total, long past the point where the +# station itself gives up; cut the send short instead and retry on the next push. +FORWARD_TIMEOUT: Final = 15 # seconds + ECOWITT: Final = "ecowitt" ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id" ECOWITT_ENABLED: Final = "ecowitt_enabled" ECOWITT_URL_PREFIX: Final = "/weatherhub" -# Ecowitt field names -> WU/PWS field names. +# Ecowitt field names -> the field names Windy accepts. # # Windy has no Ecowitt endpoint, so an Ecowitt payload has to be translated before it -# can be forwarded there. After translation the payload carries exactly the field names -# Windy already receives from a real PWS station, which is the path known to work. +# can be forwarded there. The target vocabulary is mostly the PWS/WU one Windy already +# receives from a real PWS station, but not exactly: Windy documents the UV index as +# lowercase `uv` (https://stations.windy.com/api-reference) while WU spells it `UV` +# (see REMAP_ITEMS). This table follows Windy, which is its only consumer. # # Deliberately an allowlist, not a "strip the bad keys" denylist: the Ecowitt payload # also carries device metadata (stationtype, model, freq, runtime, heap, interval) that # is meaningless upstream, and a denylist would forward whatever fields a future # firmware adds. # -# Multi-channel temp/humidity (temp1f..temp7f) are intentionally absent - the WU +# This is the *full* mapping, not the set of fields that end up on the wire: five of +# the entries below (`dateutc`, `solarradiation`, `dailyrainin`, `indoortempf`, +# `indoorhumidity`) are in PURGE_DATA and are stripped by `push_data_to_windy` right +# after the conversion. They are listed so the translation itself stays complete and +# keeps working if the purge list ever changes. +# +# Multi-channel temp/humidity (temp1f..temp7f) are intentionally absent - the PWS # protocol has no equivalent, and mapping them onto its soil fields would send wrong # data. -REMAP_ECOWITT_TO_WU: Final[dict[str, str]] = { +REMAP_ECOWITT_TO_WINDY: Final[dict[str, str]] = { # same name on both sides, listed so the allowlist is complete "tempf": "tempf", "humidity": "humidity", @@ -224,12 +238,12 @@ REMAP_ECOWITT_TO_WU: Final[dict[str, str]] = { "dailyrainin": "dailyrainin", "solarradiation": "solarradiation", "dateutc": "dateutc", + "uv": "uv", # renamed between the two protocols "dewpointf": "dewptf", "baromrelin": "baromin", "tempinf": "indoortempf", "humidityin": "indoorhumidity", - "uv": "UV", "hourlyrainin": "rainin", # WU `rainin` is the accumulation over the past hour } diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index 98d8db3..c975d88 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -7,7 +7,7 @@ from functools import partial import logging from typing import Any, Literal -from aiohttp import ClientError +from aiohttp import ClientError, ClientTimeout from py_typecheck.core import checked_or from homeassistant.config_entries import ConfigEntry @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from .const import ( DEFAULT_URL, + FORWARD_TIMEOUT, POCASI_CZ_API_ID, POCASI_CZ_API_KEY, POCASI_CZ_ECOWITT_ID_PARAM, @@ -146,6 +147,12 @@ class PocasiPush: request_url: str = "" params: dict[str, Any] = {} + # Home Assistant's shared session sets no timeout, so without an explicit one a + # stalled server would hold the station's webhook open for aiohttp's 5 minute + # default and the TimeoutError branch below could never run in time to answer + # the station. + timeout = ClientTimeout(total=FORWARD_TIMEOUT) + if mode == "ECOWITT": # Pocasi Meteo takes Ecowitt only as a POST in the Ecowitt protocol itself, # so the station payload is forwarded verbatim; only the credentials are @@ -155,7 +162,7 @@ class PocasiPush: POCASI_CZ_ECOWITT_ID_PARAM: _api_id, POCASI_CZ_ECOWITT_PW_PARAM: _api_key, } - make_request = partial(session.post, request_url, params=params, data=_data) + make_request = partial(session.post, request_url, params=params, data=_data, timeout=timeout) else: if mode == "WSLINK": _data["wsid"] = _api_id @@ -166,7 +173,7 @@ class PocasiPush: _data["PASSWORD"] = _api_key request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}" - make_request = partial(session.get, request_url, params=_data) + make_request = partial(session.get, request_url, params=_data, timeout=timeout) _LOGGER.debug( "Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] [params=%s] = %s", diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 32af2f7..64ad87c 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -32,7 +32,7 @@ from .const import ( HEAT_INDEX, OUTSIDE_HUMIDITY, OUTSIDE_TEMP, - REMAP_ECOWITT_TO_WU, + REMAP_ECOWITT_TO_WINDY, REMAP_ITEMS, REMAP_WSLINK_ITEMS, SENSORS_TO_LOAD, @@ -292,6 +292,11 @@ def heat_index(data: dict[str, int | float | str], convert: bool = False) -> flo data: dict with temperature and humidity convert: bool, convert received data from Celsius to Fahrenheit + + The result is floored at the ambient temperature. The NWS step-1 average is only + meaningful once it reaches 80 F; below that it drifts under the air temperature + (6.2 C at 40% RH yields 3.9 C), and an entity called "Heat index" reading colder + than the air is wrong rather than merely imprecise. Always returns Fahrenheit. """ if (temp := to_float(data.get(OUTSIDE_TEMP))) is None: _LOGGER.error( @@ -331,9 +336,9 @@ def heat_index(data: dict[str, int | float | str], convert: bool = False) -> flo if rh > 80 and (80 <= temp <= 87): adjustment = ((rh - 85) / 10) * ((87 - temp) / 5) - return round((full_index + adjustment if adjustment else full_index), 2) + return max(round((full_index + adjustment if adjustment else full_index), 2), temp) - return simple + return max(simple, temp) def chill_index(data: dict[str, str | float | int], convert: bool = False) -> float | None: @@ -451,14 +456,15 @@ def wslink_chill_index(data: dict[str, Any]) -> float | None: return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2) -def remap_ecowitt_to_wu(data: dict[str, Any]) -> dict[str, Any]: - """Translate an Ecowitt payload into WU/PWS field names. +def remap_ecowitt_to_windy(data: dict[str, Any]) -> dict[str, Any]: + """Translate an Ecowitt payload into the field names Windy accepts. Needed for services that do not speak the Ecowitt protocol - Windy, unlike Pocasi - Meteo, has no Ecowitt endpoint, so fields such as `baromrelin`, `dewpointf`, - `tempinf` or `uv` would simply not be understood. + Meteo, has no Ecowitt endpoint, so fields such as `baromrelin`, `dewpointf` or + `tempinf` would simply not be understood. - Only fields listed in `REMAP_ECOWITT_TO_WU` are passed on; see the table there for - why that is an allowlist rather than a denylist. + Only fields listed in `REMAP_ECOWITT_TO_WINDY` are passed on; see the table there + for why that is an allowlist rather than a denylist, and where it departs from the + WU spelling. """ - return {wu_key: data[eco_key] for eco_key, wu_key in REMAP_ECOWITT_TO_WU.items() if eco_key in data} + return {out_key: data[eco_key] for eco_key, out_key in REMAP_ECOWITT_TO_WINDY.items() if eco_key in data} diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index 565909a..46978bc 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta import logging from typing import Literal -from aiohttp.client import ClientResponse +from aiohttp.client import ClientResponse, ClientTimeout from aiohttp.client_exceptions import ClientError from py_typecheck import checked_or @@ -17,6 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from .const import ( + FORWARD_TIMEOUT, PURGE_DATA, WINDY_ENABLED, WINDY_INVALID_KEY, @@ -29,7 +30,7 @@ from .const import ( WINDY_UNEXPECTED, WINDY_URL, ) -from .utils import remap_ecowitt_to_wu, update_options +from .utils import remap_ecowitt_to_windy, update_options _LOGGER = logging.getLogger(__name__) @@ -138,7 +139,7 @@ class WindyPush: raise WindyRateLimitExceeded def _to_pws(self, data: dict[str, str], source: WindySource) -> dict[str, str]: - """Convert a station payload into the PWS field names Windy understands. + """Convert a station payload into the field names Windy understands. Windy speaks the PWS/WU vocabulary, so every other protocol has to be mapped onto it first. All three sources are dispatched here so the conversions stay @@ -149,7 +150,7 @@ class WindyPush: if source == "ecowitt": # Windy has no Ecowitt endpoint; the shared table also drops the station # metadata (PASSKEY, stationtype, model, ...) that means nothing upstream. - return remap_ecowitt_to_wu(data) + return remap_ecowitt_to_windy(data) return data def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]: @@ -174,6 +175,15 @@ class WindyPush: indata["uv"] = indata.pop("t1uvi") if "t1solrad" in indata: indata["solarradiation"] = indata.pop("t1solrad") + # Indoor readings mean nothing to Windy. Renaming them to their PWS spelling + # (rather than dropping them here) leaves PURGE_DATA - which lists PWS names - + # as the single place that decides what Windy never receives, exactly as on the + # Ecowitt path. The values stay Celsius/percent, which is fine because they are + # purged before the request is built. + if "intem" in indata: + indata["indoortempf"] = indata.pop("intem") + if "inhum" in indata: + indata["indoorhumidity"] = indata.pop("inhum") return indata @@ -259,7 +269,16 @@ class WindyPush: _LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"}) session = async_get_clientsession(self.hass) try: - async with session.get(request_url, params=purged_data, headers=headers) as resp: + # Home Assistant's shared session sets no timeout, so without an explicit + # one a stalled Windy would hold the station's webhook open for aiohttp's + # 5 minute default and the TimeoutError branch below could never run in + # time to answer the station. + async with session.get( + request_url, + params=purged_data, + headers=headers, + timeout=ClientTimeout(total=FORWARD_TIMEOUT), + ) as resp: try: self.verify_windy_response(response=resp) except WindyNotInserted: diff --git a/tests/test_pocasi_ecowitt.py b/tests/test_pocasi_ecowitt.py index e76aeec..022abf3 100644 --- a/tests/test_pocasi_ecowitt.py +++ b/tests/test_pocasi_ecowitt.py @@ -79,11 +79,27 @@ class _RecordingSession: calls: list[dict[str, Any]] = field(default_factory=list) def get(self, url: str, *, params: dict[str, Any] | None = None, **kw: Any): - self.calls.append({"verb": "GET", "url": url, "params": dict(params or {}), "body": kw.get("data")}) + self.calls.append( + { + "verb": "GET", + "url": url, + "params": dict(params or {}), + "body": kw.get("data"), + "timeout": kw.get("timeout"), + } + ) return self.response - def post(self, url: str, *, params: dict[str, Any] | None = None, data: Any = None, **_kw: Any): - self.calls.append({"verb": "POST", "url": url, "params": dict(params or {}), "body": data}) + def post(self, url: str, *, params: dict[str, Any] | None = None, data: Any = None, **kw: Any): + self.calls.append( + { + "verb": "POST", + "url": url, + "params": dict(params or {}), + "body": data, + "timeout": kw.get("timeout"), + } + ) return self.response @@ -171,6 +187,22 @@ async def test_credentials_are_not_injected_into_the_body(pusher) -> None: assert key not in body +async def test_ecowitt_post_is_bounded_by_a_timeout(pusher) -> None: + """The POST is awaited inside the station's own request, so it must be bounded. + + Home Assistant's shared session sets no timeout, leaving aiohttp's 5 minute + default - long past the point where the station gives up waiting for us. + """ + from custom_components.sws12500.const import FORWARD_TIMEOUT + + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + timeout = session.calls[0]["timeout"] + assert timeout is not None + assert timeout.total == FORWARD_TIMEOUT + + async def test_caller_payload_is_not_mutated(pusher) -> None: """The same dict is handed to Windy and to the coordinator afterwards.""" pp, _ = pusher @@ -275,73 +307,87 @@ def test_pas_is_masked_for_logging() -> None: # --------------------------------------------------------------------------- -# Windy takes the same payload translated to PWS field names +# Windy takes the same payload translated to the field names it accepts # # Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`, -# `humidityin`, `uv` and `hourlyrainin` would not be understood there. +# `humidityin` and `hourlyrainin` would not be understood there. # --------------------------------------------------------------------------- -def test_ecowitt_to_wu_renames_the_diverging_fields() -> None: - from custom_components.sws12500.utils import remap_ecowitt_to_wu +def test_ecowitt_to_windy_renames_the_diverging_fields() -> None: + from custom_components.sws12500.utils import remap_ecowitt_to_windy - translated = remap_ecowitt_to_wu(ECOWITT_PAYLOAD) + translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD) assert translated["dewptf"] == "20.3" assert translated["baromin"] == "28.792" assert translated["indoortempf"] == "53.4" assert translated["indoorhumidity"] == "76" - assert translated["UV"] == "0" assert translated["rainin"] == "2.08" # WU `rainin` is the past hour # The Ecowitt spellings must be gone, not merely duplicated. - for gone in ("baromrelin", "tempinf", "humidityin", "uv", "hourlyrainin", "dewpointf"): + for gone in ("baromrelin", "tempinf", "humidityin", "hourlyrainin", "dewpointf"): assert gone not in translated -def test_ecowitt_to_wu_keeps_the_shared_names() -> None: - from custom_components.sws12500.utils import remap_ecowitt_to_wu +def test_ecowitt_to_windy_keeps_the_shared_names() -> None: + from custom_components.sws12500.utils import remap_ecowitt_to_windy - translated = remap_ecowitt_to_wu(ECOWITT_PAYLOAD) + translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD) for same in ("tempf", "humidity", "windspeedmph", "windgustmph", "winddir", "solarradiation", "dailyrainin"): assert translated[same] == ECOWITT_PAYLOAD[same] -def test_ecowitt_to_wu_drops_device_metadata() -> None: +def test_ecowitt_to_windy_drops_device_metadata() -> None: """Allowlist: station metadata has no meaning upstream and must not be forwarded.""" - from custom_components.sws12500.utils import remap_ecowitt_to_wu + from custom_components.sws12500.utils import remap_ecowitt_to_windy - translated = remap_ecowitt_to_wu(ECOWITT_PAYLOAD) + translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD) for junk in ("PASSKEY", "stationtype", "model", "freq", "baromabsin"): assert junk not in translated -def test_ecowitt_to_wu_ignores_unknown_future_fields() -> None: +def test_ecowitt_to_windy_ignores_unknown_future_fields() -> None: """A denylist would forward whatever a future firmware starts sending.""" - from custom_components.sws12500.utils import remap_ecowitt_to_wu + from custom_components.sws12500.utils import remap_ecowitt_to_windy - translated = remap_ecowitt_to_wu({**ECOWITT_PAYLOAD, "some_new_sensor_v9": "42"}) + translated = remap_ecowitt_to_windy({**ECOWITT_PAYLOAD, "some_new_sensor_v9": "42"}) assert "some_new_sensor_v9" not in translated -def test_ecowitt_to_wu_handles_a_partial_payload() -> None: - from custom_components.sws12500.utils import remap_ecowitt_to_wu +def test_ecowitt_to_windy_handles_a_partial_payload() -> None: + from custom_components.sws12500.utils import remap_ecowitt_to_windy - assert remap_ecowitt_to_wu({"tempf": "43.2"}) == {"tempf": "43.2"} - assert remap_ecowitt_to_wu({}) == {} + assert remap_ecowitt_to_windy({"tempf": "43.2"}) == {"tempf": "43.2"} + assert remap_ecowitt_to_windy({}) == {} -def test_ecowitt_to_wu_produces_names_the_wu_pipeline_knows() -> None: +def test_ecowitt_to_windy_keeps_the_windy_uv_spelling() -> None: + """Windy documents the UV index as lowercase `uv`; WU spells it `UV`. + + The WSLink converter already emits `uv` on the path that demonstrably works, so + this table has to follow Windy rather than WU - otherwise Ecowitt users silently + lose the UV index upstream. See `test_both_windy_converters_agree_on_uv`. + """ + from custom_components.sws12500.utils import remap_ecowitt_to_windy + + translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD) + assert translated["uv"] == "0" + assert "UV" not in translated + + +def test_ecowitt_to_windy_produces_names_the_wu_pipeline_knows() -> None: """The translated names must be the ones a real PWS station sends. That is what makes the Windy forward work: it is the already-proven path, so the - output is pinned against the integration's own WU parser rather than guessed. + output is pinned against the integration's own WU parser rather than guessed. `uv` + is the documented exception - Windy's own spelling, see the table's comment. """ from custom_components.sws12500.const import REMAP_ITEMS - from custom_components.sws12500.utils import remap_ecowitt_to_wu + from custom_components.sws12500.utils import remap_ecowitt_to_windy - translated = remap_ecowitt_to_wu(ECOWITT_PAYLOAD) - unknown = set(translated) - set(REMAP_ITEMS) - {"dateutc"} - assert not unknown, f"field names the WU protocol does not define: {sorted(unknown)}" + translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD) + unknown = set(translated) - set(REMAP_ITEMS) - {"dateutc", "uv"} + assert not unknown, f"field names neither protocol defines: {sorted(unknown)}" diff --git a/tests/test_pocasi_push.py b/tests/test_pocasi_push.py index d220797..a6925ea 100644 --- a/tests/test_pocasi_push.py +++ b/tests/test_pocasi_push.py @@ -49,8 +49,8 @@ class _FakeSession: self._exc = exc self.calls: list[dict[str, Any]] = [] - def get(self, url: str, *, params: dict[str, Any] | None = None): - self.calls.append({"url": url, "params": dict(params or {})}) + def get(self, url: str, *, params: dict[str, Any] | None = None, timeout: Any = None): + self.calls.append({"url": url, "params": dict(params or {}), "timeout": timeout}) if self._exc is not None: raise self._exc assert self._response is not None @@ -372,6 +372,33 @@ async def test_push_data_to_server_timeout_is_handled_like_client_error( assert pp.enabled is True +@pytest.mark.asyncio +async def test_push_data_to_server_bounds_the_request(monkeypatch, hass): + """The send must carry an explicit timeout. + + Home Assistant's shared session sets none, so aiohttp's 5 minute default would + hold the station's own webhook open long past the point where it gives up - and + the TimeoutError branch above could never run in time to matter. + """ + from custom_components.sws12500.const import FORWARD_TIMEOUT + + pp = PocasiPush(hass, _make_entry()) + + session = _FakeSession(response=_FakeResponse(status=200, text_value="OK")) + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + + pp.next_update = dt_util.utcnow() - timedelta(seconds=1) + await pp.push_data_to_server({"tempf": "68"}, "WU") + + timeout = session.calls[0]["timeout"] + assert timeout is not None + assert timeout.total == FORWARD_TIMEOUT + assert 0 < timeout.total <= 30 + + def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass): entry = _make_entry(logger=True) pp = PocasiPush(hass, entry) diff --git a/tests/test_windy_more.py b/tests/test_windy_more.py index d643d4c..c0cd4e8 100644 --- a/tests/test_windy_more.py +++ b/tests/test_windy_more.py @@ -30,7 +30,7 @@ class _FakeSession: def __init__(self, response: _FakeResponse) -> None: self._response = response - def get(self, url: str, *, params=None, headers=None): + def get(self, url: str, *, params=None, headers=None, timeout=None): return self._response diff --git a/tests/test_windy_push.py b/tests/test_windy_push.py index e3f963d..103a61c 100644 --- a/tests/test_windy_push.py +++ b/tests/test_windy_push.py @@ -51,9 +51,15 @@ class _FakeSession: *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, + timeout: Any = None, ): self.calls.append( - {"url": url, "params": dict(params or {}), "headers": dict(headers or {})} + { + "url": url, + "params": dict(params or {}), + "headers": dict(headers or {}), + "timeout": timeout, + } ) if self._exc is not None: raise self._exc @@ -109,6 +115,8 @@ def test_covert_wslink_to_pws_maps_keys(hass): "t1rainhr": "8", "t1uvi": "9", "t1solrad": "10", + "intem": "21.5", + "inhum": "48", "other": "keep", } out = wp._covert_wslink_to_pws(data) @@ -122,6 +130,8 @@ def test_covert_wslink_to_pws_maps_keys(hass): assert out["precip"] == "8" assert out["uv"] == "9" assert out["solarradiation"] == "10" + assert out["indoortempf"] == "21.5" + assert out["indoorhumidity"] == "48" assert out["other"] == "keep" for k in ( "t1ws", @@ -134,6 +144,8 @@ def test_covert_wslink_to_pws_maps_keys(hass): "t1rainhr", "t1uvi", "t1solrad", + "intem", + "inhum", ): assert k not in out @@ -521,10 +533,10 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug( @pytest.mark.asyncio async def test_push_data_to_windy_ecowitt_conversion_applied(monkeypatch, hass): - """End to end: an Ecowitt payload must reach Windy in PWS field names. + """End to end: an Ecowitt payload must reach Windy in the names it accepts. Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`, - `humidityin`, `uv` and `hourlyrainin` would not be understood there. + `humidityin` and `hourlyrainin` would not be understood there. """ entry = _make_entry() wp = WindyPush(hass, entry) @@ -555,11 +567,11 @@ async def test_push_data_to_windy_ecowitt_conversion_applied(monkeypatch, hass): assert params["tempf"] == "68" assert params["baromin"] == "29.9" assert params["dewptf"] == "50.1" - assert params["UV"] == "3" + assert params["uv"] == "3" assert params["rainin"] == "0.04" # Ecowitt spellings gone, station metadata never forwarded. - for gone in ("baromrelin", "dewpointf", "uv", "hourlyrainin", "PASSKEY", "stationtype", "model"): + for gone in ("baromrelin", "dewpointf", "hourlyrainin", "PASSKEY", "stationtype", "model"): assert gone not in params @@ -590,3 +602,70 @@ async def test_push_data_to_windy_purges_after_converting(monkeypatch, hass): params = session.calls[0]["params"] assert "indoortempf" not in params assert "indoorhumidity" not in params + + +@pytest.mark.asyncio +async def test_push_data_to_windy_drops_wslink_indoor_readings(monkeypatch, hass): + """WSLink `intem`/`inhum` must not reach Windy under either spelling. + + PURGE_DATA lists the PWS names, so before these two were mapped in the converter + they slipped through untouched and Windy received a raw `intem`/`inhum` pair. + """ + entry = _make_entry() + wp = WindyPush(hass, entry) + wp.next_update = dt_util.utcnow() - timedelta(minutes=1) + + session = _FakeSession(response=_FakeResponse(status=200, text_value="OK")) + monkeypatch.setattr( + "custom_components.sws12500.windy_func.async_get_clientsession", + lambda _h: session, + ) + + await wp.push_data_to_windy({"t1tem": "6.2", "intem": "21.5", "inhum": "48"}, source="wslink") + + params = session.calls[0]["params"] + assert params["temp"] == "6.2" + for gone in ("intem", "inhum", "indoortempf", "indoorhumidity"): + assert gone not in params + + +def test_both_windy_converters_agree_on_uv(hass): + """One UV spelling for Windy, whichever protocol the station speaks. + + Windy documents lowercase `uv`; WU spells it `UV`. When the two converters + disagreed, Ecowitt users silently lost the UV index upstream. + """ + from custom_components.sws12500.utils import remap_ecowitt_to_windy + + wp = WindyPush(hass, _make_entry()) + + assert wp._covert_wslink_to_pws({"t1uvi": "3"}) == {"uv": "3"} + assert remap_ecowitt_to_windy({"uv": "3"}) == {"uv": "3"} + + +@pytest.mark.asyncio +async def test_push_data_to_windy_bounds_the_request(monkeypatch, hass): + """The send must carry an explicit timeout. + + Home Assistant's shared session sets none, so aiohttp's 5 minute default would + hold the station's own webhook open long past the point where it gives up - and + the TimeoutError branch could never run in time to matter. + """ + from custom_components.sws12500.const import FORWARD_TIMEOUT + + entry = _make_entry() + wp = WindyPush(hass, entry) + wp.next_update = dt_util.utcnow() - timedelta(minutes=1) + + session = _FakeSession(response=_FakeResponse(status=200, text_value="OK")) + monkeypatch.setattr( + "custom_components.sws12500.windy_func.async_get_clientsession", + lambda _h: session, + ) + + await wp.push_data_to_windy({"tempf": "68"}) + + timeout = session.calls[0]["timeout"] + assert timeout is not None + assert timeout.total == FORWARD_TIMEOUT + assert 0 < timeout.total <= 30 diff --git a/tests/test_wslink_derived_indices.py b/tests/test_wslink_derived_indices.py index f817606..64ffcba 100644 --- a/tests/test_wslink_derived_indices.py +++ b/tests/test_wslink_derived_indices.py @@ -151,3 +151,35 @@ def test_wu_platform_still_computes_in_fahrenheit() -> None: 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 + + +# --------------------------------------------------------------------------- +# A heat index colder than the air is not a heat index +# --------------------------------------------------------------------------- + + +def test_cold_weather_heat_index_is_clamped_to_ambient() -> None: + """The NWS step-1 average drifts below the air temperature in the cold. + + Unclamped, this exact payload reports 3.92 C under a sensor named "Heat index" + while the station measures 6.2 C. + """ + assert wslink_heat_index(LIVE_PAYLOAD) == 6.2 + + +def test_hot_weather_heat_index_is_left_alone() -> None: + """The clamp must not touch the range the formula is actually defined for.""" + hot = {OUTSIDE_TEMP: "32", OUTSIDE_HUMIDITY: "70"} + computed = wslink_heat_index(hot) + + assert computed is not None + assert computed > 32, computed + + +def test_clamp_applies_to_the_wu_platform_too() -> None: + """Both platforms share `heat_index`, so neither can drift from the other.""" + assert heat_index({OUTSIDE_TEMP: "43.16", OUTSIDE_HUMIDITY: "40"}) == 43.16 + assert heat_index({OUTSIDE_TEMP: "6.2", OUTSIDE_HUMIDITY: "40"}, convert=True) == pytest.approx(43.16) + + hot_f = heat_index({OUTSIDE_TEMP: "90", OUTSIDE_HUMIDITY: "70"}) + assert hot_f is not None and hot_f > 90