fix(correctness): reload-window guards, rate-limit races, ecowitt flush, dt_util

- C1: received_data / received_ecowitt_data return 503 (not 500) when the entry is
  mid-reload (runtime_data gone); add_new_sensors / add_new_binary_sensors are
  defensive no-ops in that window too.
- C2/C3: Windy and Pocasi reserve their next send window *before* the await, so
  concurrent webhooks can no longer both pass the rate-limit check and double-send
  (which could falsely trip the auto-disable threshold).
- C5: EcowittBridge.set_add_entities flushes unmapped sensors parsed before the
  platform was ready (aioecowitt fires new_sensor_cb only once per key).
- C6: a forwarder auto-disabling itself from the hot path no longer forces a full
  config-entry reload (update_listener now skips reload for live-read flags
  SENSORS_TO_LOAD / WINDY_ENABLED / POCASI_CZ_ENABLED).
- C7: to_int accepts decimal-formatted integers ("180.0").
- C8: forwarders use dt_util.utcnow() (UTC-aware) instead of naive datetime.now().

Tests updated; 305 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SchiZzA 2026-06-21 18:32:00 +02:00
parent 1fe4d6da45
commit 038d2459b9
No known key found for this signature in database
17 changed files with 158 additions and 65 deletions

View File

@ -46,7 +46,9 @@ from .const import (
ECOWITT_URL_PREFIX,
HEALTH_URL,
LEGACY_ENABLED,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
WSLINK_URL,
)
@ -202,10 +204,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
"""Handle config entry option updates.
We skip reloading when only `SENSORS_TO_LOAD` changes.
We skip reloading when only live-read options change:
- `SENSORS_TO_LOAD` (auto-discovery updates it as new payload fields appear), and
- the forwarding enable flags (`WINDY_ENABLED`/`POCASI_CZ_ENABLED`), which the
forwarders read on every push - so a forwarder that auto-disables itself from the
hot path no longer triggers a disruptive reload.
Why:
- Auto-discovery updates `SENSORS_TO_LOAD` as new payload fields appear.
- Reloading a push-based integration temporarily unloads platforms and removes
coordinator listeners, which can make the UI appear "stuck" until restart.
"""
@ -219,8 +224,8 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
runtime.last_options = new_options
if changed_keys == {SENSORS_TO_LOAD}:
_LOGGER.debug("Options updated (%s); skipping reload.", SENSORS_TO_LOAD)
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
return
update_legacy_battery_issue(hass, entry)

View File

@ -58,7 +58,9 @@ def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: lis
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = entry.runtime_data
runtime = getattr(entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_binary_entities
if add_entities is None:
return

View File

@ -100,6 +100,10 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
entities through the bridge callback.
"""
# See received_data: reject cleanly if the entry is mid-reload (no runtime_data).
if getattr(self.config, "runtime_data", None) is None:
return aiohttp.web.Response(text="Integration is reloading.", status=503)
health = self._health_coordinator()
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
@ -184,6 +188,12 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
- updates coordinator data so existing entities refresh immediately
"""
# The aiohttp routes outlive a config-entry reload and keep pointing at this
# bound method. If a payload arrives while the entry is unloaded, runtime_data
# is gone; reject cleanly with 503 instead of raising AttributeError (500).
if getattr(self.config, "runtime_data", None) is None:
return aiohttp.web.Response(text="Integration is reloading.", status=503)
# WSLink uses different auth and payload field naming than the legacy endpoint.
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)

View File

