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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue