730 lines
24 KiB
Python
730 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from aiohttp.client_exceptions import ClientError
|
|
import pytest
|
|
|
|
from custom_components.sws12500.const import (
|
|
PURGE_DATA,
|
|
WINDY_ENABLED,
|
|
WINDY_LOGGER_ENABLED,
|
|
WINDY_MAX_RETRIES,
|
|
WINDY_STATION_ID,
|
|
WINDY_STATION_PW,
|
|
WINDY_UNEXPECTED,
|
|
WINDY_URL,
|
|
)
|
|
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _FakeResponse:
|
|
status: int
|
|
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
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(
|
|
self, *, response: _FakeResponse | None = None, exc: Exception | None = None
|
|
):
|
|
self._response = response
|
|
self._exc = exc
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def get(
|
|
self,
|
|
url: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
timeout: Any = None,
|
|
):
|
|
self.calls.append(
|
|
{
|
|
"url": url,
|
|
"params": dict(params or {}),
|
|
"headers": dict(headers or {}),
|
|
"timeout": timeout,
|
|
}
|
|
)
|
|
if self._exc is not None:
|
|
raise self._exc
|
|
assert self._response is not None
|
|
return self._response
|
|
|
|
|
|
@pytest.fixture
|
|
def hass():
|
|
# Use HA provided fixture if available; otherwise a minimal stub works because we patch session getter.
|
|
return SimpleNamespace()
|
|
|
|
|
|
def _make_entry(**options: Any):
|
|
defaults = {
|
|
WINDY_LOGGER_ENABLED: False,
|
|
WINDY_ENABLED: True,
|
|
WINDY_STATION_ID: "station",
|
|
WINDY_STATION_PW: "token",
|
|
}
|
|
defaults.update(options)
|
|
return SimpleNamespace(options=defaults)
|
|
|
|
|
|
def test_verify_windy_response_notice_raises_not_inserted(hass):
|
|
wp = WindyPush(hass, _make_entry())
|
|
with pytest.raises(WindyNotInserted):
|
|
wp.verify_windy_response(_FakeResponse(status=400, text_value="Bad Request"))
|
|
|
|
|
|
def test_verify_windy_response_success_raises_success(hass):
|
|
wp = WindyPush(hass, _make_entry())
|
|
with pytest.raises(WindySuccess):
|
|
wp.verify_windy_response(_FakeResponse(status=200, text_value="OK"))
|
|
|
|
|
|
def test_verify_windy_response_password_missing_raises(hass):
|
|
wp = WindyPush(hass, _make_entry())
|
|
with pytest.raises(WindyPasswordMissing):
|
|
wp.verify_windy_response(_FakeResponse(status=401, text_value="Unauthorized"))
|
|
|
|
|
|
def test_covert_wslink_to_pws_maps_keys(hass):
|
|
wp = WindyPush(hass, _make_entry())
|
|
data = {
|
|
"t1ws": "1",
|
|
"t1wgust": "2",
|
|
"t1wdir": "3",
|
|
"t1hum": "4",
|
|
"t1dew": "5",
|
|
"t1tem": "6",
|
|
"rbar": "7",
|
|
"t1rainhr": "8",
|
|
"t1uvi": "9",
|
|
"t1solrad": "10",
|
|
"intem": "21.5",
|
|
"inhum": "48",
|
|
"other": "keep",
|
|
}
|
|
out = wp._covert_wslink_to_pws(data)
|
|
assert out["wind"] == "1"
|
|
assert out["gust"] == "2"
|
|
assert out["winddir"] == "3"
|
|
assert out["humidity"] == "4"
|
|
assert out["dewpoint"] == "5"
|
|
assert out["temp"] == "6"
|
|
assert out["mbar"] == "7"
|
|
assert out["precip"] == "8"
|
|
assert out["uv"] == "9"
|
|
assert out["solarradiation"] == "10"
|
|
assert out["other"] == "keep"
|
|
for k in (
|
|
"t1ws",
|
|
"t1wgust",
|
|
"t1wdir",
|
|
"t1hum",
|
|
"t1dew",
|
|
"t1tem",
|
|
"rbar",
|
|
"t1rainhr",
|
|
"t1uvi",
|
|
"t1solrad",
|
|
"intem",
|
|
"inhum",
|
|
):
|
|
assert k not in out
|
|
|
|
# Indoor readings are dropped, not renamed: WSLink sends Celsius, while the PWS
|
|
# `indoortempf` spelling promises Fahrenheit.
|
|
assert "indoortempf" not in out
|
|
assert "indoorhumidity" not in out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
|
|
# Ensure "next_update > now" is true
|
|
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
|
|
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: _FakeSession(response=_FakeResponse(status=200, text_value="OK")),
|
|
)
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
|
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
|
wp = WindyPush(hass, entry)
|
|
|
|
# Force it to send now
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
data = {k: "x" for k in PURGE_DATA}
|
|
data.update({"keep": "1"})
|
|
ok = await wp.push_data_to_windy(data, source="pws")
|
|
assert ok is True
|
|
|
|
assert len(session.calls) == 1
|
|
call = session.calls[0]
|
|
assert call["url"] == WINDY_URL
|
|
# Purged keys removed
|
|
for k in PURGE_DATA:
|
|
assert k not in call["params"]
|
|
# Added keys
|
|
assert call["params"]["id"] == entry.options[WINDY_STATION_ID]
|
|
assert call["params"]["time"] == "now"
|
|
assert (
|
|
call["headers"]["Authorization"] == f"Bearer {entry.options[WINDY_STATION_PW]}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=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({"t1ws": "1", "t1tem": "2"}, source="wslink")
|
|
assert ok is True
|
|
params = session.calls[0]["params"]
|
|
assert "wind" in params and params["wind"] == "1"
|
|
assert "temp" in params and params["temp"] == "2"
|
|
assert "t1ws" not in params and "t1tem" not in params
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch, hass):
|
|
entry = _make_entry()
|
|
entry.options.pop(WINDY_STATION_ID)
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
update_options = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is False
|
|
assert session.calls == []
|
|
# Disabling must not overwrite the specific reason the diagnostics sensor reports.
|
|
assert wp.last_status == "config_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch, hass):
|
|
entry = _make_entry()
|
|
entry.options.pop(WINDY_STATION_PW)
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
update_options = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is False
|
|
assert session.calls == []
|
|
assert wp.last_status == "config_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
# Response triggers WindyPasswordMissing (401)
|
|
session = _FakeSession(
|
|
response=_FakeResponse(status=401, text_value="Unauthorized")
|
|
)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
update_options = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is True
|
|
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
|
|
assert wp.last_status == "auth_error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_debug(
|
|
monkeypatch, hass
|
|
):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(
|
|
response=_FakeResponse(status=401, text_value="Unauthorized")
|
|
)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
update_options = AsyncMock(return_value=False)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
|
|
dbg = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
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_notice_logs_not_inserted(monkeypatch, hass):
|
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
err = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.error", err)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is True
|
|
# It logs WINDY_NOT_INSERTED regardless of log setting
|
|
err.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
|
|
monkeypatch, hass
|
|
):
|
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
info = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.info", info)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is True
|
|
# It should log WINDY_SUCCESS (or at least call info) when logging is enabled
|
|
info.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_logger_enabled(
|
|
monkeypatch, hass
|
|
):
|
|
"""Cover the `else:` branch when `verify_windy_response` does not raise.
|
|
|
|
This is a defensive branch in `push_data_to_windy`:
|
|
try: verify(...)
|
|
except ...:
|
|
else:
|
|
if self.log:
|
|
_LOGGER.debug(WINDY_NOT_INSERTED)
|
|
"""
|
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
|
|
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
debug = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", debug)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is True
|
|
debug.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_client_error_increments_and_disables_after_three(
|
|
monkeypatch, hass
|
|
):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
update_options = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
|
|
crit = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
# Cause ClientError on session.get
|
|
session = _FakeSession(exc=ClientError("boom"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
# First 3 calls should not disable; 4th should
|
|
for i in range(4):
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
assert ok is True
|
|
|
|
assert wp.invalid_response_count == 4
|
|
# update_options awaited once when count > 3
|
|
update_options.assert_awaited()
|
|
args = update_options.await_args.args
|
|
assert args[2] == WINDY_ENABLED
|
|
assert args[3] is False
|
|
# It should log WINDY_UNEXPECTED at least once
|
|
assert any(
|
|
WINDY_UNEXPECTED in str(c.args[0]) for c in crit.call_args_list if c.args
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_timeout_is_caught_and_not_counted(monkeypatch, hass):
|
|
"""A network timeout must not escape into the webhook handler.
|
|
|
|
`TimeoutError` is not a subclass of `ClientError`, so it used to propagate out
|
|
of `push_data_to_windy`, through the awaiting coordinator, and answer the
|
|
station with HTTP 500 - even though the measured data was already stored.
|
|
|
|
It must not spend the retry budget either: a slow upstream is transient, so the
|
|
next push simply tries again.
|
|
"""
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", AsyncMock(return_value=True)
|
|
)
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", MagicMock())
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
session = _FakeSession(exc=TimeoutError("timed out"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
|
|
assert ok is True
|
|
assert wp.last_status == "timeout"
|
|
assert wp.last_error == "TimeoutError"
|
|
assert wp.invalid_response_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_timeouts_never_disable_windy(monkeypatch, hass):
|
|
"""15 minutes of a merely slow Windy must not turn forwarding off for good.
|
|
|
|
Bounding the request made this branch reachable; counting it would let a
|
|
transient slowdown burn the whole retry budget with nothing to recover it.
|
|
"""
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
|
|
update_options = AsyncMock(return_value=True)
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func.update_options", update_options)
|
|
notify = MagicMock()
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create", notify
|
|
)
|
|
|
|
session = _FakeSession(exc=TimeoutError("timed out"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
for _ in range(WINDY_MAX_RETRIES + 2):
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
assert await wp.push_data_to_windy({"a": "b"}) is True
|
|
|
|
assert wp.invalid_response_count == 0
|
|
assert wp.enabled is True
|
|
update_options.assert_not_awaited()
|
|
notify.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_does_not_reset_an_existing_client_error_budget(monkeypatch, hass):
|
|
"""A timeout is ignored by the counter, not a substitute for a successful send."""
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.invalid_response_count = 2
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
session = _FakeSession(exc=TimeoutError("timed out"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
await wp.push_data_to_windy({"a": "b"})
|
|
|
|
assert wp.invalid_response_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
|
|
monkeypatch, hass
|
|
):
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.invalid_response_count = 3 # next error will push it over the threshold
|
|
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
|
|
|
update_options = AsyncMock(return_value=False)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.update_options", update_options
|
|
)
|
|
|
|
dbg = MagicMock()
|
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
|
MagicMock(),
|
|
)
|
|
|
|
session = _FakeSession(exc=ClientError("boom"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
ok = await wp.push_data_to_windy({"a": "b"})
|
|
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 the names it accepts.
|
|
|
|
Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`,
|
|
`humidityin` 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", "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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_drops_wslink_indoor_readings(monkeypatch, hass):
|
|
"""WSLink `intem`/`inhum` must not reach Windy under either spelling.
|
|
|
|
PURGE_DATA lists the PWS names, so these two used to slip through untouched and
|
|
Windy received a raw `intem`/`inhum` pair. The converter drops them instead, which
|
|
holds regardless of what PURGE_DATA contains.
|
|
"""
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
await wp.push_data_to_windy({"t1tem": "6.2", "intem": "21.5", "inhum": "48"}, source="wslink")
|
|
|
|
params = session.calls[0]["params"]
|
|
assert params["temp"] == "6.2"
|
|
for gone in ("intem", "inhum", "indoortempf", "indoorhumidity"):
|
|
assert gone not in params
|
|
|
|
|
|
def test_both_windy_converters_agree_on_uv(hass):
|
|
"""One UV spelling for Windy, whichever protocol the station speaks.
|
|
|
|
Windy documents lowercase `uv`; WU spells it `UV`. When the two converters
|
|
disagreed, Ecowitt users silently lost the UV index upstream.
|
|
"""
|
|
from custom_components.sws12500.utils import remap_ecowitt_to_windy
|
|
|
|
wp = WindyPush(hass, _make_entry())
|
|
|
|
assert wp._covert_wslink_to_pws({"t1uvi": "3"}) == {"uv": "3"}
|
|
assert remap_ecowitt_to_windy({"uv": "3"}) == {"uv": "3"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_data_to_windy_bounds_the_request(monkeypatch, hass):
|
|
"""The send must carry an explicit timeout.
|
|
|
|
Home Assistant's shared session sets none, so aiohttp's 5 minute default would
|
|
hold the station's own webhook open long past the point where it gives up - and
|
|
the TimeoutError branch could never run in time to matter.
|
|
"""
|
|
from custom_components.sws12500.const import FORWARD_TIMEOUT
|
|
|
|
entry = _make_entry()
|
|
wp = WindyPush(hass, entry)
|
|
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
|
|
|
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
|
monkeypatch.setattr(
|
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
|
lambda _h: session,
|
|
)
|
|
|
|
await wp.push_data_to_windy({"tempf": "68"})
|
|
|
|
timeout = session.calls[0]["timeout"]
|
|
assert timeout is not None
|
|
assert timeout.total == FORWARD_TIMEOUT
|
|
assert 0 < timeout.total <= 30
|