diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index 175849d..b19aba2 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -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) diff --git a/custom_components/sws12500/binary_sensor.py b/custom_components/sws12500/binary_sensor.py index 8c6f458..c432a99 100644 --- a/custom_components/sws12500/binary_sensor.py +++ b/custom_components/sws12500/binary_sensor.py @@ -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 diff --git a/custom_components/sws12500/coordinator.py b/custom_components/sws12500/coordinator.py index eaca6f9..87461a3 100644 --- a/custom_components/sws12500/coordinator.py +++ b/custom_components/sws12500/coordinator.py @@ -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) diff --git a/custom_components/sws12500/ecowitt.py b/custom_components/sws12500/ecowitt.py index cf6bb5b..4788414 100644 --- a/custom_components/sws12500/ecowitt.py +++ b/custom_components/sws12500/ecowitt.py @@ -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. diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index 73496e3..618f746 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -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)) diff --git a/custom_components/sws12500/sensor.py b/custom_components/sws12500/sensor.py index 3440b34..cf9110e 100644 --- a/custom_components/sws12500/sensor.py +++ b/custom_components/sws12500/sensor.py @@ -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 diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 98baaf9..53b16e1 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -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: diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index 18269e7..3851d0c 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -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: diff --git a/tests/test_binary_battery.py b/tests/test_binary_battery.py index 17a157d..a06ed5a 100644 --- a/tests/test_binary_battery.py +++ b/tests/test_binary_battery.py @@ -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 diff --git a/tests/test_ecowitt_bridge.py b/tests/test_ecowitt_bridge.py index 255f41e..796f9c4 100644 --- a/tests/test_ecowitt_bridge.py +++ b/tests/test_ecowitt_bridge.py @@ -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) diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index 3872f1d..b1d7bca 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -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).""" diff --git a/tests/test_pocasi_push.py b/tests/test_pocasi_push.py index 73af3b6..58df5f9 100644 --- a/tests/test_pocasi_push.py +++ b/tests/test_pocasi_push.py @@ -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 diff --git a/tests/test_received_ecowitt.py b/tests/test_received_ecowitt.py index f4d4e90..b1288fe 100644 --- a/tests/test_received_ecowitt.py +++ b/tests/test_received_ecowitt.py @@ -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 diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index a9f1201..a67f7c5 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -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 diff --git a/tests/test_sensors_wslink.py b/tests/test_sensors_wslink.py index 9f9317e..4aba4e7 100644 --- a/tests/test_sensors_wslink.py +++ b/tests/test_sensors_wslink.py @@ -1,5 +1,5 @@ + from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK -import pytest def test_sensor_types_wslink_structure(): diff --git a/tests/test_windy_more.py b/tests/test_windy_more.py index 49a6f24..d57716c 100644 --- a/tests/test_windy_more.py +++ b/tests/test_windy_more.py @@ -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( diff --git a/tests/test_windy_push.py b/tests/test_windy_push.py index b6d31c4..2f13a51 100644 --- a/tests/test_windy_push.py +++ b/tests/test_windy_push.py @@ -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(