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 WINDY_MAX_RETRIES: Final = 3
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT" 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: Final = "ecowitt"
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id" ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
ECOWITT_ENABLED: Final = "ecowitt_enabled" ECOWITT_ENABLED: Final = "ecowitt_enabled"
ECOWITT_URL_PREFIX: Final = "/weatherhub" 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 # 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 # can be forwarded there. The target vocabulary is mostly the PWS/WU one Windy already
# Windy already receives from a real PWS station, which is the path known to work. # 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 # Deliberately an allowlist, not a "strip the bad keys" denylist: the Ecowitt payload
# also carries device metadata (stationtype, model, freq, runtime, heap, interval) that # also carries device metadata (stationtype, model, freq, runtime, heap, interval) that
# is meaningless upstream, and a denylist would forward whatever fields a future # is meaningless upstream, and a denylist would forward whatever fields a future
# firmware adds. # 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 # protocol has no equivalent, and mapping them onto its soil fields would send wrong
# data. # 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 # same name on both sides, listed so the allowlist is complete
"tempf": "tempf", "tempf": "tempf",
"humidity": "humidity", "humidity": "humidity",
@ -224,12 +238,12 @@ REMAP_ECOWITT_TO_WU: Final[dict[str, str]] = {
"dailyrainin": "dailyrainin", "dailyrainin": "dailyrainin",
"solarradiation": "solarradiation", "solarradiation": "solarradiation",
"dateutc": "dateutc", "dateutc": "dateutc",
"uv": "uv",
# renamed between the two protocols # renamed between the two protocols
"dewpointf": "dewptf", "dewpointf": "dewptf",
"baromrelin": "baromin", "baromrelin": "baromin",
"tempinf": "indoortempf", "tempinf": "indoortempf",
"humidityin": "indoorhumidity", "humidityin": "indoorhumidity",
"uv": "UV",
"hourlyrainin": "rainin", # WU `rainin` is the accumulation over the past hour "hourlyrainin": "rainin", # WU `rainin` is the accumulation over the past hour
} }

View File

