fix(review): clamp heat index, unify Windy uv, bound forward timeouts
parent
4e4c3b8f7c
commit
2c6b698ca9
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue