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>
parent
1fe4d6da45
commit
038d2459b9
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
||||
import pytest
|
||||
|
||||
|
||||
def test_sensor_types_wslink_structure():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue