Compare commits

...

2 Commits

Author SHA1 Message Date
SchiZzA 39b16afcbc
Update constants to more readable form. 2026-03-05 11:47:52 +01:00
SchiZzA f0554573ce
Make routes method-aware and update related tests
Include HTTP method in route keys and dispatch, and fix
Routes.show_enabled.
Update register_path to accept a HealthCoordinator and adjust router
stubs in tests. Update WindyPush tests to use response objects
(status/text)
and adapt related exception/notification expectations.
2026-03-04 07:53:26 +01:00
8 changed files with 280 additions and 173 deletions

View File

@ -3,26 +3,92 @@
from enum import StrEnum
from typing import Final
from .channels import *
# Integration specific constants.
DOMAIN = "sws12500"
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
WSLINK_URL = "/data/upload.php"
HEALTH_URL = "/station/health"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
DATABASE_PATH = "/config/home-assistant_v2.db"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
ICON = "mdi:weather"
DEV_DBG: Final = "dev_debug_checkbox"
# Common constants
API_KEY = "API_KEY"
API_ID = "API_ID"
SENSORS_TO_LOAD: Final = "sensors_to_load"
SENSOR_TO_MIGRATE: Final = "sensor_to_migrate"
DEV_DBG: Final = "dev_debug_checkbox"
INVALID_CREDENTIALS: Final = [
"API",
"API_ID",
"API ID",
"_ID",
"ID",
"API KEY",
"API_KEY",
"KEY",
"_KEY",
]
# Health specific constants
HEALTH_URL = "/station/health"
# PWS specific constants
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
]
REMAP_ITEMS: dict[str, str] = {
"baromin": .channels.BARO_PRESSURE,
"tempf": OUTSIDE_TEMP,
"dewptf": DEW_POINT,
"humidity": OUTSIDE_HUMIDITY,
"windspeedmph": WIND_SPEED,
"windgustmph": WIND_GUST,
"winddir": WIND_DIR,
"rainin": RAIN,
"dailyrainin": DAILY_RAIN,
"solarradiation": SOLAR_RADIATION,
"indoortempf": INDOOR_TEMP,
"indoorhumidity": INDOOR_HUMIDITY,
"UV": UV,
"soiltempf": CH2_TEMP,
"soilmoisture": CH2_HUMIDITY,
"soiltemp2f": CH3_TEMP,
"soilmoisture2": CH3_HUMIDITY,
"soiltemp3f": CH4_TEMP,
"soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY,
}
WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
WSLINK: Final = "wslink"
WINDY_MAX_RETRIES: Final = 3
@ -141,30 +207,7 @@ WINDY_UNEXPECTED: Final = (
"Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
)
INVALID_CREDENTIALS: Final = [
"API",
"API_ID",
"API ID",
"_ID",
"ID",
"API KEY",
"API_KEY",
"KEY",
"_KEY",
]
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
]
PURGE_DATA_POCAS: Final = [
"ID",
@ -174,83 +217,41 @@ PURGE_DATA_POCAS: Final = [
]
BARO_PRESSURE: Final = "baro_pressure"
OUTSIDE_TEMP: Final = "outside_temp"
DEW_POINT: Final = "dew_point"
OUTSIDE_HUMIDITY: Final = "outside_humidity"
OUTSIDE_CONNECTION: Final = "outside_connection"
OUTSIDE_BATTERY: Final = "outside_battery"
WIND_SPEED: Final = "wind_speed"
WIND_GUST: Final = "wind_gust"
WIND_DIR: Final = "wind_dir"
WIND_AZIMUT: Final = "wind_azimut"
RAIN: Final = "rain"
HOURLY_RAIN: Final = "hourly_rain"
WEEKLY_RAIN: Final = "weekly_rain"
MONTHLY_RAIN: Final = "monthly_rain"
YEARLY_RAIN: Final = "yearly_rain"
DAILY_RAIN: Final = "daily_rain"
SOLAR_RADIATION: Final = "solar_radiation"
INDOOR_TEMP: Final = "indoor_temp"
INDOOR_HUMIDITY: Final = "indoor_humidity"
INDOOR_BATTERY: Final = "indoor_battery"
UV: Final = "uv"
CH2_TEMP: Final = "ch2_temp"
CH2_HUMIDITY: Final = "ch2_humidity"
CH2_CONNECTION: Final = "ch2_connection"
CH2_BATTERY: Final = "ch2_battery"
CH3_TEMP: Final = "ch3_temp"
CH3_HUMIDITY: Final = "ch3_humidity"
CH3_CONNECTION: Final = "ch3_connection"
CH3_BATTERY: Final = "ch3_battery"
CH4_TEMP: Final = "ch4_temp"
CH4_HUMIDITY: Final = "ch4_humidity"
CH4_CONNECTION: Final = "ch4_connection"
CH4_BATTERY: Final = "ch4_battery"
CH5_TEMP: Final = "ch5_temp"
CH5_HUMIDITY: Final = "ch5_humidity"
CH5_CONNECTION: Final = "ch5_connection"
CH5_BATTERY: Final = "ch5_battery"
CH6_TEMP: Final = "ch6_temp"
CH6_HUMIDITY: Final = "ch6_humidity"
CH6_CONNECTION: Final = "ch6_connection"
CH6_BATTERY: Final = "ch6_battery"
CH7_TEMP: Final = "ch7_temp"
CH7_HUMIDITY: Final = "ch7_humidity"
CH7_CONNECTION: Final = "ch7_connection"
CH7_BATTERY: Final = "ch7_battery"
CH8_TEMP: Final = "ch8_temp"
CH8_HUMIDITY: Final = "ch8_humidity"
CH8_CONNECTION: Final = "ch8_connection"
CH8_BATTERY: Final = "ch8_battery"
HEAT_INDEX: Final = "heat_index"
CHILL_INDEX: Final = "chill_index"
WBGT_TEMP: Final = "wbgt_temp"
REMAP_ITEMS: dict[str, str] = {
"baromin": BARO_PRESSURE,
"tempf": OUTSIDE_TEMP,
"dewptf": DEW_POINT,
"humidity": OUTSIDE_HUMIDITY,
"windspeedmph": WIND_SPEED,
"windgustmph": WIND_GUST,
"winddir": WIND_DIR,
"rainin": RAIN,
"dailyrainin": DAILY_RAIN,
"solarradiation": SOLAR_RADIATION,
"indoortempf": INDOOR_TEMP,
"indoorhumidity": INDOOR_HUMIDITY,
"UV": UV,
"soiltempf": CH2_TEMP,
"soilmoisture": CH2_HUMIDITY,
"soiltemp2f": CH3_TEMP,
"soilmoisture2": CH3_HUMIDITY,
"soiltemp3f": CH4_TEMP,
"soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY,
}
"""NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
I have no option to test, if it will work correctly. So their implementatnion will be in future releases.
leafwetness - [%]
+ for sensor 2 use leafwetness2
visibility - [nm visibility]
pweather - [text] -- metar style (+RA)
clouds - [text] -- SKC, FEW, SCT, BKN, OVC
Pollution Fields:
AqNO - [ NO (nitric oxide) ppb ]
AqNO2T - (nitrogen dioxide), true measure ppb
AqNO2 - NO2 computed, NOx-NO ppb
AqNO2Y - NO2 computed, NOy-NO ppb
AqNOX - NOx (nitrogen oxides) - ppb
AqNOY - NOy (total reactive nitrogen) - ppb
AqNO3 - NO3 ion (nitrate, not adjusted for ammonium ion) UG/M3
AqSO4 - SO4 ion (sulfate, not adjusted for ammonium ion) UG/M3
AqSO2 - (sulfur dioxide), conventional ppb
AqSO2T - trace levels ppb
AqCO - CO (carbon monoxide), conventional ppm
AqCOT -CO trace levels ppb
AqEC - EC (elemental carbon) PM2.5 UG/M3
AqOC - OC (organic carbon, not adjusted for oxygen and hydrogen) PM2.5 UG/M3
AqBC - BC (black carbon at 880 nm) UG/M3
AqUV-AETH - UV-AETH (second channel of Aethalometer at 370 nm) UG/M3
AqPM2.5 - PM2.5 mass - UG/M3
AqPM10 - PM10 mass - PM10 mass
AqOZONE - Ozone - ppb
"""
REMAP_WSLINK_ITEMS: dict[str, str] = {
"intem": INDOOR_TEMP,
@ -306,12 +307,64 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1wbgt": WBGT_TEMP,
}
# TODO: Add more sensors
# NOTE: Add more sensors
#
# 'inbat' indoor battery level (1 normal, 0 low)
# 't1bat': outdoor battery level (1 normal, 0 low)
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLin
#
# In the following there are sensors that should be available by WSLink.
# We need to compare them to PWS API to make sure, we have the same intarnal
# representation of same sensors.
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
# &t5lst= Last Lightning strike time integer
# &t5lskm= Lightning distance integer km
# &t5lsf= Lightning strike count last 1 Hours integer
# &t5ls5mtc= Lightning count total of during 5 minutes integer
# &t5ls30mtc= Lightning count total of during 30 minutes integer
# &t5ls1htc= Lightning count total of during 1 Hour integer
# &t5ls1dtc= Lightning count total of during 1 day integer
# &t5lsbat= Lightning Sensor battery (Normal=1, Low battery=0) integer
# &t5lscn= Lightning Sensor connection (Connected=1, No connect=0) integer
# &t6c1wls= Water leak sensor CH1 (Leak=1, No leak=0) integer
# &t6c1bat= Water leak sensor CH1 battery (Normal=1, Low battery=0) integer
# &t6c1cn= Water leak sensor CH1 connection (Connected=1, No connect=0) integer
# &t6c2wls= Water leak sensor CH2 (Leak=1, No leak=0) integer
# &t6c2bat= Water leak sensor CH2 battery (Normal=1, Low battery=0) integer
# &t6c2cn= Water leak sensor CH2 connection (Connected=1, No connect=0) integer
# &t6c3wls= Water leak sensor CH3 (Leak=1, No leak=0) integer
# &t6c3bat= Water leak sensor CH3 battery (Normal=1, Low battery=0) integer
# &t6c3cn= Water leak sensor CH3 connection (Connected=1, No connect=0) integer
# &t6c4wls= Water leak sensor CH4 (Leak=1, No leak=0) integer
# &t6c4bat= Water leak sensor CH4 battery (Normal=1, Low battery=0) integer
# &t6c4cn= Water leak sensor CH4 connection (Connected=1, No connect=0) integer
# &t6c5wls= Water leak sensor CH5 (Leak=1, No leak=0) integer
# &t6c5bat= Water leak sensor CH5 battery (Normal=1, Low battery=0) integer
# &t6c5cn= Water leak sensor CH5 connection (Connected=1, No connect=0) integer
# &t6c6wls= Water leak sensor CH6 (Leak=1, No leak=0) integer
# &t6c6bat= Water leak sensor CH6 battery (Normal=1, Low battery=0) integer
# &t6c6cn= Water leak sensor CH6 connection (Connected=1, No connect=0) integer
# &t6c7wls= Water leak sensor CH7 (Leak=1, No leak=0) integer
# &t6c7bat= Water leak sensor CH7 battery (Normal=1, Low battery=0) integer
# &t6c7cn= Water leak sensor CH7 connection (Connected=1, No connect=0) integer
# &t8pm25= PM2.5 concentration integer ug/m3
# &t8pm10= PM10 concentration integer ug/m3
# &t8pm25ai= PM2.5 AQI integer
# &t8pm10ai = PM10 AQI integer
# &t8bat= PM sensor battery level (0~5) remark: 5 is full integer
# &t8cn= PM sensor connection (Connected=1, No connect=0) integer
# &t9hcho= HCHO concentration integer ppb
# &t9voclv= VOC level (1~5) 1 is the highest level, 5 is the lowest VOC level integer
# &t9bat= HCHO / VOC sensor battery level (0~5) remark: 5 is full integer
# &t9cn= HCHO / VOC sensor connection (Connected=1, No connect=0) integer
# &t10co2= CO2 concentration integer ppm
# &t10bat= CO2 sensor battery level (0~5) remark: 5 is full integer
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
# &t11co= CO concentration integer ppm
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
# &t11cn= CO sensor connection (Connected=1, No connect=0) integer
#
DISABLED_BY_DEFAULT: Final = [
CH2_TEMP,

View File

@ -116,9 +116,9 @@ class Routes:
for route in self.routes.values()
if route.enabled
}
return ", ".join(
sorted(enabled_routes) if enabled_routes else "No routes are enabled."
)
if not enabled_routes:
return "No routes are enabled."
return ", ".join(sorted(enabled_routes))
async def unregistered(request: Request) -> Response:

View File

@ -40,7 +40,7 @@ async def test_async_setup_entry_creates_runtime_state(
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _entry: True,
lambda _hass, _coordinator, _coordinator_h, _entry: True,
)
# Avoid depending on Home Assistant integration loader in this test.
@ -69,7 +69,7 @@ async def test_async_setup_entry_forwards_sensor_platform(
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _entry: True,
lambda _hass, _coordinator, _coordinator_h, _entry: True,
)
# Patch forwarding so we don't need to load real platforms for this unit/integration test.

View File

@ -10,6 +10,7 @@ import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import (
HealthCoordinator,
IncorrectDataError,
WeatherDataUpdateCoordinator,
async_setup_entry,
@ -22,6 +23,7 @@ from custom_components.sws12500.const import (
API_KEY,
DEFAULT_URL,
DOMAIN,
HEALTH_URL,
SENSORS_TO_LOAD,
WSLINK,
WSLINK_URL,
@ -35,6 +37,9 @@ class _RequestStub:
query: dict[str, Any]
async def post(self) -> dict[str, Any]:
return {}
class _RouterStub:
"""Router stub that records route registrations."""
@ -44,17 +49,17 @@ class _RouterStub:
self.add_post_calls: list[tuple[str, Any]] = []
self.raise_on_add: Exception | None = None
def add_get(self, path: str, handler: Any) -> Any:
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_get_calls.append((path, handler))
return object()
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any) -> Any:
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_post_calls.append((path, handler))
return object()
return SimpleNamespace(method="POST")
@pytest.fixture
@ -79,13 +84,18 @@ async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_ht
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
ok = register_path(hass_with_http, coordinator, entry)
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
assert ok is True
# Router registrations
router: _RouterStub = hass_with_http.http.app.router
assert [p for (p, _h) in router.add_get_calls] == [DEFAULT_URL]
assert [p for (p, _h) in router.add_get_calls] == [
DEFAULT_URL,
WSLINK_URL,
HEALTH_URL,
]
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL]
# Dispatcher stored
@ -115,13 +125,14 @@ async def test_register_path_raises_config_entry_not_ready_on_router_runtime_err
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
# Make router raise RuntimeError on add
router: _RouterStub = hass_with_http.http.app.router
router.raise_on_add = RuntimeError("router broken")
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
@pytest.mark.asyncio
@ -143,12 +154,13 @@ async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_no
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
# Force wrong type under DOMAIN so `checked(..., dict)` fails.
hass_with_http.data[DOMAIN] = []
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
@pytest.mark.asyncio
@ -213,7 +225,7 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
# Force register_path to return False
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _entry: False,
lambda _hass, _coordinator, _coordinator_h, _entry: False,
)
# Forwarding shouldn't be reached; patch anyway to avoid accidental loader calls.
@ -251,7 +263,8 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes
routes = hass_with_http.data[DOMAIN].get("routes")
if routes is None:
# Create a dispatcher via register_path once
register_path(hass_with_http, existing_coordinator, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, existing_coordinator, coordinator_health, entry)
routes = hass_with_http.data[DOMAIN]["routes"]
# Turn on WSLINK to trigger dispatcher switching.