@ -166,10 +166,18 @@ class EcowittBridge:
self._add_entities_cb: AddEntitiesCallback | None = None
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
"""Store the platform callback for dynamic entity creation."""
"""Store the platform callback for dynamic entity creation.
aioecowitt fires `new_sensor_cb` only once per key. If a payload arrived
before the platform was ready (callback unset), those unmapped sensors were
skipped and would never get an entity. Flush them now so nothing is lost.
"""
self._add_entities_cb = callback
for sensor in self.unmapped_sensor.values():
self._on_new_sensor(sensor)
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
"""Process raw Ecowitt POST payload.

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import timedelta
import logging
from typing import Any, Literal
@ -12,6 +12,7 @@ from py_typecheck.core import checked
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_URL,
@ -56,8 +57,8 @@ class PocasiPush:
self.last_attempt_at: str | None = None
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
self.last_update = datetime.now()
self.next_update = datetime.now() + timedelta(seconds=self._interval)
self.last_update = dt_util.utcnow()
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
self.invalid_response_count = 0
@ -81,7 +82,7 @@ class PocasiPush:
_data = data.copy()
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
self.last_attempt_at = datetime.now().isoformat()
self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
@ -103,7 +104,7 @@ class PocasiPush:
str(self.next_update),
)
if self.next_update > datetime.now():
if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local"
_LOGGER.debug(
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s",
@ -112,6 +113,9 @@ class PocasiPush:
)
return
# 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
@ -161,8 +165,8 @@ class PocasiPush:
self.enabled = False
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
self.last_update = datetime.now()
self.next_update = datetime.now() + timedelta(seconds=self._interval)
self.last_update = dt_util.utcnow()
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
if self.log:
_LOGGER.info("Next update: %s", str(self.next_update))

View File

@ -132,7 +132,9 @@ def add_new_sensors(hass: HomeAssistant, config_entry: SWSConfigEntry, keys: lis
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = config_entry.runtime_data
runtime = getattr(config_entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_sensor_entities
if add_entities is None:
return

View File

@ -261,7 +261,7 @@ def celsius_to_fahrenheit(celsius: float) -> float:
def to_int(val: Any) -> int | None:
"""Convert int or string to int."""
"""Convert int or string (including decimal-formatted, e.g. "180.0") to int."""
if val is None:
return None
@ -270,11 +270,15 @@ def to_int(val: Any) -> int | None:
return None
try:
v = int(val)
return int(val)
except (TypeError, ValueError):
pass
# The station sometimes sends integer fields as decimals ("180.0"); accept those.
try:
return int(float(val))
except (TypeError, ValueError):
return None
else:
return v
def to_float(val: Any) -> float | None:

View File

@ -13,6 +13,7 @@ from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
PURGE_DATA,
@ -88,8 +89,8 @@ class WindyPush:
""" lets wait for 1 minute to get initial data from station
and then try to push first data to Windy
"""
self.last_update: datetime = datetime.now()
self.next_update: datetime = datetime.now() + timed(minutes=1)
self.last_update: datetime = dt_util.utcnow()
self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
@ -170,7 +171,7 @@ class WindyPush:
# First check if we have valid credentials, before any data manipulation.
self.enabled = self.config.options.get(WINDY_ENABLED, False)
self.last_attempt_at = datetime.now().isoformat()
self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
@ -196,10 +197,14 @@ class WindyPush:
str(self.next_update),
)
if self.next_update > datetime.now():
if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local"
return False
# Reserve the next send window now (before the await below) so a concurrent
# 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:
@ -261,7 +266,7 @@ class WindyPush:
_LOGGER.critical(
"Windy responded with WindyRateLimitExceeded, this should happend only on restarting Home Assistant when we lost track of last send time. Pause resend for next 5 minutes."
)
self.next_update = datetime.now() + timedelta(minutes=5)
self.next_update = dt_util.utcnow() + timedelta(minutes=5)
except WindySuccess:
# reset invalid_response_count
@ -300,7 +305,7 @@ class WindyPush:
if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED)
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.")
self.last_update = datetime.now()
self.last_update = dt_util.utcnow()
self.next_update = self.last_update + timed(minutes=5)
if self.log:

View File

@ -215,3 +215,9 @@ def test_device_info():
assert info["manufacturer"] == "Schizza"
assert info["model"] == "Weather Station SWS 12500"
assert info["identifiers"] == {(DOMAIN,)}
def test_add_new_binary_sensors_noop_when_runtime_data_missing():
"""add_new_binary_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_binary_sensors(None, entry, keys=["outside_battery"]) # must not raise

View File

@ -352,3 +352,21 @@ def test_handle_update_writes_ha_state() -> None:
entity._handle_update()
entity.async_write_ha_state.assert_called_once_with()
@pytest.mark.asyncio
async def test_set_add_entities_flushes_pending_unmapped_sensors() -> None:
"""Unmapped sensors parsed before the callback was set are flushed on set_add_entities."""
bridge = _make_bridge()
# Payload arrives before the platform is ready (no callback): native sensors are
# parsed by aioecowitt but skipped by _on_new_sensor.
await bridge.process_payload(dict(_PAYLOAD))
assert bridge.unmapped_sensor # e.g. pm25_ch1 / co2 parsed but not yet created
created: list[Any] = []
bridge.set_add_entities(lambda entities: created.extend(entities))
# The previously-skipped unmapped sensors are created now.
assert created
assert all(isinstance(e, EcoWittNativeSensor) for e in created)

View File

@ -393,6 +393,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Missing security params -> unauthorized
@ -408,6 +409,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
# Missing API_ID in options -> IncorrectDataError
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
entry2.add_to_hass(hass)
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
with pytest.raises(IncorrectDataError):
await coordinator2.received_data(
@ -415,6 +417,20 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_returns_503_when_runtime_data_missing(hass):
"""A payload arriving while the entry is unloaded is rejected with 503, not 500."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", WSLINK: False})
entry.add_to_hass(hass)
# No runtime_data assigned -> simulates the unloaded/reloading window.
coordinator = WeatherDataUpdateCoordinator(hass, entry)
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 503
@pytest.mark.asyncio
async def test_register_path_idempotent_when_routes_exist(hass_with_http):
"""A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
from types import SimpleNamespace
from typing import Any, Literal
from unittest.mock import AsyncMock, MagicMock
@ -21,11 +21,8 @@ from custom_components.sws12500.const import (
POCASI_INVALID_KEY,
WSLINK_URL,
)
from custom_components.sws12500.pocasti_cz import (
PocasiApiKeyError,
PocasiPush,
PocasiSuccess,
)
from custom_components.sws12500.pocasti_cz import PocasiApiKeyError, PocasiPush, PocasiSuccess
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
@ -123,7 +120,7 @@ async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
pp = PocasiPush(hass, entry)
# Ensure "next_update > now" so it returns early before doing HTTP.
pp.next_update = datetime.now() + timedelta(seconds=999)
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
@ -146,7 +143,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
pp = PocasiPush(hass, entry)
# Force send now.
pp.next_update = datetime.now() - timedelta(seconds=1)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
@ -176,7 +173,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = datetime.now() - timedelta(seconds=1)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
@ -196,7 +193,7 @@ async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, hass):
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = datetime.now() - timedelta(seconds=1)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
@ -232,7 +229,7 @@ async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, h
async def test_push_data_to_server_success_logs_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry)
pp.next_update = datetime.now() - timedelta(seconds=1)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
@ -276,7 +273,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
# Force request attempts and exceed invalid count threshold.
for _i in range(4):
pp.next_update = datetime.now() - timedelta(seconds=1)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 4