@ -7,7 +7,7 @@ from functools import partial
import logging import logging
from typing import Any, Literal from typing import Any, Literal
from aiohttp import ClientError from aiohttp import ClientError, ClientTimeout
from py_typecheck.core import checked_or from py_typecheck.core import checked_or
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util
from .const import ( from .const import (
DEFAULT_URL, DEFAULT_URL,
FORWARD_TIMEOUT,
POCASI_CZ_API_ID, POCASI_CZ_API_ID,
POCASI_CZ_API_KEY, POCASI_CZ_API_KEY,
POCASI_CZ_ECOWITT_ID_PARAM, POCASI_CZ_ECOWITT_ID_PARAM,
@ -146,6 +147,12 @@ class PocasiPush:
request_url: str = "" request_url: str = ""
params: dict[str, Any] = {} 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": if mode == "ECOWITT":
# Pocasi Meteo takes Ecowitt only as a POST in the Ecowitt protocol itself, # Pocasi Meteo takes Ecowitt only as a POST in the Ecowitt protocol itself,
# so the station payload is forwarded verbatim; only the credentials are # 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_ID_PARAM: _api_id,
POCASI_CZ_ECOWITT_PW_PARAM: _api_key, 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: else:
if mode == "WSLINK": if mode == "WSLINK":
_data["wsid"] = _api_id _data["wsid"] = _api_id
@ -166,7 +173,7 @@ class PocasiPush:
_data["PASSWORD"] = _api_key _data["PASSWORD"] = _api_key
request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}" 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( _LOGGER.debug(
"Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] [params=%s] = %s", "Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] [params=%s] = %s",

View File

@ -32,7 +32,7 @@ from .const import (
HEAT_INDEX, HEAT_INDEX,
OUTSIDE_HUMIDITY, OUTSIDE_HUMIDITY,
OUTSIDE_TEMP, OUTSIDE_TEMP,
REMAP_ECOWITT_TO_WU, REMAP_ECOWITT_TO_WINDY,
REMAP_ITEMS, REMAP_ITEMS,
REMAP_WSLINK_ITEMS, REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD, 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 data: dict with temperature and humidity
convert: bool, convert received data from Celsius to Fahrenheit 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: if (temp := to_float(data.get(OUTSIDE_TEMP))) is None:
_LOGGER.error( _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): if rh > 80 and (80 <= temp <= 87):
adjustment = ((rh - 85) / 10) * ((87 - temp) / 5) 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: 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) 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]: def remap_ecowitt_to_windy(data: dict[str, Any]) -> dict[str, Any]:
"""Translate an Ecowitt payload into WU/PWS field names. """Translate an Ecowitt payload into the field names Windy accepts.
Needed for services that do not speak the Ecowitt protocol - Windy, unlike Pocasi Needed for services that do not speak the Ecowitt protocol - Windy, unlike Pocasi
Meteo, has no Ecowitt endpoint, so fields such as `baromrelin`, `dewpointf`, Meteo, has no Ecowitt endpoint, so fields such as `baromrelin`, `dewpointf` or
`tempinf` or `uv` would simply not be understood. `tempinf` would simply not be understood.
Only fields listed in `REMAP_ECOWITT_TO_WU` are passed on; see the table there for Only fields listed in `REMAP_ECOWITT_TO_WINDY` are passed on; see the table there
why that is an allowlist rather than a denylist. 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 import logging
from typing import Literal from typing import Literal
from aiohttp.client import ClientResponse from aiohttp.client import ClientResponse, ClientTimeout
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from py_typecheck import checked_or 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 homeassistant.util import dt as dt_util
from .const import ( from .const import (
FORWARD_TIMEOUT,
PURGE_DATA, PURGE_DATA,
WINDY_ENABLED, WINDY_ENABLED,
WINDY_INVALID_KEY, WINDY_INVALID_KEY,
@ -29,7 +30,7 @@ from .const import (
WINDY_UNEXPECTED, WINDY_UNEXPECTED,
WINDY_URL, WINDY_URL,
) )
from .utils import remap_ecowitt_to_wu, update_options from .utils import remap_ecowitt_to_windy, update_options
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -138,7 +139,7 @@ class WindyPush:
raise WindyRateLimitExceeded raise WindyRateLimitExceeded
def _to_pws(self, data: dict[str, str], source: WindySource) -> dict[str, str]: 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 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 onto it first. All three sources are dispatched here so the conversions stay
@ -149,7 +150,7 @@ class WindyPush:
if source == "ecowitt": if source == "ecowitt":
# Windy has no Ecowitt endpoint; the shared table also drops the station # Windy has no Ecowitt endpoint; the shared table also drops the station
# metadata (PASSKEY, stationtype, model, ...) that means nothing upstream. # metadata (PASSKEY, stationtype, model, ...) that means nothing upstream.
return remap_ecowitt_to_wu(data) return remap_ecowitt_to_windy(data)
return data return data
def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]: def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]:
@ -174,6 +175,15 @@ class WindyPush:
indata["uv"] = indata.pop("t1uvi") indata["uv"] = indata.pop("t1uvi")
if "t1solrad" in indata: if "t1solrad" in indata:
indata["solarradiation"] = indata.pop("t1solrad") 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 return indata
@ -259,7 +269,16 @@ class WindyPush:
_LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"}) _LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"})
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
try: 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: try:
self.verify_windy_response(response=resp) self.verify_windy_response(response=resp)
except WindyNotInserted: except WindyNotInserted:

View File