View File

@ -26,10 +26,14 @@ from custom_components.sws12500.const import (
class _RequestStub:
"""Minimal aiohttp Request stub.
The coordinator only uses `webdata.query` (a mapping of query parameters).
The coordinator uses `webdata.query` and `await webdata.post()`.
"""
query: dict[str, Any]
post_data: dict[str, Any] | None = None
async def post(self) -> dict[str, Any]:
return self.post_data or {}
def _make_entry(

View File

@ -15,26 +15,34 @@ Handler = Callable[["_RequestStub"], Awaitable[Response]]
class _RequestStub:
"""Minimal request stub for unit-testing the dispatcher.
`Routes.dispatch` relies on `request.path`.
`Routes.dispatch` relies on `request.method` and `request.path`.
`unregistered` accepts a request object but does not use it.
"""
method: str
path: str
@dataclass(slots=True)
class _RouteStub:
"""Minimal route stub providing `method` expected by Routes.add_route`."""
method: str
@pytest.fixture
def routes() -> Routes:
return Routes()
async def test_dispatch_unknown_path_calls_unregistered(routes: Routes) -> None:
request = _RequestStub(path="/unregistered")
request = _RequestStub(method="GET", path="/unregistered")
response = await routes.dispatch(request) # type: ignore[arg-type]
assert response.status == 400
async def test_unregistered_handler_returns_400() -> None:
request = _RequestStub(path="/invalid")
request = _RequestStub(method="GET", path="/invalid")
response = await unregistered(request) # type: ignore[arg-type]
assert response.status == 400
@ -43,9 +51,9 @@ async def test_dispatch_registered_but_disabled_uses_fallback(routes: Routes) ->
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", handler, enabled=False)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
response = await routes.dispatch(_RequestStub(path="/a")) # type: ignore[arg-type]
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 400
@ -53,9 +61,9 @@ async def test_dispatch_registered_and_enabled_uses_handler(routes: Routes) -> N
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=201)
routes.add_route("/a", handler, enabled=True)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=True)
response = await routes.dispatch(_RequestStub(path="/a")) # type: ignore[arg-type]
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 201
@ -66,32 +74,32 @@ def test_switch_route_enables_exactly_one(routes: Routes) -> None:
async def handler_b(_request: _RequestStub) -> Response:
return Response(text="B", status=200)
routes.add_route("/a", handler_a, enabled=True)
routes.add_route("/b", handler_b, enabled=False)
routes.add_route("/a", _RouteStub(method="GET"), handler_a, enabled=True)
routes.add_route("/b", _RouteStub(method="GET"), handler_b, enabled=False)
routes.switch_route("/b")
routes.switch_route(handler_b, "/b")
assert routes.routes["/a"].enabled is False
assert routes.routes["/b"].enabled is True
assert routes.routes["GET:/a"].enabled is False
assert routes.routes["GET:/b"].enabled is True
def test_show_enabled_returns_message_when_none_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", handler, enabled=False)
routes.add_route("/b", handler, enabled=False)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=False)
assert routes.show_enabled() == "No routes is enabled."
assert routes.show_enabled() == "No routes are enabled."
def test_show_enabled_includes_url_when_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", handler, enabled=False)
routes.add_route("/b", handler, enabled=True)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=True)
msg = routes.show_enabled()
assert "Dispatcher enabled for URL: /b" in msg
assert "Dispatcher enabled for (GET):/b" in msg
assert "handler" in msg

