feat(sws12500): forward Ecowitt payloads to Windy and Pocasi Meteo
parent
0e5a8ce10c
commit
2b266f33b4
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue