From 2b266f33b4f6e861664ff8b7f968102824cf209b Mon Sep 17 00:00:00 2001 From: SchiZzA Date: Sun, 26 Jul 2026 11:50:11 +0200 Subject: [PATCH] feat(sws12500): forward Ecowitt payloads to Windy and Pocasi Meteo --- custom_components/sws12500/const.py | 42 +++ custom_components/sws12500/coordinator.py | 15 +- custom_components/sws12500/pocasti_cz.py | 59 +++- custom_components/sws12500/utils.py | 17 +- custom_components/sws12500/windy_func.py | 38 ++- tests/test_pocasi_ecowitt.py | 347 ++++++++++++++++++++++ tests/test_received_data.py | 2 +- tests/test_received_ecowitt.py | 41 ++- tests/test_windy_push.py | 77 ++++- 9 files changed, 601 insertions(+), 37 deletions(-) create mode 100644 tests/test_pocasi_ecowitt.py diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 8668712..29b57dd 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -179,6 +179,15 @@ POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz" POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data POCASI_CZ_MAX_RETRIES: Final = 3 # failed sends in a row before resending is disabled +# Pocasi Meteo accepts Ecowitt only as a POST in the Ecowitt protocol itself - the +# station payload is forwarded verbatim rather than translated to PWS. Per their +# integration notes the account credentials go in the query string, and the password +# parameter is `PAS` (not `PASS` / `PASSWORD` as on the PWS endpoint): +# ms.pocasimeteo.cz/ws_ecowitt/ws.php?ID=xxxx&PAS=yyyy +POCASI_CZ_ECOWITT_URL: Final = "/ws_ecowitt/ws.php" +POCASI_CZ_ECOWITT_ID_PARAM: Final = "ID" +POCASI_CZ_ECOWITT_PW_PARAM: Final = "PAS" + WSLINK: Final = "wslink" LEGACY_ENABLED: Final = "legacy_enabled" @@ -191,6 +200,39 @@ ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id" ECOWITT_ENABLED: Final = "ecowitt_enabled" ECOWITT_URL_PREFIX: Final = "/weatherhub" +# Ecowitt field names -> WU/PWS field names. +# +# 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. +# +# 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 +# protocol has no equivalent, and mapping them onto its soil fields would send wrong +# data. +REMAP_ECOWITT_TO_WU: Final[dict[str, str]] = { + # same name on both sides, listed so the allowlist is complete + "tempf": "tempf", + "humidity": "humidity", + "windspeedmph": "windspeedmph", + "windgustmph": "windgustmph", + "winddir": "winddir", + "dailyrainin": "dailyrainin", + "solarradiation": "solarradiation", + "dateutc": "dateutc", + # 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 +} + REMAP_ECOWITT_COMPAT: dict[str, str] = { "tempf": OUTSIDE_TEMP, "humidity": OUTSIDE_HUMIDITY, diff --git a/custom_components/sws12500/coordinator.py b/custom_components/sws12500/coordinator.py index 39367e9..3ca6b63 100644 --- a/custom_components/sws12500/coordinator.py +++ b/custom_components/sws12500/coordinator.py @@ -228,12 +228,17 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): _windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False) _pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False) - if _windy_enabled: - await self.windy.push_data_to_windy(data, False) - # TODO: create ecowitt protocol to send full payload to Pocasi CZ + # The two services need the payload in different shapes. Windy has no Ecowitt + # endpoint and converts to PWS itself; Pocasi Meteo does, so it gets the station + # payload verbatim. + if _windy_enabled: + await self.windy.push_data_to_windy(data, "ecowitt") + + # Pocasi Meteo does have an Ecowitt endpoint, so it gets the station payload + # verbatim instead. if _pocasi_enabled: - await self.pocasi.push_data_to_server(data, "WU") + await self.pocasi.push_data_to_server(data, "ECOWITT") if health: health.update_forwarding(self.windy, self.pocasi) @@ -324,7 +329,7 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): _pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False) if _windy_enabled: - await self.windy.push_data_to_windy(data, _wslink) + await self.windy.push_data_to_windy(data, "wslink" if _wslink else "pws") if _pocasi_enabled: await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU") diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index 7cf2275..98d8db3 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging from typing import Any, Literal @@ -18,6 +19,9 @@ from .const import ( DEFAULT_URL, POCASI_CZ_API_ID, POCASI_CZ_API_KEY, + POCASI_CZ_ECOWITT_ID_PARAM, + POCASI_CZ_ECOWITT_PW_PARAM, + POCASI_CZ_ECOWITT_URL, POCASI_CZ_ENABLED, POCASI_CZ_LOGGER_ENABLED, POCASI_CZ_MAX_RETRIES, @@ -35,6 +39,9 @@ _LOGGER = logging.getLogger(__name__) # Outcome of a single send, derived from the HTTP status (see `verify_response`). type PocasiResult = Literal["ok", "auth_error", "unexpected_response"] +# Which upstream protocol a payload is forwarded in. +type PocasiMode = Literal["WU", "WSLINK", "ECOWITT"] + class PocasiPush: """Forward station payloads to the Pocasi Meteo server.""" @@ -90,8 +97,13 @@ class PocasiPush: if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False): _LOGGER.debug("Failed to set Pocasi Meteo options to false.") - async def push_data_to_server(self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]): - """Pushes weather data to server.""" + async def push_data_to_server(self, data: dict[str, Any], mode: PocasiMode): + """Forward a station payload to Pocasi Meteo. + + WU and WSLINK are GET requests carrying the readings as query parameters. + ECOWITT is a POST of the station's own payload with the credentials in the + query string - the server only accepts the Ecowitt protocol that way. + """ _data = data.copy() self.last_attempt_at = dt_util.utcnow().isoformat() @@ -130,26 +142,43 @@ class PocasiPush: # Reserve the next send window before the await to avoid concurrent double-sends. self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval) - request_url: str = "" - if mode == "WSLINK": - _data["wsid"] = _api_id - _data["wspw"] = _api_key - request_url = f"{POCASI_CZ_URL}{WSLINK_URL}" - - if mode == "WU": - _data["ID"] = _api_id - _data["PASSWORD"] = _api_key - request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}" - session = async_get_clientsession(self.hass) + request_url: str = "" + params: dict[str, Any] = {} + + 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 + # ours, and they travel in the query string as ID / PAS. + request_url = f"{POCASI_CZ_URL}{POCASI_CZ_ECOWITT_URL}" + params = { + 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) + else: + if mode == "WSLINK": + _data["wsid"] = _api_id + _data["wspw"] = _api_key + request_url = f"{POCASI_CZ_URL}{WSLINK_URL}" + else: + _data["ID"] = _api_id + _data["PASSWORD"] = _api_key + request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}" + + make_request = partial(session.get, request_url, params=_data) + _LOGGER.debug( - "Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] = %s", + "Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] [params=%s] = %s", mode, request_url, + anonymize(params), anonymize(_data), ) try: - async with session.get(request_url, params=_data) as resp: + # Built inside the try: a failure while creating the request must be handled + # like any other transport error, not escape into the webhook handler. + async with make_request() as resp: result = self.verify_response(resp.status, await resp.text()) http_status = resp.status diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 4396909..32af2f7 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -32,6 +32,7 @@ from .const import ( HEAT_INDEX, OUTSIDE_HUMIDITY, OUTSIDE_TEMP, + REMAP_ECOWITT_TO_WU, REMAP_ITEMS, REMAP_WSLINK_ITEMS, SENSORS_TO_LOAD, @@ -113,7 +114,8 @@ def anonymize( - Keep all keys, but mask sensitive values. - Do not raise on unexpected/missing keys. """ - secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"} + # `PAS` is Pocasi Meteo's Ecowitt password parameter (not a typo for PASS). + secrets = {"ID", "PASSWORD", "PAS", "wsid", "wspw", "passkey", "PASSKEY"} return {k: ("***" if k in secrets else v) for k, v in data.items()} @@ -447,3 +449,16 @@ 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. + + 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. + + 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. + """ + return {wu_key: data[eco_key] for eco_key, wu_key in REMAP_ECOWITT_TO_WU.items() if eco_key in data} diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index 54412db..565909a 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Literal from aiohttp.client import ClientResponse from aiohttp.client_exceptions import ClientError @@ -28,10 +29,13 @@ from .const import ( WINDY_UNEXPECTED, WINDY_URL, ) -from .utils import update_options +from .utils import remap_ecowitt_to_wu, update_options _LOGGER = logging.getLogger(__name__) +# Which protocol the payload arrived in; Windy itself always speaks PWS. +type WindySource = Literal["pws", "wslink", "ecowitt"] + class WindyNotInserted(Exception): """NotInserted state. @@ -133,6 +137,21 @@ class WindyPush: if response.status == 429: 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. + + 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 + in one place instead of being applied by whoever happens to call us. + """ + if source == "wslink": + return self._covert_wslink_to_pws(data) + 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 data + def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]: """Convert WSLink API data to Windy API data protocol.""" if "t1ws" in indata: @@ -174,7 +193,7 @@ class WindyPush: persistent_notification.async_create(self.hass, reason, "Windy resending disabled.") - async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool: + async def push_data_to_windy(self, data: dict[str, str], source: WindySource = "pws") -> bool: """Pushes weather data do Windy stations. Interval is 5 minutes, otherwise Windy would not accepts data. @@ -220,15 +239,12 @@ class WindyPush: # webhook does not also pass the rate-limit check and double-send. self.next_update = dt_util.utcnow() + timed(minutes=5) - purged_data = data.copy() - - for purge in PURGE_DATA: - if purge in purged_data: - _ = purged_data.pop(purge) - - if wslink: - # WSLink -> Windy params - purged_data = self._covert_wslink_to_pws(purged_data) + # Convert *before* purging. PURGE_DATA lists PWS field names, so a reading that + # only becomes a purge target after conversion - Ecowitt `tempinf` -> + # `indoortempf`, WSLink `t1solrad` -> `solarradiation` - would otherwise slip + # through to Windy unnoticed. + converted = self._to_pws(data.copy(), source) + purged_data = {key: value for key, value in converted.items() if key not in PURGE_DATA} request_url = f"{WINDY_URL}" diff --git a/tests/test_pocasi_ecowitt.py b/tests/test_pocasi_ecowitt.py new file mode 100644 index 0000000..e76aeec --- /dev/null +++ b/tests/test_pocasi_ecowitt.py @@ -0,0 +1,347 @@ +"""Forwarding an Ecowitt payload to Pocasi Meteo. + +Their integration notes are specific, and every detail here is load-bearing: + +- Ecowitt is accepted **only as a POST** in the Ecowitt protocol; the payload is + forwarded verbatim rather than translated into PWS field names. +- The endpoint is ``/ws_ecowitt/ws.php`` (not the PWS one). +- The account credentials go in the **query string**, and the password parameter is + ``PAS`` - not ``PASS`` or ``PASSWORD`` as on the PWS endpoint. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import timedelta +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from custom_components.sws12500.const import ( + DEFAULT_URL, + POCASI_CZ_API_ID, + POCASI_CZ_API_KEY, + POCASI_CZ_ECOWITT_URL, + POCASI_CZ_ENABLED, + POCASI_CZ_LOGGER_ENABLED, + POCASI_CZ_SEND_INTERVAL, + POCASI_CZ_URL, +) +from custom_components.sws12500.pocasti_cz import PocasiPush +from homeassistant.util import dt as dt_util + +# A representative Ecowitt POST body, including the metadata a station really sends. +ECOWITT_PAYLOAD: dict[str, str] = { + "PASSKEY": "A1B2C3D4E5F6", + "stationtype": "GW1000B_V1.6.8", + "dateutc": "2026-07-26 01:09:02", + "tempinf": "53.4", + "humidityin": "76", + "baromrelin": "28.792", + "baromabsin": "28.792", + "tempf": "43.2", + "humidity": "40", + "dewpointf": "20.3", + "winddir": "119", + "windspeedmph": "6.9", + "windgustmph": "9.7", + "solarradiation": "7.0", + "uv": "0", + "dailyrainin": "3.55", + "hourlyrainin": "2.08", + "model": "GW1000", + "freq": "868M", +} + + +@dataclass(slots=True) +class _FakeResponse: + status: int = 200 + text_value: str = "" + + async def text(self) -> str: + return self.text_value + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + +@dataclass +class _RecordingSession: + """Records how the request was made: verb, url, query params and body.""" + + response: _FakeResponse = field(default_factory=_FakeResponse) + 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")}) + 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}) + return self.response + + +def _make_entry(*, api_id: str = "myid", api_key: str = "mykey") -> Any: + return SimpleNamespace( + options={ + POCASI_CZ_API_ID: api_id, + POCASI_CZ_API_KEY: api_key, + POCASI_CZ_ENABLED: True, + POCASI_CZ_LOGGER_ENABLED: False, + POCASI_CZ_SEND_INTERVAL: 30, + }, + entry_id="entry", + ) + + +@pytest.fixture +def hass(): + return SimpleNamespace() + + +@pytest.fixture +def pusher(monkeypatch, hass): + """A PocasiPush wired to a recording session and allowed to send immediately.""" + session = _RecordingSession() + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) + + pp = PocasiPush(hass, _make_entry()) + pp.next_update = dt_util.utcnow() - timedelta(seconds=1) + return pp, session + + +# --------------------------------------------------------------------------- +# Request shape +# --------------------------------------------------------------------------- + + +async def test_ecowitt_is_posted_not_get(pusher) -> None: + """Their server accepts the Ecowitt protocol only via POST.""" + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert len(session.calls) == 1 + assert session.calls[0]["verb"] == "POST" + + +async def test_ecowitt_uses_its_own_endpoint(pusher) -> None: + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert session.calls[0]["url"] == f"{POCASI_CZ_URL}{POCASI_CZ_ECOWITT_URL}" + assert session.calls[0]["url"] != f"{POCASI_CZ_URL}{DEFAULT_URL}" + + +async def test_credentials_go_in_the_query_string_as_id_and_pas(pusher) -> None: + """`PAS`, not `PASS`/`PASSWORD` - the PWS spelling is rejected here.""" + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + params = session.calls[0]["params"] + assert params == {"ID": "myid", "PAS": "mykey"} + assert "PASS" not in params + assert "PASSWORD" not in params + + +async def test_station_payload_is_forwarded_verbatim(pusher) -> None: + """A full forward: what the station sent is what the server receives.""" + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert session.calls[0]["body"] == ECOWITT_PAYLOAD + + +async def test_credentials_are_not_injected_into_the_body(pusher) -> None: + """Only the query string carries our credentials; the body stays the station's.""" + pp, session = pusher + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + body = session.calls[0]["body"] + for key in ("ID", "PAS", "PASSWORD", "wsid", "wspw"): + assert key not in body + + +async def test_caller_payload_is_not_mutated(pusher) -> None: + """The same dict is handed to Windy and to the coordinator afterwards.""" + pp, _ = pusher + original = dict(ECOWITT_PAYLOAD) + + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert ECOWITT_PAYLOAD == original + + +# --------------------------------------------------------------------------- +# The PWS / WSLink modes keep their existing shape +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("mode", "path", "id_key", "pw_key"), + [("WU", DEFAULT_URL, "ID", "PASSWORD"), ("WSLINK", "/data/upload.php", "wsid", "wspw")], +) +async def test_legacy_modes_still_use_get_with_inline_credentials(pusher, mode, path, id_key, pw_key) -> None: + pp, session = pusher + await pp.push_data_to_server({"tempf": "43.2"}, mode) + + call = session.calls[0] + assert call["verb"] == "GET" + assert call["url"] == f"{POCASI_CZ_URL}{path}" + assert call["params"][id_key] == "myid" + assert call["params"][pw_key] == "mykey" + + +# --------------------------------------------------------------------------- +# Shared behaviour still applies to the new mode +# --------------------------------------------------------------------------- + + +async def test_ecowitt_respects_the_send_interval(monkeypatch, hass) -> None: + session = _RecordingSession() + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + pp = PocasiPush(hass, _make_entry()) + pp.next_update = dt_util.utcnow() + timedelta(seconds=999) + + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert session.calls == [] + assert pp.last_status == "rate_limited_local" + + +@pytest.mark.parametrize("missing", ["api_id", "api_key"]) +async def test_ecowitt_needs_credentials(monkeypatch, hass, missing) -> None: + session = _RecordingSession() + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + pp = PocasiPush(hass, _make_entry(**{missing: ""})) + pp.next_update = dt_util.utcnow() - timedelta(seconds=1) + + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert session.calls == [] + assert pp.last_status == "config_error" + + +async def test_ecowitt_auth_error_disables_forwarding(monkeypatch, hass) -> None: + session = _RecordingSession(response=_FakeResponse(status=401)) + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) + + entry = _make_entry() + + async def _apply(_hass, _entry, key, value): + entry.options[key] = value + return True + + monkeypatch.setattr("custom_components.sws12500.pocasti_cz.update_options", MagicMock(side_effect=_apply)) + + pp = PocasiPush(hass, entry) + pp.next_update = dt_util.utcnow() - timedelta(seconds=1) + + await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT") + + assert pp.last_status == "auth_error" + assert pp.enabled is False + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def test_pas_is_masked_for_logging() -> None: + """`PAS` is a credential; the debug log must not print it.""" + from custom_components.sws12500.utils import anonymize + + assert anonymize({"ID": "myid", "PAS": "mykey"}) == {"ID": "***", "PAS": "***"} + + +# --------------------------------------------------------------------------- +# Windy takes the same payload translated to PWS field names +# +# Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`, +# `humidityin`, `uv` 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 + + translated = remap_ecowitt_to_wu(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"): + 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 + + translated = remap_ecowitt_to_wu(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: + """Allowlist: station metadata has no meaning upstream and must not be forwarded.""" + from custom_components.sws12500.utils import remap_ecowitt_to_wu + + translated = remap_ecowitt_to_wu(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: + """A denylist would forward whatever a future firmware starts sending.""" + from custom_components.sws12500.utils import remap_ecowitt_to_wu + + translated = remap_ecowitt_to_wu({**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 + + assert remap_ecowitt_to_wu({"tempf": "43.2"}) == {"tempf": "43.2"} + assert remap_ecowitt_to_wu({}) == {} + + +def test_ecowitt_to_wu_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. + """ + from custom_components.sws12500.const import REMAP_ITEMS + from custom_components.sws12500.utils import remap_ecowitt_to_wu + + 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)}" diff --git a/tests/test_received_data.py b/tests/test_received_data.py index 16eaab4..9cc8928 100644 --- a/tests/test_received_data.py +++ b/tests/test_received_data.py @@ -218,7 +218,7 @@ async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch): coordinator.windy.push_data_to_windy.assert_awaited_once() args, _kwargs = coordinator.windy.push_data_to_windy.await_args assert isinstance(args[0], dict) # raw data dict - assert args[1] is False # wslink flag + assert args[1] == "pws" # protocol the payload arrived in @pytest.mark.asyncio diff --git a/tests/test_received_ecowitt.py b/tests/test_received_ecowitt.py index 7d0b512..11a3111 100644 --- a/tests/test_received_ecowitt.py +++ b/tests/test_received_ecowitt.py @@ -262,10 +262,11 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_ coordinator.windy.push_data_to_windy.assert_awaited_once() w_args, _ = coordinator.windy.push_data_to_windy.await_args assert isinstance(w_args[0], dict) - assert w_args[1] is False + assert w_args[1] == "ecowitt" coordinator.pocasi.push_data_to_server.assert_awaited_once() p_args, _ = coordinator.pocasi.push_data_to_server.await_args - assert p_args[1] == "WU" + # Pocasi Meteo has a dedicated Ecowitt endpoint; the payload is forwarded as-is. + assert p_args[1] == "ECOWITT" # Health branches. health.update_ingress_result.assert_called_once() @@ -571,3 +572,39 @@ async def test_received_ecowitt_returns_503_when_runtime_data_missing(hass): _EcowittRequestStub(match_info={"webhook_id": "x"}) ) # type: ignore[arg-type] assert resp.status == 503 + + +async def test_each_forwarder_is_told_which_protocol_the_payload_is(hass, monkeypatch): + """Routing contract: Windy converts to PWS itself, Pocasi wants Ecowitt verbatim. + + Both forwarders get the untouched station payload; what differs is the protocol + they are told it arrived in. The conversion itself lives in `WindyPush._to_pws` + and is verified end to end in test_windy_push.py, not at this call site. + """ + entry = _make_entry( + ecowitt_enabled=True, + ecowitt_webhook_id="hook", + windy_enabled=True, + pocasi_enabled=True, + health=_make_health_stub(), + ) + coordinator = WeatherDataUpdateCoordinator(hass, entry) + + coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={"outside_temp": "68"}) + monkeypatch.setattr("custom_components.sws12500.coordinator.check_disabled", lambda *_: None) + coordinator.async_set_updated_data = MagicMock() + + coordinator.windy.push_data_to_windy = AsyncMock() + coordinator.pocasi.push_data_to_server = AsyncMock() + + station_payload = {"PASSKEY": "ABC123", "model": "GW1000", "tempf": "68", "baromrelin": "29.9"} + request = _EcowittRequestStub(match_info={"webhook_id": "hook"}, post_data=dict(station_payload)) + await coordinator.received_ecowitt_data(request) # type: ignore[arg-type] + + windy_payload, windy_source = coordinator.windy.push_data_to_windy.await_args[0] + assert windy_source == "ecowitt" + assert windy_payload == station_payload + + pocasi_payload, pocasi_mode = coordinator.pocasi.push_data_to_server.await_args[0] + assert pocasi_mode == "ECOWITT" + assert pocasi_payload == station_payload diff --git a/tests/test_windy_push.py b/tests/test_windy_push.py index fe116d2..e3f963d 100644 --- a/tests/test_windy_push.py +++ b/tests/test_windy_push.py @@ -170,7 +170,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass): data = {k: "x" for k in PURGE_DATA} data.update({"keep": "1"}) - ok = await wp.push_data_to_windy(data, wslink=False) + ok = await wp.push_data_to_windy(data, source="pws") assert ok is True assert len(session.calls) == 1 @@ -199,7 +199,7 @@ async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass): lambda _h: session, ) - ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, wslink=True) + ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, source="wslink") assert ok is True params = session.calls[0]["params"] assert "wind" in params and params["wind"] == "1" @@ -517,3 +517,76 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug( assert ok is True update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False) dbg.assert_called() + + +@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. + + Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`, + `humidityin`, `uv` and `hourlyrainin` would not be understood there. + """ + 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, + ) + + ok = await wp.push_data_to_windy( + { + "PASSKEY": "ABC123", + "stationtype": "GW1000B_V1.6.8", + "model": "GW1000", + "tempf": "68", + "baromrelin": "29.9", + "dewpointf": "50.1", + "uv": "3", + "hourlyrainin": "0.04", + }, + source="ecowitt", + ) + assert ok is True + + params = session.calls[0]["params"] + assert params["tempf"] == "68" + assert params["baromin"] == "29.9" + assert params["dewptf"] == "50.1" + 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"): + assert gone not in params + + +@pytest.mark.asyncio +async def test_push_data_to_windy_purges_after_converting(monkeypatch, hass): + """PURGE_DATA lists PWS names, so it must be applied *after* the conversion. + + Purging first would let a reading that only becomes a purge target once converted + (WSLink `t1solrad` -> `solarradiation`, Ecowitt `tempinf` -> `indoortempf`) reach + Windy anyway. + """ + 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", "t1solrad": "7"}, source="wslink") + assert "solarradiation" not in session.calls[0]["params"] + + session.calls.clear() + wp.next_update = dt_util.utcnow() - timedelta(minutes=1) + await wp.push_data_to_windy({"tempf": "68", "tempinf": "70.2", "humidityin": "55"}, source="ecowitt") + params = session.calls[0]["params"] + assert "indoortempf" not in params + assert "indoorhumidity" not in params