View File

@ -51,10 +51,7 @@ def test_native_value_prefers_value_from_data_fn_success():
def test_native_value_value_from_data_fn_success_with_dev_logging_hits_computed_debug_branch(
monkeypatch,
):
"""Cover the dev-log debug branch after successful value_from_data_fn computation."""
debug = MagicMock()
monkeypatch.setattr("custom_components.sws12500.sensor._LOGGER.debug", debug)
"""Ensure value_from_data_fn works with dev logging enabled."""
desc = _DescriptionStub(
key="derived",
value_from_data_fn=lambda data: data["x"] + 1,
@ -65,12 +62,6 @@ def test_native_value_value_from_data_fn_success_with_dev_logging_hits_computed_
assert entity.native_value == 42
debug.assert_any_call(
"native_value computed via value_from_data_fn: key=%s -> %s",
"derived",
42,
)
def test_native_value_value_from_data_fn_exception_returns_none():
def boom(_data: dict[str, Any]) -> Any:

View File

@ -22,8 +22,8 @@ from custom_components.sws12500.const import (
WINDY_URL,
)
from custom_components.sws12500.windy_func import (
WindyApiKeyError,
WindyNotInserted,
WindyPasswordMissing,
WindyPush,
WindySuccess,
)
@ -31,7 +31,8 @@ from custom_components.sws12500.windy_func import (
@dataclass(slots=True)
class _FakeResponse:
text_value: str
status: int
text_value: str = ""
async def text(self) -> str:
return self.text_value
@ -87,20 +88,19 @@ def _make_entry(**options: Any):
def test_verify_windy_response_notice_raises_not_inserted(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyNotInserted):
wp.verify_windy_response("NOTICE: something")
wp.verify_windy_response(_FakeResponse(status=400, text_value="Bad Request"))
def test_verify_windy_response_success_raises_success(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindySuccess):
wp.verify_windy_response("SUCCESS")
wp.verify_windy_response(_FakeResponse(status=200, text_value="OK"))
@pytest.mark.parametrize("msg", ["Invalid API key", "Unauthorized"])
def test_verify_windy_response_api_key_errors_raise(msg, hass):
def test_verify_windy_response_password_missing_raises(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyApiKeyError):
wp.verify_windy_response(msg)
with pytest.raises(WindyPasswordMissing):
wp.verify_windy_response(_FakeResponse(status=401, text_value="Unauthorized"))
def test_covert_wslink_to_pws_maps_keys(hass):
@ -155,7 +155,7 @@ async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(response=_FakeResponse("SUCCESS")),
lambda _h: _FakeSession(response=_FakeResponse(status=200, text_value="OK")),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
@ -169,7 +169,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
# Force it to send now
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("SUCCESS"))
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -200,7 +200,7 @@ async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("SUCCESS"))
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -221,12 +221,21 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("SUCCESS"))
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
@ -239,12 +248,21 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("SUCCESS"))
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
@ -256,8 +274,10 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
# Response triggers WindyApiKeyError
session = _FakeSession(response=_FakeResponse("Invalid API key"))
# Response triggers WindyPasswordMissing (401)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -267,6 +287,10 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
@ -281,7 +305,9 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("Unauthorized"))
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -294,6 +320,10 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
@ -307,7 +337,7 @@ async def test_push_data_to_windy_notice_logs_not_inserted(monkeypatch, hass):
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("NOTICE: no insert"))
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -330,7 +360,7 @@ async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
wp = WindyPush(hass, entry)
wp.next_update = datetime.now() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("SUCCESS"))
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -363,7 +393,7 @@ async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_l
wp.next_update = datetime.now() - timedelta(seconds=1)
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
session = _FakeSession(response=_FakeResponse("OK"))
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
@ -392,6 +422,10 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
# Cause ClientError on session.get
session = _FakeSession(exc=ClientError("boom"))
@ -434,6 +468,10 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(