fix(review): clamp heat index, unify Windy uv, bound forward timeouts

ecowitt_support
SchiZzA 2026-07-26 13:13:05 +02:00
parent 4e4c3b8f7c
commit 2c6b698ca9
No known key found for this signature in database
9 changed files with 291 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}"

View File

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

View File

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

View File

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

View File

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