feat(sws12500): forward Ecowitt payloads to Windy and Pocasi Meteo

ecowitt_support
SchiZzA 2026-07-26 11:50:11 +02:00
parent 0e5a8ce10c
commit 2b266f33b4
No known key found for this signature in database
9 changed files with 601 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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