View File

@ -576,3 +576,19 @@ async def test_received_data_success_with_health_autodiscovery_and_binary(hass,
assert kw["accepted"] is True
assert kw["reason"] == "accepted"
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
@pytest.mark.asyncio
async def test_received_ecowitt_returns_503_when_runtime_data_missing(hass):
"""An Ecowitt payload during the unload window is rejected with 503, not 500."""
entry = SimpleNamespace(
entry_id="x",
options={ECOWITT_ENABLED: True},
async_on_unload=lambda *_a, **_k: None,
) # no runtime_data attribute -> guard triggers
coordinator = WeatherDataUpdateCoordinator(hass, entry)
resp = await coordinator.received_ecowitt_data(
_EcowittRequestStub(match_info={"webhook_id": "x"})
) # type: ignore[arg-type]
assert resp.status == 503

View File

@ -212,3 +212,9 @@ def test_add_new_sensors_adds_known_keys(hass):
assert len(entities_arg) == 1
assert isinstance(entities_arg[0], WeatherSensor)
assert entities_arg[0].entity_description.key == known_desc.key
def test_add_new_sensors_noop_when_runtime_data_missing():
"""add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_sensors(None, entry, keys=["anything"]) # must not raise

View File

@ -1,5 +1,5 @@
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
import pytest
def test_sensor_types_wslink_structure():

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
@ -12,6 +12,7 @@ import pytest
from custom_components.sws12500.const import WINDY_ENABLED, WINDY_LOGGER_ENABLED, WINDY_STATION_ID, WINDY_STATION_PW
from custom_components.sws12500.windy_func import WindyDuplicatePayloadDetected, WindyPush, WindyRateLimitExceeded
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
@ -64,7 +65,7 @@ def test_verify_response_rate_limit_raises(hass):
@pytest.mark.asyncio
async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
wp = WindyPush(hass, _make_entry())
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
@ -80,14 +81,14 @@ async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
@pytest.mark.asyncio
async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
wp = WindyPush(hass, _make_entry())
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(_FakeResponse(status=429)),
)
before = datetime.now()
before = dt_util.utcnow()
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.last_status == "rate_limited_remote"
@ -99,7 +100,7 @@ async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
wp = WindyPush(hass, _make_entry())
wp.invalid_response_count = 2 # next duplicate makes it 3 -> finally disables
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
@ -12,21 +12,14 @@ import pytest
from custom_components.sws12500.const import (
PURGE_DATA,
WINDY_ENABLED,
WINDY_INVALID_KEY,
WINDY_LOGGER_ENABLED,
WINDY_NOT_INSERTED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WINDY_SUCCESS,
WINDY_UNEXPECTED,
WINDY_URL,
)
from custom_components.sws12500.windy_func import (
WindyNotInserted,
WindyPasswordMissing,
WindyPush,
WindySuccess,
)
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
@ -151,7 +144,7 @@ async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass
wp = WindyPush(hass, entry)
# Ensure "next_update > now" is true
wp.next_update = datetime.now() + timedelta(minutes=10)
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
@ -167,7 +160,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
wp = WindyPush(hass, entry)
# Force it to send now
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
@ -198,7 +191,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
@ -219,7 +212,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
entry = _make_entry()
entry.options.pop(WINDY_STATION_ID)
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
@ -246,7 +239,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
entry = _make_entry()
entry.options.pop(WINDY_STATION_PW)
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
@ -272,7 +265,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
# Response triggers WindyPasswordMissing (401)
session = _FakeSession(
@ -303,7 +296,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
@ -335,7 +328,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
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 = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
monkeypatch.setattr(
@ -358,7 +351,7 @@ async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
@ -390,7 +383,7 @@ async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_l
"""
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
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"))
@ -413,7 +406,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
@ -436,7 +429,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
# First 3 calls should not disable; 4th should
for i in range(4):
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
@ -459,7 +452,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.invalid_response_count = 3 # next error will push it over the threshold
wp.next_update = datetime.now() - timedelta(seconds=1)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=False)
monkeypatch.setattr(