@ -79,11 +79,27 @@ class _RecordingSession:
calls: list[dict[str, Any]] = field(default_factory=list) calls: list[dict[str, Any]] = field(default_factory=list)
def get(self, url: str, *, params: dict[str, Any] | None = None, **kw: Any): 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 return self.response
def post(self, url: str, *, params: dict[str, Any] | None = None, data: Any = None, **_kw: Any): 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}) self.calls.append(
{
"verb": "POST",
"url": url,
"params": dict(params or {}),
"body": data,
"timeout": kw.get("timeout"),
}
)
return self.response return self.response
@ -171,6 +187,22 @@ async def test_credentials_are_not_injected_into_the_body(pusher) -> None:
assert key not in body 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: async def test_caller_payload_is_not_mutated(pusher) -> None:
"""The same dict is handed to Windy and to the coordinator afterwards.""" """The same dict is handed to Windy and to the coordinator afterwards."""
pp, _ = pusher 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`, # 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: def test_ecowitt_to_windy_renames_the_diverging_fields() -> None:
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)
assert translated["dewptf"] == "20.3" assert translated["dewptf"] == "20.3"
assert translated["baromin"] == "28.792" assert translated["baromin"] == "28.792"
assert translated["indoortempf"] == "53.4" assert translated["indoortempf"] == "53.4"
assert translated["indoorhumidity"] == "76" assert translated["indoorhumidity"] == "76"
assert translated["UV"] == "0"
assert translated["rainin"] == "2.08" # WU `rainin` is the past hour assert translated["rainin"] == "2.08" # WU `rainin` is the past hour
# The Ecowitt spellings must be gone, not merely duplicated. # 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 assert gone not in translated
def test_ecowitt_to_wu_keeps_the_shared_names() -> None: def test_ecowitt_to_windy_keeps_the_shared_names() -> None:
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 same in ("tempf", "humidity", "windspeedmph", "windgustmph", "winddir", "solarradiation", "dailyrainin"): for same in ("tempf", "humidity", "windspeedmph", "windgustmph", "winddir", "solarradiation", "dailyrainin"):
assert translated[same] == ECOWITT_PAYLOAD[same] 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.""" """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"): for junk in ("PASSKEY", "stationtype", "model", "freq", "baromabsin"):
assert junk not in translated 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.""" """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 assert "some_new_sensor_v9" not in translated
def test_ecowitt_to_wu_handles_a_partial_payload() -> None: def test_ecowitt_to_windy_handles_a_partial_payload() -> None:
from custom_components.sws12500.utils import remap_ecowitt_to_wu 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_windy({"tempf": "43.2"}) == {"tempf": "43.2"}
assert remap_ecowitt_to_wu({}) == {} 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. """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 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.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) translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
unknown = set(translated) - set(REMAP_ITEMS) - {"dateutc"} unknown = set(translated) - set(REMAP_ITEMS) - {"dateutc", "uv"}
assert not unknown, f"field names the WU protocol does not define: {sorted(unknown)}" assert not unknown, f"field names neither protocol defines: {sorted(unknown)}"

View File

@ -49,8 +49,8 @@ class _FakeSession:
self._exc = exc self._exc = exc
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
def get(self, url: str, *, params: dict[str, Any] | None = None): def get(self, url: str, *, params: dict[str, Any] | None = None, timeout: Any = None):
self.calls.append({"url": url, "params": dict(params or {})}) self.calls.append({"url": url, "params": dict(params or {}), "timeout": timeout})
if self._exc is not None: if self._exc is not None:
raise self._exc raise self._exc
assert self._response is not None 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 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): def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True) entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry) pp = PocasiPush(hass, entry)

View File

@ -30,7 +30,7 @@ class _FakeSession:
def __init__(self, response: _FakeResponse) -> None: def __init__(self, response: _FakeResponse) -> None:
self._response = response 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 return self._response

View File

@ -51,9 +51,15 @@ class _FakeSession:
*, *,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
timeout: Any = None,
): ):
self.calls.append( 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: if self._exc is not None:
raise self._exc raise self._exc
@ -109,6 +115,8 @@ def test_covert_wslink_to_pws_maps_keys(hass):
"t1rainhr": "8", "t1rainhr": "8",
"t1uvi": "9", "t1uvi": "9",
"t1solrad": "10", "t1solrad": "10",
"intem": "21.5",
"inhum": "48",
"other": "keep", "other": "keep",
} }
out = wp._covert_wslink_to_pws(data) 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["precip"] == "8"
assert out["uv"] == "9" assert out["uv"] == "9"
assert out["solarradiation"] == "10" assert out["solarradiation"] == "10"
assert out["indoortempf"] == "21.5"
assert out["indoorhumidity"] == "48"
assert out["other"] == "keep" assert out["other"] == "keep"
for k in ( for k in (
"t1ws", "t1ws",
@ -134,6 +144,8 @@ def test_covert_wslink_to_pws_maps_keys(hass):
"t1rainhr", "t1rainhr",
"t1uvi", "t1uvi",
"t1solrad", "t1solrad",
"intem",
"inhum",
): ):
assert k not in out 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 @pytest.mark.asyncio
async def test_push_data_to_windy_ecowitt_conversion_applied(monkeypatch, hass): 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`, 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() entry = _make_entry()
wp = WindyPush(hass, 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["tempf"] == "68"
assert params["baromin"] == "29.9" assert params["baromin"] == "29.9"
assert params["dewptf"] == "50.1" assert params["dewptf"] == "50.1"
assert params["UV"] == "3" assert params["uv"] == "3"
assert params["rainin"] == "0.04" assert params["rainin"] == "0.04"
# Ecowitt spellings gone, station metadata never forwarded. # 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 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"] params = session.calls[0]["params"]
assert "indoortempf" not in params assert "indoortempf" not in params
assert "indoorhumidity" 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} 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[HEAT_INDEX].value_from_data_fn is heat_index
assert by_key[CHILL_INDEX].value_from_data_fn is chill_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