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>
ecowitt_support
SchiZzA 2026-06-21 18:32:00 +02:00
parent 4fa86a5bb0
commit 14466cbe60
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, ECOWITT_URL_PREFIX,
HEALTH_URL, HEALTH_URL,
LEGACY_ENABLED, LEGACY_ENABLED,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD, SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK, WSLINK,
WSLINK_URL, WSLINK_URL,
) )
@ -202,10 +204,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry): async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
"""Handle config entry option updates. """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: Why:
- Auto-discovery updates `SENSORS_TO_LOAD` as new payload fields appear.
- Reloading a push-based integration temporarily unloads platforms and removes - Reloading a push-based integration temporarily unloads platforms and removes
coordinator listeners, which can make the UI appear "stuck" until restart. 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 runtime.last_options = new_options
if changed_keys == {SENSORS_TO_LOAD}: if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
_LOGGER.debug("Options updated (%s); skipping reload.", SENSORS_TO_LOAD) _LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
return return
update_legacy_battery_issue(hass, entry) 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 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 add_entities = runtime.add_binary_entities
if add_entities is None: if add_entities is None:
return return

View File

@ -100,6 +100,10 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
entities through the bridge callback. 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() health = self._health_coordinator()
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False): 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 - 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 uses different auth and payload field naming than the legacy endpoint.
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False) _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 self._add_entities_cb: AddEntitiesCallback | None = None
def set_add_entities(self, callback: AddEntitiesCallback) -> 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 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]: async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
"""Process raw Ecowitt POST payload. """Process raw Ecowitt POST payload.

View File

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

View File

@ -261,7 +261,7 @@ def celsius_to_fahrenheit(celsius: float) -> float:
def to_int(val: Any) -> int | None: 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: if val is None:
return None return None
@ -270,11 +270,15 @@ def to_int(val: Any) -> int | None:
return None return None
try: 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): except (TypeError, ValueError):
return None return None
else:
return v
def to_float(val: Any) -> float | None: 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import ( from .const import (
PURGE_DATA, PURGE_DATA,
@ -88,8 +89,8 @@ class WindyPush:
""" lets wait for 1 minute to get initial data from station """ lets wait for 1 minute to get initial data from station
and then try to push first data to Windy and then try to push first data to Windy
""" """
self.last_update: datetime = datetime.now() self.last_update: datetime = dt_util.utcnow()
self.next_update: datetime = datetime.now() + timed(minutes=1) self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False) 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. # First check if we have valid credentials, before any data manipulation.
self.enabled = self.config.options.get(WINDY_ENABLED, False) 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 self.last_error = None
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is 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), str(self.next_update),
) )
if self.next_update > datetime.now(): if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local" self.last_status = "rate_limited_local"
return False 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() purged_data = data.copy()
for purge in PURGE_DATA: for purge in PURGE_DATA:
@ -261,7 +266,7 @@ class WindyPush:
_LOGGER.critical( _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." "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: except WindySuccess:
# reset invalid_response_count # reset invalid_response_count
@ -300,7 +305,7 @@ class WindyPush:
if self.invalid_response_count >= WINDY_MAX_RETRIES: if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED) _LOGGER.critical(WINDY_UNEXPECTED)
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.") 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) self.next_update = self.last_update + timed(minutes=5)
if self.log: if self.log:

View File

@ -215,3 +215,9 @@ def test_device_info():
assert info["manufacturer"] == "Schizza" assert info["manufacturer"] == "Schizza"
assert info["model"] == "Weather Station SWS 12500" assert info["model"] == "Weather Station SWS 12500"
assert info["identifiers"] == {(DOMAIN,)} 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._handle_update()
entity.async_write_ha_state.assert_called_once_with() 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}, options={API_ID: "id", API_KEY: "key", WSLINK: False},
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator = WeatherDataUpdateCoordinator(hass, entry) coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Missing security params -> unauthorized # 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 # Missing API_ID in options -> IncorrectDataError
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False}) entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
entry2.add_to_hass(hass) entry2.add_to_hass(hass)
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2) coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
with pytest.raises(IncorrectDataError): with pytest.raises(IncorrectDataError):
await coordinator2.received_data( await coordinator2.received_data(
@ -415,6 +417,20 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
) # type: ignore[arg-type] ) # 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 @pytest.mark.asyncio
async def test_register_path_idempotent_when_routes_exist(hass_with_http): 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).""" """A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Literal from typing import Any, Literal
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -21,11 +21,8 @@ from custom_components.sws12500.const import (
POCASI_INVALID_KEY, POCASI_INVALID_KEY,
WSLINK_URL, WSLINK_URL,
) )
from custom_components.sws12500.pocasti_cz import ( from custom_components.sws12500.pocasti_cz import PocasiApiKeyError, PocasiPush, PocasiSuccess
PocasiApiKeyError, from homeassistant.util import dt as dt_util
PocasiPush,
PocasiSuccess,
)
@dataclass(slots=True) @dataclass(slots=True)
@ -123,7 +120,7 @@ async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
pp = PocasiPush(hass, entry) pp = PocasiPush(hass, entry)
# Ensure "next_update > now" so it returns early before doing HTTP. # 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")) session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr( monkeypatch.setattr(
@ -146,7 +143,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
pp = PocasiPush(hass, entry) pp = PocasiPush(hass, entry)
# Force send now. # 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")) session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr( 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): async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
entry = _make_entry() entry = _make_entry()
pp = PocasiPush(hass, 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")) session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr( 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): async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, hass):
entry = _make_entry() entry = _make_entry()
pp = PocasiPush(hass, 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")) session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr( 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): async def test_push_data_to_server_success_logs_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True) entry = _make_entry(logger=True)
pp = PocasiPush(hass, 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")) session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr( 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. # Force request attempts and exceed invalid count threshold.
for _i in range(4): 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") await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 4 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["accepted"] is True
assert kw["reason"] == "accepted" assert kw["reason"] == "accepted"
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi) 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 len(entities_arg) == 1
assert isinstance(entities_arg[0], WeatherSensor) assert isinstance(entities_arg[0], WeatherSensor)
assert entities_arg[0].entity_description.key == known_desc.key 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 from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
import pytest
def test_sensor_types_wslink_structure(): def test_sensor_types_wslink_structure():

View File

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

View File

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