Compare commits
9 Commits
2f7d96f8d3
...
bf6a02c5b3
| Author | SHA1 | Date |
|---|---|---|
|
|
bf6a02c5b3 | |
|
|
b3aec61fc2 | |
|
|
acda94bb03 | |
|
|
50d0e9fae7 | |
|
|
25341f79b3 | |
|
|
b72efa5985 | |
|
|
e20c23a3af | |
|
|
b777296b52 | |
|
|
e3d712bd54 |
|
|
@ -167,6 +167,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
|
|||
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
||||
routes.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL, enabled=_legacy)
|
||||
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.received_ecowitt_data, _ecowitt_enabled)
|
||||
# Rebind the sticky health route to the new coordinator so /station/health
|
||||
# does not keep serving the previous (stale) HealthCoordinator after a reload.
|
||||
routes.rebind_handler(HEALTH_URL, coordinator_health.health_status)
|
||||
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
||||
coordinator_health.update_routing(routes)
|
||||
_LOGGER.debug("%s", routes.show_enabled())
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
|
||||
self.wslink_addon_port = {WSLINK_ADDON_PORT: self.config_entry.options.get(WSLINK_ADDON_PORT, 443)}
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] = {}):
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||
"""Manage the options - show menu first."""
|
||||
_ = user_input
|
||||
return self.async_show_menu(
|
||||
|
|
@ -296,7 +296,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
await self._get_entry_data()
|
||||
|
||||
if not (port := self.wslink_addon_port.get(WSLINK_ADDON_PORT)):
|
||||
port = 433
|
||||
port = 443
|
||||
|
||||
wslink_port_schema = {
|
||||
vol.Required(WSLINK_ADDON_PORT, default=port): int,
|
||||
|
|
|
|||
|
|
@ -262,14 +262,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
|
|||
"t1uvi": UV,
|
||||
"t234c1tem": CH2_TEMP,
|
||||
"t234c1hum": CH2_HUMIDITY,
|
||||
"t1cn": OUTSIDE_CONNECTION,
|
||||
"t234c1cn": CH2_CONNECTION,
|
||||
"t234c2cn": CH3_CONNECTION,
|
||||
"t234c3cn": CH4_CONNECTION,
|
||||
"t234c4cn": CH5_CONNECTION,
|
||||
"t234c5cn": CH6_CONNECTION,
|
||||
"t234c6cn": CH7_CONNECTION,
|
||||
"t234c7cn": CH8_CONNECTION,
|
||||
# NOTE: connection flags (t1cn / t234cXcn / t9cn) are intentionally NOT remapped.
|
||||
# They are used only as gating inputs (see CONNECTION_GATED_SENSORS), which read the
|
||||
# raw payload keys. Remapping them used to leak ghost "*_connection" keys (with no
|
||||
# entity) into the coordinator data and into persisted SENSORS_TO_LOAD.
|
||||
"t1chill": CHILL_INDEX,
|
||||
"t1heat": HEAT_INDEX,
|
||||
"t1rainhr": HOURLY_RAIN,
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
||||
self.ecowitt_bridge: EcowittBridge = EcowittBridge(hass, config)
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||
super().__init__(hass, _LOGGER, config_entry=config, name=DOMAIN)
|
||||
|
||||
def _health_coordinator(self) -> HealthCoordinator | None:
|
||||
"""Return the health coordinator for this config entry."""
|
||||
|
|
@ -115,7 +115,10 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
expected_webhook = self.config.options.get(ECOWITT_WEBHOOK_ID, "")
|
||||
actual_webhook = webdata.match_info.get("webhook_id", "")
|
||||
|
||||
if not expected_webhook or actual_webhook != expected_webhook:
|
||||
# Constant-time comparison to avoid leaking the webhook id via timing.
|
||||
if not expected_webhook or not hmac.compare_digest(
|
||||
actual_webhook.encode("utf-8"), expected_webhook.encode("utf-8")
|
||||
):
|
||||
_LOGGER.error("Ecowitt: invalid webhook ID")
|
||||
if health:
|
||||
health.update_ingress_result(
|
||||
|
|
@ -260,32 +263,21 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
|
||||
|
||||
if sensors := check_disabled(remaped_items, self.config):
|
||||
if (
|
||||
translate_sensors := checked(
|
||||
[
|
||||
await translations(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"sensor.{t_key}",
|
||||
key="name",
|
||||
category="entity",
|
||||
)
|
||||
for t_key in sensors
|
||||
if await translations(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"sensor.{t_key}",
|
||||
key="name",
|
||||
category="entity",
|
||||
)
|
||||
is not None
|
||||
],
|
||||
list[str],
|
||||
# Resolve each sensor's display name once (the previous comprehension
|
||||
# awaited translations() twice per key).
|
||||
translated_sensors: list[str] = []
|
||||
for t_key in sensors:
|
||||
name = await translations(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"sensor.{t_key}",
|
||||
key="name",
|
||||
category="entity",
|
||||
)
|
||||
) is not None:
|
||||
human_readable: str = "\n".join(translate_sensors)
|
||||
else:
|
||||
human_readable = ""
|
||||
if name is not None:
|
||||
translated_sensors.append(name)
|
||||
|
||||
human_readable = "\n".join(translated_sensors)
|
||||
|
||||
await translated_notification(
|
||||
self.hass,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,21 @@ from typing import Any
|
|||
from homeassistant.components.diagnostics import async_redact_data # pyright: ignore[reportUnknownVariableType]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import API_ID, API_KEY, POCASI_CZ_API_ID, POCASI_CZ_API_KEY, WINDY_STATION_ID, WINDY_STATION_PW
|
||||
from .const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
WINDY_STATION_ID,
|
||||
WINDY_STATION_PW,
|
||||
)
|
||||
from .data import SWSConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
API_ID,
|
||||
API_KEY,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
WINDY_STATION_ID,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,8 @@ class EcoWittNativeSensor(SensorEntity):
|
|||
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||
self._attr_name = sensor.name # default name, can be overridden by translation_key if we had one
|
||||
|
||||
# set HomeAssistant metadata from aioecowitt sensor type
|
||||
# set HomeAssistant metadata from aioecowitt sensor type.
|
||||
# Unknown types still get a usable entity (raw value, no device class/unit).
|
||||
ha_meta = STYPE_TO_HA.get(sensor.stype)
|
||||
if ha_meta:
|
||||
device_class, unit, state_class = ha_meta
|
||||
|
|
@ -259,15 +260,17 @@ class EcoWittNativeSensor(SensorEntity):
|
|||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_state_class = state_class
|
||||
|
||||
station = sensor.station
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections=set(),
|
||||
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
|
||||
manufacturer="Ecowitt impl. from Schizza for SWS12500",
|
||||
model=station.model if station else None,
|
||||
)
|
||||
# Always attach device info so the entity is grouped under the Ecowitt
|
||||
# station device even when its sensor type has no HA mapping.
|
||||
station = sensor.station
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections=set(),
|
||||
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
|
||||
manufacturer="Ecowitt impl. from Schizza for SWS12500",
|
||||
model=station.model if station else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ from typing import Any
|
|||
import aiohttp
|
||||
from aiohttp import ClientConnectionError
|
||||
import aiohttp.web
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
from py_typecheck import checked, checked_or
|
||||
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED
|
||||
from homeassistant.components.network import async_get_source_ip
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
|
@ -69,6 +71,18 @@ def _protocol_from_path(path: str) -> str:
|
|||
return "unknown"
|
||||
|
||||
|
||||
def _sanitize_path(path: str) -> str:
|
||||
"""Strip the secret Ecowitt webhook id from a path before storing/exposing it.
|
||||
|
||||
The Ecowitt endpoint is `/weatherhub/<webhook_id>` where the id is the only
|
||||
credential. Keeping the raw path in the health snapshot would leak it via the
|
||||
health endpoint and diagnostics, so mask the id segment.
|
||||
"""
|
||||
if path.startswith(ECOWITT_URL_PREFIX + "/"):
|
||||
return ECOWITT_URL_PREFIX + "/***"
|
||||
return path
|
||||
|
||||
|
||||
def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
|
||||
"""Build the default forwarding status payload."""
|
||||
return {
|
||||
|
|
@ -144,6 +158,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
config_entry=config,
|
||||
name=f"{DOMAIN}_health",
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
|
|
@ -268,7 +283,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
data["last_ingress"] = {
|
||||
"time": dt_util.utcnow().isoformat(),
|
||||
"protocol": _protocol_from_path(request.path),
|
||||
"path": request.path,
|
||||
"path": _sanitize_path(request.path),
|
||||
"method": request.method,
|
||||
"route_enabled": route_enabled,
|
||||
"accepted": False,
|
||||
|
|
@ -293,7 +308,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
{
|
||||
"time": dt_util.utcnow().isoformat(),
|
||||
"protocol": _protocol_from_path(request.path),
|
||||
"path": request.path,
|
||||
"path": _sanitize_path(request.path),
|
||||
"method": request.method,
|
||||
"accepted": accepted,
|
||||
"authorized": authorized,
|
||||
|
|
@ -325,11 +340,23 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
self._refresh_summary(data)
|
||||
self._commit(data)
|
||||
|
||||
async def health_status(self, _: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
async def health_status(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
"""Serve the current health snapshot over HTTP.
|
||||
|
||||
Requires Home Assistant authentication. The route is registered directly on
|
||||
the aiohttp router (so it can share the dispatcher), which means HA's auth
|
||||
middleware only *flags* the request - it does not block it. We therefore
|
||||
enforce auth here so the snapshot (internal URLs/IPs, add-on status, last
|
||||
ingress) is never exposed to unauthenticated callers.
|
||||
|
||||
Auth is satisfied by a valid bearer token or a signed request, the same as
|
||||
any HomeAssistantView.
|
||||
|
||||
The endpoint forces one refresh before returning so that the caller sees
|
||||
a reasonably fresh add-on status.
|
||||
"""
|
||||
if not request.get(KEY_AUTHENTICATED, False):
|
||||
raise HTTPUnauthorized
|
||||
|
||||
await self.async_request_refresh()
|
||||
return aiohttp.web.json_response(self.data, status=200)
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ class PocasiPush:
|
|||
self.last_error = str(ex)
|
||||
_LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex))
|
||||
self.invalid_response_count += 1
|
||||
if self.invalid_response_count > 3:
|
||||
if self.invalid_response_count >= 3:
|
||||
_LOGGER.critical(POCASI_CZ_UNEXPECTED)
|
||||
self.enabled = False
|
||||
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ class Routes:
|
|||
if key in self.routes:
|
||||
return self.routes[key]
|
||||
|
||||
resource = request.match_info.route.resource
|
||||
# Fallback to the aiohttp resource canonical URL (for routes with a path
|
||||
# parameter such as {webhook_id}). Resolve defensively: a request without
|
||||
# match_info/route/resource simply has no canonical match.
|
||||
match_info = getattr(request, "match_info", None)
|
||||
route = getattr(match_info, "route", None)
|
||||
resource = getattr(route, "resource", None)
|
||||
if resource is not None:
|
||||
canonical_key = f"{request.method}:{resource.canonical}"
|
||||
if canonical_key in self.routes:
|
||||
|
|
@ -103,6 +108,24 @@ class Routes:
|
|||
)
|
||||
return
|
||||
|
||||
def rebind_handler(self, url_path: str, handler: Handler) -> None:
|
||||
"""Repoint an always-on sticky route to a new handler after a reload.
|
||||
|
||||
Sticky routes (e.g. health) stay enabled across reloads, but their stored
|
||||
handler is a bound method tied to a specific coordinator instance. When the
|
||||
integration reloads, a new coordinator is created, so the handler must be
|
||||
repointed - otherwise the route keeps calling the old (stale) instance.
|
||||
|
||||
Unlike `set_ecowitt_enabled`, this never changes `enabled`; it only rebinds
|
||||
currently enabled sticky routes for `url_path`.
|
||||
"""
|
||||
|
||||
for route in self.routes.values():
|
||||
if route.url_path == url_path and route.sticky and route.enabled:
|
||||
route.handler = handler
|
||||
_LOGGER.debug("Rebound sticky route handler for %s", url_path)
|
||||
return
|
||||
|
||||
def set_ingress_observer(self, observer: IngressObserver | None) -> None:
|
||||
"""Set a callback notified for every incoming dispatcher request."""
|
||||
self._ingress_observer = observer
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ async def async_setup_entry(
|
|||
runtime.add_sensor_entities = async_add_entities
|
||||
runtime.sensor_descriptions = {desc.key: desc for desc in sensor_types}
|
||||
|
||||
# Connect Ecowitt bridge to sensor platform so it can dynamically add
|
||||
# native Ecowitt entities (sensors without internal SWS mapping).
|
||||
coordinator.ecowitt_bridge.set_add_entities(async_add_entities)
|
||||
|
||||
sensors_to_load = checked_or(config_entry.options.get(SENSORS_TO_LOAD), list[str], [])
|
||||
if not sensors_to_load:
|
||||
return
|
||||
|
|
@ -114,10 +118,6 @@ async def async_setup_entry(
|
|||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
# Connect Ecowitt bridge to sensor platform so it can dynamically add
|
||||
# native Ecowitt entities (sensors without internal SWS mapping).
|
||||
coordinator.ecowitt_bridge.set_add_entities(async_add_entities)
|
||||
|
||||
|
||||
def add_new_sensors(hass: HomeAssistant, config_entry: SWSConfigEntry, keys: list[str]) -> None:
|
||||
"""Dynamically add newly discovered sensors without reloading the entry.
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
WeatherSensorEntityDescription(
|
||||
key=WIND_AZIMUT,
|
||||
icon="mdi:sign-direction",
|
||||
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR, 0.0)),
|
||||
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfDir],
|
||||
translation_key=WIND_AZIMUT,
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
WeatherSensorEntityDescription(
|
||||
key=WIND_AZIMUT,
|
||||
icon="mdi:sign-direction",
|
||||
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR, 0.0)),
|
||||
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[e.value for e in UnitOfDir],
|
||||
translation_key=WIND_AZIMUT,
|
||||
|
|
@ -551,39 +551,4 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
suggested_display_precision=0,
|
||||
value_fn=battery_5step_to_pct,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=HCHO,
|
||||
translation_key=HCHO,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
value_fn=to_int,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=HCHO,
|
||||
translation_key=HCHO,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
value_fn=to_int,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=VOC,
|
||||
translation_key=VOC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(VOCLevel),
|
||||
icon="mdi:air-filter",
|
||||
value_from_data_fn=lambda data: voc_level_to_text(data.get(VOC, None)),
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=T9_BATTERY,
|
||||
translation_key=T9_BATTERY,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=battery_5step_to_pct,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -188,18 +188,23 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str
|
|||
return missing_sensors if entityFound else None
|
||||
|
||||
|
||||
def wind_dir_to_text(deg: float) -> UnitOfDir | None:
|
||||
def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
|
||||
"""Return wind direction in text representation.
|
||||
|
||||
Accepts the raw payload value (str), a float, or None. A direction of 0 - or
|
||||
a missing/invalid value - is treated as "no reading" (calm) and returns None,
|
||||
so a missing wind direction does not render as North.
|
||||
|
||||
Returns UnitOfDir or None
|
||||
"""
|
||||
|
||||
_deg = to_float(deg)
|
||||
if _deg is not None:
|
||||
_LOGGER.debug("wind_dir: %s", AZIMUT[int(abs((_deg - 11.25) % 360) / 22.5)])
|
||||
return AZIMUT[int(abs((_deg - 11.25) % 360) / 22.5)]
|
||||
if _deg is None or _deg == 0:
|
||||
return None
|
||||
|
||||
return None
|
||||
azimut = AZIMUT[int(abs((_deg - 11.25) % 360) / 22.5)]
|
||||
_LOGGER.debug("wind_dir: %s", azimut)
|
||||
return azimut
|
||||
|
||||
|
||||
def battery_level(battery: int | str | None) -> UnitOfBat:
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ class WindyPush:
|
|||
"""Verify answer form Windy."""
|
||||
|
||||
if self.log and response:
|
||||
_LOGGER.info("Windy raw response: %s", response.text)
|
||||
# response.text is a coroutine; we are in a sync method here, so log the
|
||||
# status instead of awaiting/logging a bound method object.
|
||||
_LOGGER.info("Windy raw response status: %s", response.status)
|
||||
|
||||
if response.status == 200:
|
||||
raise WindySuccess
|
||||
|
|
@ -270,8 +272,10 @@ class WindyPush:
|
|||
else:
|
||||
self.last_status = "unexpected_response"
|
||||
self.last_error = "Unexpected response from Windy."
|
||||
# Always count unexpected responses toward the disable threshold,
|
||||
# regardless of the logging setting.
|
||||
self.invalid_response_count += 1
|
||||
if self.log:
|
||||
self.invalid_response_count += 1
|
||||
_LOGGER.debug(
|
||||
"Unexpected response from Windy. Max retries before disabling resend function: %s",
|
||||
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
"""Tests for the binary sensor platform and battery binary sensor entity.
|
||||
|
||||
Covers:
|
||||
- `binary_sensor.async_setup_entry` (with and without battery keys in SENSORS_TO_LOAD)
|
||||
- `binary_sensor.add_new_binary_sensors` (no-op / dedupe / unknown / new)
|
||||
- `battery_sensors.BatteryBinarySensor` (`is_on` value mapping + `device_info`)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.battery_sensors import BatteryBinarySensor
|
||||
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS
|
||||
from custom_components.sws12500.binary_sensor import add_new_binary_sensors, async_setup_entry
|
||||
from custom_components.sws12500.const import CH2_BATTERY, DOMAIN, INDOOR_BATTERY, OUTSIDE_BATTERY, SENSORS_TO_LOAD
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
|
||||
|
||||
class _CoordinatorStub:
|
||||
"""Minimal coordinator stub: CoordinatorEntity only stores it, and `is_on` reads `.data`."""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if data is not None else {}
|
||||
|
||||
|
||||
def _make_entry(
|
||||
*, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None
|
||||
) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]:
|
||||
"""Build a config-entry stub carrying typed runtime_data, like the integration does."""
|
||||
coordinator = coordinator or _CoordinatorStub()
|
||||
runtime = SWSRuntimeData(
|
||||
coordinator=coordinator, # type: ignore[arg-type]
|
||||
health_coordinator=object(), # type: ignore[arg-type]
|
||||
last_options={},
|
||||
)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="test_entry_id",
|
||||
options=options if options is not None else {},
|
||||
runtime_data=runtime,
|
||||
)
|
||||
return entry, coordinator, runtime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hass():
|
||||
# binary_sensor platform setup deletes hass; add_new_binary_sensors also deletes it.
|
||||
return object()
|
||||
|
||||
|
||||
def _capture_add_entities():
|
||||
captured: list[Any] = []
|
||||
|
||||
def _add_entities(entities: list[Any]) -> None:
|
||||
captured.extend(entities)
|
||||
|
||||
return captured, _add_entities
|
||||
|
||||
|
||||
def _desc_for(key: str):
|
||||
return next(d for d in BATTERY_BINARY_SENSORS if d.key == key)
|
||||
|
||||
|
||||
# --- async_setup_entry -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_creates_entities_for_battery_keys(hass):
|
||||
entry, coordinator, runtime = _make_entry(
|
||||
options={SENSORS_TO_LOAD: [OUTSIDE_BATTERY, INDOOR_BATTERY]}
|
||||
)
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
# Callback + description map persisted for dynamic entity creation.
|
||||
assert runtime.add_binary_entities is add_entities
|
||||
assert set(runtime.binary_descriptions.keys()) == {d.key for d in BATTERY_BINARY_SENSORS}
|
||||
|
||||
# Entities created for the requested battery keys only.
|
||||
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
|
||||
created_keys = {e.entity_description.key for e in captured}
|
||||
assert created_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
|
||||
|
||||
# added_binary_keys tracks them.
|
||||
assert runtime.added_binary_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
|
||||
|
||||
# Entities are bound to our coordinator stub.
|
||||
assert all(e.coordinator is coordinator for e in captured)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_no_battery_keys_adds_nothing(hass):
|
||||
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
# Callback/descriptions still stored, but no entities created.
|
||||
assert runtime.add_binary_entities is add_entities
|
||||
assert runtime.binary_descriptions # populated
|
||||
assert captured == []
|
||||
assert runtime.added_binary_keys == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_no_sensors_to_load_option_adds_nothing(hass):
|
||||
entry, _coordinator, runtime = _make_entry() # no options at all
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
assert captured == []
|
||||
assert runtime.added_binary_keys == set()
|
||||
|
||||
|
||||
# --- add_new_binary_sensors ------------------------------------------------
|
||||
|
||||
|
||||
def test_add_new_is_noop_when_callback_missing(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
# Platform not set up yet -> no stored callback.
|
||||
assert runtime.add_binary_entities is None
|
||||
|
||||
# Must not raise and must not populate anything.
|
||||
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
|
||||
assert runtime.added_binary_keys == set()
|
||||
|
||||
|
||||
def test_add_new_ignores_already_added_keys(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
captured, add_entities = _capture_add_entities()
|
||||
runtime.add_binary_entities = add_entities
|
||||
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
|
||||
runtime.added_binary_keys = {OUTSIDE_BATTERY}
|
||||
|
||||
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
|
||||
|
||||
# Already added -> no new entities, callback not invoked.
|
||||
assert captured == []
|
||||
assert runtime.added_binary_keys == {OUTSIDE_BATTERY}
|
||||
|
||||
|
||||
def test_add_new_ignores_unknown_keys(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
captured, add_entities = _capture_add_entities()
|
||||
runtime.add_binary_entities = add_entities
|
||||
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
|
||||
|
||||
add_new_binary_sensors(hass, entry, ["totally_unknown_key"])
|
||||
|
||||
assert captured == []
|
||||
assert runtime.added_binary_keys == set()
|
||||
|
||||
|
||||
def test_add_new_adds_new_known_keys(hass):
|
||||
entry, coordinator, runtime = _make_entry()
|
||||
captured, add_entities = _capture_add_entities()
|
||||
runtime.add_binary_entities = add_entities
|
||||
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
|
||||
|
||||
# Mix of new known, unknown, and a key we'll mark already-added.
|
||||
runtime.added_binary_keys = {INDOOR_BATTERY}
|
||||
add_new_binary_sensors(
|
||||
hass, entry, [OUTSIDE_BATTERY, CH2_BATTERY, INDOOR_BATTERY, "unknown"]
|
||||
)
|
||||
|
||||
created_keys = {e.entity_description.key for e in captured}
|
||||
assert created_keys == {OUTSIDE_BATTERY, CH2_BATTERY}
|
||||
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
|
||||
assert all(e.coordinator is coordinator for e in captured)
|
||||
assert runtime.added_binary_keys == {INDOOR_BATTERY, OUTSIDE_BATTERY, CH2_BATTERY}
|
||||
|
||||
|
||||
# --- BatteryBinarySensor ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("0", True), # low battery -> on
|
||||
(0, True),
|
||||
("1", False), # battery OK -> off
|
||||
(1, False),
|
||||
(None, None), # missing
|
||||
("", None), # empty string
|
||||
("x", None), # non-int
|
||||
([], None), # non-int / TypeError path
|
||||
],
|
||||
)
|
||||
def test_is_on_value_mapping(raw, expected):
|
||||
coordinator = _CoordinatorStub({OUTSIDE_BATTERY: raw})
|
||||
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
|
||||
|
||||
assert sensor.is_on is expected
|
||||
assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary"
|
||||
|
||||
|
||||
def test_is_on_key_absent_returns_none():
|
||||
coordinator = _CoordinatorStub({}) # key not present at all
|
||||
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
|
||||
|
||||
assert sensor.is_on is None
|
||||
|
||||
|
||||
def test_device_info():
|
||||
coordinator = _CoordinatorStub()
|
||||
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
|
||||
|
||||
info = sensor.device_info
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["manufacturer"] == "Schizza"
|
||||
assert info["model"] == "Weather Station SWS 12500"
|
||||
assert info["identifiers"] == {(DOMAIN,)}
|
||||
|
|
@ -13,6 +13,7 @@ from custom_components.sws12500.const import (
|
|||
ECOWITT_ENABLED,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
INVALID_CREDENTIALS,
|
||||
LEGACY_ENABLED,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
POCASI_CZ_ENABLED,
|
||||
|
|
@ -24,6 +25,7 @@ from custom_components.sws12500.const import (
|
|||
WINDY_STATION_ID,
|
||||
WINDY_STATION_PW,
|
||||
WSLINK,
|
||||
WSLINK_ADDON_PORT,
|
||||
)
|
||||
from homeassistant import config_entries
|
||||
|
||||
|
|
@ -36,9 +38,15 @@ async def test_config_flow_user_form_then_create_entry(
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] == "menu"
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "pws"
|
||||
|
||||
user_input = {
|
||||
API_ID: "my_id",
|
||||
API_KEY: "my_key",
|
||||
|
|
@ -46,12 +54,16 @@ async def test_config_flow_user_form_then_create_entry(
|
|||
DEV_DBG: False,
|
||||
}
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=user_input
|
||||
form["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == DOMAIN
|
||||
assert result2["data"] == user_input
|
||||
assert result2["options"] == user_input
|
||||
# The PWS step augments user input with legacy/ecowitt flags.
|
||||
assert result2["data"][API_ID] == "my_id"
|
||||
assert result2["data"][API_KEY] == "my_key"
|
||||
assert result2["data"][LEGACY_ENABLED] is True
|
||||
assert result2["data"][ECOWITT_ENABLED] is False
|
||||
assert result2["options"] == result2["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -62,7 +74,13 @@ async def test_config_flow_user_invalid_credentials_api_id(
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] == "menu"
|
||||
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "pws"
|
||||
|
||||
user_input = {
|
||||
API_ID: INVALID_CREDENTIALS[0],
|
||||
|
|
@ -71,10 +89,10 @@ async def test_config_flow_user_invalid_credentials_api_id(
|
|||
DEV_DBG: False,
|
||||
}
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=user_input
|
||||
form["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result2["type"] == "form"
|
||||
assert result2["step_id"] == "user"
|
||||
assert result2["step_id"] == "pws"
|
||||
assert result2["errors"][API_ID] == "valid_credentials_api"
|
||||
|
||||
|
||||
|
|
@ -86,7 +104,13 @@ async def test_config_flow_user_invalid_credentials_api_key(
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] == "menu"
|
||||
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "pws"
|
||||
|
||||
user_input = {
|
||||
API_ID: "ok_id",
|
||||
|
|
@ -95,10 +119,10 @@ async def test_config_flow_user_invalid_credentials_api_key(
|
|||
DEV_DBG: False,
|
||||
}
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=user_input
|
||||
form["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result2["type"] == "form"
|
||||
assert result2["step_id"] == "user"
|
||||
assert result2["step_id"] == "pws"
|
||||
assert result2["errors"][API_KEY] == "valid_credentials_key"
|
||||
|
||||
|
||||
|
|
@ -110,7 +134,13 @@ async def test_config_flow_user_invalid_credentials_match(
|
|||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] == "menu"
|
||||
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "pws"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "pws"
|
||||
|
||||
user_input = {
|
||||
API_ID: "same",
|
||||
|
|
@ -119,10 +149,10 @@ async def test_config_flow_user_invalid_credentials_match(
|
|||
DEV_DBG: False,
|
||||
}
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=user_input
|
||||
form["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result2["type"] == "form"
|
||||
assert result2["step_id"] == "user"
|
||||
assert result2["step_id"] == "pws"
|
||||
assert result2["errors"]["base"] == "valid_credentials_match"
|
||||
|
||||
|
||||
|
|
@ -135,7 +165,7 @@ async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
|
|||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] == "menu"
|
||||
assert result["step_id"] == "init"
|
||||
assert set(result["menu_options"]) == {"basic", "ecowitt", "windy", "pocasi"}
|
||||
assert set(result["menu_options"]) == {"basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -381,3 +411,59 @@ async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_defaul
|
|||
)
|
||||
assert done["type"] == "create_entry"
|
||||
assert done["data"][ECOWITT_ENABLED] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_flow_wslink_port_setup(hass, enable_custom_integrations) -> None:
|
||||
"""The WSLink add-on port step shows a form and stores the port."""
|
||||
# A falsy stored port exercises the 443 default fallback.
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={WSLINK_ADDON_PORT: 0})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
init = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert init["type"] == "menu"
|
||||
|
||||
form = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"], user_input={"next_step_id": "wslink_port_setup"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "wslink_port_setup"
|
||||
|
||||
done = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"], user_input={WSLINK_ADDON_PORT: 8443}
|
||||
)
|
||||
assert done["type"] == "create_entry"
|
||||
assert done["data"][WSLINK_ADDON_PORT] == 8443
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integrations) -> None:
|
||||
"""Initial config flow: user menu -> ecowitt step creates an Ecowitt-only entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "menu"
|
||||
|
||||
with patch(
|
||||
"custom_components.sws12500.config_flow.get_url",
|
||||
return_value="http://example.local:8123",
|
||||
):
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "ecowitt"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "ecowitt"
|
||||
placeholders = form.get("description_placeholders") or {}
|
||||
assert placeholders["url"] == "example.local"
|
||||
assert placeholders["webhook_id"]
|
||||
|
||||
done = await hass.config_entries.flow.async_configure(
|
||||
form["flow_id"],
|
||||
user_input={
|
||||
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
|
||||
ECOWITT_ENABLED: True,
|
||||
},
|
||||
)
|
||||
assert done["type"] == "create_entry"
|
||||
assert done["data"][ECOWITT_ENABLED] is True
|
||||
assert done["data"][LEGACY_ENABLED] is False
|
||||
|
|
|
|||
|
|
@ -1,13 +1,46 @@
|
|||
from custom_components.sws12500.data import (
|
||||
ENTRY_ADD_ENTITIES,
|
||||
ENTRY_COORDINATOR,
|
||||
ENTRY_DESCRIPTIONS,
|
||||
ENTRY_LAST_OPTIONS,
|
||||
)
|
||||
"""Tests for the typed per-entry runtime data container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
|
||||
|
||||
def test_data_constants():
|
||||
assert ENTRY_COORDINATOR == "coordinator"
|
||||
assert ENTRY_ADD_ENTITIES == "async_add_entities"
|
||||
assert ENTRY_DESCRIPTIONS == "sensor_descriptions"
|
||||
assert ENTRY_LAST_OPTIONS == "last_options"
|
||||
def test_runtime_data_defaults():
|
||||
"""SWSRuntimeData exposes the expected fields with safe defaults.
|
||||
|
||||
The ad-hoc hass.data[DOMAIN][entry_id] string keys (ENTRY_*) were replaced by
|
||||
this typed dataclass stored on entry.runtime_data in v2.0.
|
||||
"""
|
||||
runtime = SWSRuntimeData(
|
||||
coordinator=object(), # type: ignore[arg-type]
|
||||
health_coordinator=object(), # type: ignore[arg-type]
|
||||
last_options={"legacy_enabled": True},
|
||||
)
|
||||
|
||||
assert runtime.last_options == {"legacy_enabled": True}
|
||||
|
||||
# Optional platform wiring defaults.
|
||||
assert runtime.add_sensor_entities is None
|
||||
assert runtime.sensor_descriptions == {}
|
||||
assert runtime.add_binary_entities is None
|
||||
assert runtime.binary_descriptions == {}
|
||||
assert runtime.added_binary_keys == set()
|
||||
|
||||
# Diagnostics / staleness defaults.
|
||||
assert runtime.health_data is None
|
||||
assert runtime.last_seen == {}
|
||||
assert runtime.started_at is not None
|
||||
|
||||
|
||||
def test_runtime_data_collections_are_independent_per_instance():
|
||||
"""Mutable defaults must not be shared between instances."""
|
||||
a = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
|
||||
b = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
|
||||
|
||||
a.sensor_descriptions["x"] = object() # type: ignore[assignment]
|
||||
a.added_binary_keys.add("y")
|
||||
a.last_seen["z"] = object() # type: ignore[assignment]
|
||||
|
||||
assert b.sensor_descriptions == {}
|
||||
assert b.added_binary_keys == set()
|
||||
assert b.last_seen == {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
"""Tests for the SWS12500 config entry diagnostics.
|
||||
|
||||
These cover both branches of `async_get_config_entry_diagnostics`:
|
||||
- `runtime_data.health_data` is populated and returned directly.
|
||||
- `runtime_data.health_data` is None, so it falls back to the live
|
||||
`health_coordinator.data` snapshot.
|
||||
|
||||
In both cases secret keys listed in `TO_REDACT` must be replaced by the
|
||||
Home Assistant `async_redact_data` sentinel, while non-secret values pass
|
||||
through untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DOMAIN,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
WINDY_STATION_ID,
|
||||
WINDY_STATION_PW,
|
||||
)
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.diagnostics import async_get_config_entry_diagnostics
|
||||
|
||||
REDACTED = "**REDACTED**"
|
||||
|
||||
|
||||
def _make_runtime(*, health_data, health_coordinator) -> SWSRuntimeData:
|
||||
"""Build a runtime data container with lightweight stub coordinators.
|
||||
|
||||
Diagnostics only ever reads `health_data` and `health_coordinator.data`,
|
||||
so the real coordinators can be replaced with plain stubs.
|
||||
"""
|
||||
return SWSRuntimeData(
|
||||
coordinator=object(), # type: ignore[arg-type]
|
||||
health_coordinator=health_coordinator, # type: ignore[arg-type]
|
||||
last_options={},
|
||||
health_data=health_data,
|
||||
)
|
||||
|
||||
|
||||
async def test_diagnostics_uses_persisted_health_data(hass) -> None:
|
||||
"""When `health_data` is present it is returned and secrets are redacted."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
API_ID: "secret-api-id",
|
||||
API_KEY: "secret-api-key",
|
||||
"name": "Station",
|
||||
},
|
||||
options={
|
||||
WINDY_STATION_ID: "secret-windy-id",
|
||||
WINDY_STATION_PW: "secret-windy-pw",
|
||||
"interval": 60,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
health_data = {
|
||||
"ID": "secret-station-id",
|
||||
"PASSWORD": "secret-station-pw",
|
||||
POCASI_CZ_API_ID: "secret-pocasi-id",
|
||||
POCASI_CZ_API_KEY: "secret-pocasi-key",
|
||||
"wsid": "secret-wsid",
|
||||
"wspw": "secret-wspw",
|
||||
"status": "ok",
|
||||
}
|
||||
# A separate coordinator snapshot that must NOT be used in this branch.
|
||||
health_coordinator = SimpleNamespace(data={"status": "stale-should-not-appear"})
|
||||
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=health_data,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
# entry_data: secrets redacted, plain value preserved.
|
||||
assert result["entry_data"][API_ID] == REDACTED
|
||||
assert result["entry_data"][API_KEY] == REDACTED
|
||||
assert result["entry_data"]["name"] == "Station"
|
||||
|
||||
# entry_options: secrets redacted, plain value preserved.
|
||||
assert result["entry_options"][WINDY_STATION_ID] == REDACTED
|
||||
assert result["entry_options"][WINDY_STATION_PW] == REDACTED
|
||||
assert result["entry_options"]["interval"] == 60
|
||||
|
||||
# health_data: the persisted payload is used (not the coordinator snapshot).
|
||||
assert result["health_data"]["status"] == "ok"
|
||||
assert result["health_data"]["ID"] == REDACTED
|
||||
assert result["health_data"]["PASSWORD"] == REDACTED
|
||||
assert result["health_data"][POCASI_CZ_API_ID] == REDACTED
|
||||
assert result["health_data"][POCASI_CZ_API_KEY] == REDACTED
|
||||
assert result["health_data"]["wsid"] == REDACTED
|
||||
assert result["health_data"]["wspw"] == REDACTED
|
||||
|
||||
# The original payload must be untouched (deepcopy is used internally).
|
||||
assert health_data["ID"] == "secret-station-id"
|
||||
|
||||
|
||||
async def test_diagnostics_falls_back_to_coordinator_data(hass) -> None:
|
||||
"""When `health_data` is None it falls back to the coordinator snapshot."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={API_ID: "secret-api-id", "name": "Station"},
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
coordinator_data = {
|
||||
"ID": "secret-station-id",
|
||||
"PASSWORD": "secret-station-pw",
|
||||
"status": "live",
|
||||
}
|
||||
health_coordinator = SimpleNamespace(data=coordinator_data)
|
||||
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=None,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["entry_data"][API_ID] == REDACTED
|
||||
assert result["entry_data"]["name"] == "Station"
|
||||
assert result["entry_options"] == {}
|
||||
|
||||
# Fallback snapshot is used and redacted.
|
||||
assert result["health_data"]["status"] == "live"
|
||||
assert result["health_data"]["ID"] == REDACTED
|
||||
assert result["health_data"]["PASSWORD"] == REDACTED
|
||||
|
||||
|
||||
async def test_diagnostics_empty_health_data(hass) -> None:
|
||||
"""A falsy fallback snapshot yields an empty health_data dict."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
health_coordinator = SimpleNamespace(data=None)
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=None,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["health_data"] == {}
|
||||
assert result["entry_data"] == {}
|
||||
assert result["entry_options"] == {}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
"""Tests for the Ecowitt bridge and native passthrough sensor.
|
||||
|
||||
Covers `custom_components.sws12500.ecowitt`:
|
||||
- `EcowittBridge`: set_add_entities, process_payload, _on_new_sensor branches,
|
||||
and the `unmapped_sensor` / `all_sensors` properties.
|
||||
- `EcoWittNativeSensor`: __init__ (mapped/unmapped stype, station / no station),
|
||||
native_value, async_added_to_hass / async_will_remove_from_hass and _handle_update.
|
||||
|
||||
The tests drive real `aioecowitt` parsing where practical and construct
|
||||
`EcoWittSensor` objects directly to exercise deterministic branches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
|
||||
from aioecowitt.station import EcoWittStation
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
|
||||
|
||||
# A realistic Ecowitt POST payload. `model` is required by aioecowitt's
|
||||
# station extraction. Contains both internally mapped fields (tempf, humidity,
|
||||
# windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2).
|
||||
_PAYLOAD: dict[str, Any] = {
|
||||
"PASSKEY": "ABC123",
|
||||
"stationtype": "GW1000",
|
||||
"model": "GW1000",
|
||||
"dateutc": "2024-01-01 00:00:00",
|
||||
"freq": "868M",
|
||||
"tempf": "68.0",
|
||||
"humidity": "50",
|
||||
"windspeedmph": "1.0",
|
||||
"baromrelin": "29.9",
|
||||
"pm25_ch1": "12.0",
|
||||
"co2": "400",
|
||||
}
|
||||
|
||||
|
||||
def _make_bridge() -> EcowittBridge:
|
||||
"""Build a bridge with lightweight hass / config stubs.
|
||||
|
||||
The bridge only stores hass/config; parsing is delegated to aioecowitt.
|
||||
"""
|
||||
hass = SimpleNamespace()
|
||||
config = SimpleNamespace(options={})
|
||||
return EcowittBridge(hass, config)
|
||||
|
||||
|
||||
def _make_sensor(
|
||||
*,
|
||||
key: str = "pm25_ch1",
|
||||
name: str = "PM2.5 CH1",
|
||||
stype: EcoWittSensorTypes = EcoWittSensorTypes.PM25,
|
||||
station: EcoWittStation | None = None,
|
||||
value: Any = 12.0,
|
||||
) -> EcoWittSensor:
|
||||
"""Construct a real EcoWittSensor for entity-level tests."""
|
||||
if station is None:
|
||||
station = EcoWittStation(
|
||||
station="GW1000",
|
||||
model="GW1000",
|
||||
frequence="868M",
|
||||
key="ABC123",
|
||||
)
|
||||
sensor = EcoWittSensor(name, key, stype, station)
|
||||
sensor.value = value
|
||||
return sensor
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# EcowittBridge
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bridge_init_registers_new_sensor_cb() -> None:
|
||||
"""The bridge wires its own _on_new_sensor into the listener."""
|
||||
bridge = _make_bridge()
|
||||
assert bridge._on_new_sensor in bridge._listener.new_sensor_cb
|
||||
assert bridge._add_entities_cb is None
|
||||
|
||||
|
||||
def test_set_add_entities_stores_callback() -> None:
|
||||
"""set_add_entities stores the platform callback."""
|
||||
bridge = _make_bridge()
|
||||
cb = MagicMock()
|
||||
bridge.set_add_entities(cb)
|
||||
assert bridge._add_entities_cb is cb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_payload_returns_mapped_result() -> None:
|
||||
"""process_payload parses payload and returns only internally mapped keys."""
|
||||
bridge = _make_bridge()
|
||||
|
||||
result = await bridge.process_payload(dict(_PAYLOAD))
|
||||
|
||||
# Mapped fields present in the payload are remapped to internal keys.
|
||||
assert result[REMAP_ECOWITT_COMPAT["tempf"]] == "68.0"
|
||||
assert result[REMAP_ECOWITT_COMPAT["humidity"]] == "50"
|
||||
assert result[REMAP_ECOWITT_COMPAT["windspeedmph"]] == "1.0"
|
||||
assert result[REMAP_ECOWITT_COMPAT["baromrelin"]] == "29.9"
|
||||
|
||||
# Unmapped fields never appear in mapped_result.
|
||||
assert all(k in REMAP_ECOWITT_COMPAT.values() for k in result)
|
||||
|
||||
# aioecowitt populated the listener with sensors.
|
||||
assert bridge.all_sensors
|
||||
assert "ABC123.tempf" in bridge.all_sensors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_payload_no_mapped_fields() -> None:
|
||||
"""A payload without mapped fields yields an empty mapped_result."""
|
||||
bridge = _make_bridge()
|
||||
data = {
|
||||
"PASSKEY": "ABC123",
|
||||
"stationtype": "GW1000",
|
||||
"model": "GW1000",
|
||||
"dateutc": "2024-01-01 00:00:00",
|
||||
"co2": "400",
|
||||
}
|
||||
result = await bridge.process_payload(data)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_payload_creates_native_entities_for_unmapped() -> None:
|
||||
"""With a callback set, unmapped sensors become native entities."""
|
||||
bridge = _make_bridge()
|
||||
created: list[EcoWittNativeSensor] = []
|
||||
bridge.set_add_entities(lambda entities: created.extend(entities))
|
||||
|
||||
await bridge.process_payload(dict(_PAYLOAD))
|
||||
|
||||
created_keys = {e._ecowitt_sensor.key for e in created}
|
||||
# Unmapped sensors got native entities ...
|
||||
assert "pm25_ch1" in created_keys
|
||||
assert "co2" in created_keys
|
||||
# ... but mapped sensors did NOT.
|
||||
assert "tempf" not in created_keys
|
||||
assert "humidity" not in created_keys
|
||||
assert all(isinstance(e, EcoWittNativeSensor) for e in created)
|
||||
|
||||
|
||||
def test_on_new_sensor_skips_mapped_key() -> None:
|
||||
"""A sensor whose key has an internal mapping creates no entity."""
|
||||
bridge = _make_bridge()
|
||||
bridge.set_add_entities(MagicMock())
|
||||
sensor = _make_sensor(key="tempf", stype=EcoWittSensorTypes.TEMPERATURE_F)
|
||||
|
||||
bridge._on_new_sensor(sensor)
|
||||
|
||||
bridge._add_entities_cb.assert_not_called()
|
||||
assert "tempf" not in bridge._know_native_keys
|
||||
|
||||
|
||||
def test_on_new_sensor_skips_already_known() -> None:
|
||||
"""A sensor whose key is already tracked creates no new entity."""
|
||||
bridge = _make_bridge()
|
||||
cb = MagicMock()
|
||||
bridge.set_add_entities(cb)
|
||||
bridge._know_native_keys.add("pm25_ch1")
|
||||
sensor = _make_sensor(key="pm25_ch1")
|
||||
|
||||
bridge._on_new_sensor(sensor)
|
||||
|
||||
cb.assert_not_called()
|
||||
|
||||
|
||||
def test_on_new_sensor_no_callback_is_noop() -> None:
|
||||
"""Without a platform callback the discovery is a no-op (not tracked)."""
|
||||
bridge = _make_bridge()
|
||||
sensor = _make_sensor(key="pm25_ch1")
|
||||
|
||||
bridge._on_new_sensor(sensor) # _add_entities_cb is None
|
||||
|
||||
assert "pm25_ch1" not in bridge._know_native_keys
|
||||
|
||||
|
||||
def test_on_new_sensor_creates_entity() -> None:
|
||||
"""An unmapped, unknown sensor creates and registers a native entity."""
|
||||
bridge = _make_bridge()
|
||||
cb = MagicMock()
|
||||
bridge.set_add_entities(cb)
|
||||
sensor = _make_sensor(key="pm25_ch1")
|
||||
|
||||
bridge._on_new_sensor(sensor)
|
||||
|
||||
assert "pm25_ch1" in bridge._know_native_keys
|
||||
cb.assert_called_once()
|
||||
(entities,) = cb.call_args.args
|
||||
assert len(entities) == 1
|
||||
assert isinstance(entities[0], EcoWittNativeSensor)
|
||||
assert entities[0]._ecowitt_sensor is sensor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmapped_sensor_property() -> None:
|
||||
"""unmapped_sensor returns only sensors without an internal mapping."""
|
||||
bridge = _make_bridge()
|
||||
await bridge.process_payload(dict(_PAYLOAD))
|
||||
|
||||
unmapped = bridge.unmapped_sensor
|
||||
keys = {s.key for s in unmapped.values()}
|
||||
|
||||
assert "pm25_ch1" in keys
|
||||
assert "co2" in keys
|
||||
# Mapped sensors are excluded.
|
||||
assert "tempf" not in keys
|
||||
assert "humidity" not in keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_sensors_property() -> None:
|
||||
"""all_sensors returns the listener's full sensor dict."""
|
||||
bridge = _make_bridge()
|
||||
await bridge.process_payload(dict(_PAYLOAD))
|
||||
|
||||
assert bridge.all_sensors is bridge._listener.sensors
|
||||
assert "ABC123.tempf" in bridge.all_sensors
|
||||
assert "ABC123.pm25_ch1" in bridge.all_sensors
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# EcoWittNativeSensor
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_native_sensor_init_with_mapped_stype() -> None:
|
||||
"""A known stype sets device class / unit / state class and device info."""
|
||||
station = EcoWittStation(
|
||||
station="GW1000", model="GW1000", frequence="868M", key="ABC123"
|
||||
)
|
||||
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
|
||||
assert entity._attr_unique_id == "ecowitt_pm25_ch1"
|
||||
assert entity._attr_name == "PM2.5 CH1"
|
||||
assert entity._attr_translation_key is None
|
||||
|
||||
device_class, unit, state_class = STYPE_TO_HA[EcoWittSensorTypes.PM25]
|
||||
assert entity._attr_device_class == device_class
|
||||
assert entity._attr_native_unit_of_measurement == unit
|
||||
assert entity._attr_state_class == state_class
|
||||
|
||||
# Device info groups the entity under the station device.
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt GW1000"
|
||||
assert info["model"] == "GW1000"
|
||||
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||
assert info["manufacturer"] == "Ecowitt impl. from Schizza for SWS12500"
|
||||
|
||||
|
||||
def test_native_sensor_init_with_unmapped_stype() -> None:
|
||||
"""An unknown stype leaves device class / unit / state class unset.
|
||||
|
||||
Device info is still attached (the recent change).
|
||||
"""
|
||||
assert EcoWittSensorTypes.INTERNAL not in STYPE_TO_HA
|
||||
station = EcoWittStation(
|
||||
station="GW1000", model="GW1000", frequence="868M", key="ABC123"
|
||||
)
|
||||
sensor = _make_sensor(
|
||||
key="runtime",
|
||||
name="Runtime",
|
||||
stype=EcoWittSensorTypes.INTERNAL,
|
||||
station=station,
|
||||
value="1000",
|
||||
)
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
|
||||
# No HA metadata set for unknown types.
|
||||
assert getattr(entity, "_attr_device_class", None) is None
|
||||
assert getattr(entity, "_attr_native_unit_of_measurement", None) is None
|
||||
assert getattr(entity, "_attr_state_class", None) is None
|
||||
|
||||
# Device info is still present.
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt GW1000"
|
||||
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||
|
||||
|
||||
def test_native_sensor_init_without_station() -> None:
|
||||
"""A sensor with no station falls back to generic device info."""
|
||||
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25)
|
||||
# Force the no-station branch.
|
||||
sensor.station = None # type: ignore[assignment]
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt station"
|
||||
assert info["model"] is None
|
||||
assert (DOMAIN, "ecowitt") in info["identifiers"]
|
||||
|
||||
|
||||
def test_native_value_returns_value() -> None:
|
||||
"""native_value returns the underlying sensor value."""
|
||||
sensor = _make_sensor(value=42.0)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
assert entity.native_value == 42.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("empty", [None, ""])
|
||||
def test_native_value_none_or_empty(empty: Any) -> None:
|
||||
"""native_value maps None and "" to None."""
|
||||
sensor = _make_sensor(value=empty)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
assert entity.native_value is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_added_and_removed_callback_lifecycle() -> None:
|
||||
"""async_added/async_will_remove register and unregister the update cb."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
|
||||
await entity.async_added_to_hass()
|
||||
assert entity._handle_update in sensor.update_cb
|
||||
|
||||
await entity.async_will_remove_from_hass()
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_will_remove_when_callback_absent() -> None:
|
||||
"""async_will_remove is safe when the callback was never registered."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
|
||||
# Not added; removal must not raise and must not touch the list.
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
await entity.async_will_remove_from_hass()
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
|
||||
|
||||
def test_handle_update_writes_ha_state() -> None:
|
||||
"""_handle_update forwards to async_write_ha_state."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
entity._handle_update()
|
||||
|
||||
entity.async_write_ha_state.assert_called_once_with()
|
||||
|
|
@ -0,0 +1,692 @@
|
|||
"""Coverage tests for the health coordinator and health diagnostic sensors.
|
||||
|
||||
These tests exercise the runtime health model end to end without requiring real
|
||||
network access. The add-on reachability check in `_async_update_data` is the only
|
||||
network-bound path; it is covered by monkeypatching the aiohttp session factory
|
||||
and the network helpers (`async_get_source_ip`, `get_url`).
|
||||
|
||||
Architecture notes (see CRITICAL ARCHITECTURE NOTES in the task brief):
|
||||
- `HealthCoordinator(hass, config)` forwards `config_entry=config` to
|
||||
`DataUpdateCoordinator.__init__`, which calls `config.async_on_unload(...)`.
|
||||
A `MockConfigEntry` supports that, so we always pass one.
|
||||
- Health persistence writes `config.runtime_data.health_data`; we set
|
||||
`entry.runtime_data` to a real `SWSRuntimeData` to cover the success path and
|
||||
also test the `AttributeError` branch when it is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientConnectionError
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500 import health_coordinator as hc, health_sensor as hs
|
||||
from custom_components.sws12500.const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
POCASI_CZ_ENABLED,
|
||||
WINDY_ENABLED,
|
||||
WSLINK,
|
||||
WSLINK_ADDON_PORT,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.health_coordinator import HealthCoordinator
|
||||
from custom_components.sws12500.routes import Routes
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_entry(options: dict[str, Any] | None = None) -> MockConfigEntry:
|
||||
"""Create a config entry usable by the coordinator constructor."""
|
||||
return MockConfigEntry(domain=DOMAIN, data={}, options=options or {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry() -> MockConfigEntry:
|
||||
"""A config entry with empty options."""
|
||||
return _make_entry()
|
||||
|
||||
|
||||
def _attach_runtime_data(entry: MockConfigEntry, coordinator: HealthCoordinator) -> None:
|
||||
"""Attach a real SWSRuntimeData so the persistence success path is exercised."""
|
||||
entry.runtime_data = SWSRuntimeData(
|
||||
coordinator=MagicMock(),
|
||||
health_coordinator=coordinator,
|
||||
last_options={},
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal aiohttp-like response."""
|
||||
|
||||
def __init__(self, status: int, json_data: Any = None, json_exc: Exception | None = None) -> None:
|
||||
self.status = status
|
||||
self._json_data = json_data
|
||||
self._json_exc = json_exc
|
||||
|
||||
async def json(self, content_type: Any = None) -> Any: # noqa: ARG002 - signature match
|
||||
if self._json_exc is not None:
|
||||
raise self._json_exc
|
||||
return self._json_data
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Fake aiohttp session whose `.get()` returns an async context manager.
|
||||
|
||||
`responses` maps a URL substring to either a `_FakeResponse` or an Exception
|
||||
that should be raised when entering the context manager.
|
||||
"""
|
||||
|
||||
def __init__(self, responses: dict[str, Any]) -> None:
|
||||
self._responses = responses
|
||||
|
||||
def get(self, url: str): # noqa: D401 - mimic aiohttp
|
||||
outcome: Any = None
|
||||
for needle, value in self._responses.items():
|
||||
if needle in url:
|
||||
outcome = value
|
||||
break
|
||||
|
||||
@asynccontextmanager
|
||||
async def _ctx():
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
yield outcome
|
||||
|
||||
return _ctx()
|
||||
|
||||
|
||||
def _patch_network(monkeypatch, session: _FakeSession, ip: str = "1.2.3.4") -> None:
|
||||
"""Patch the coordinator network helpers to deterministic values."""
|
||||
monkeypatch.setattr(hc, "async_get_clientsession", lambda _hass, _verify=False: session)
|
||||
monkeypatch.setattr(hc, "async_get_source_ip", AsyncMock(return_value=ip))
|
||||
monkeypatch.setattr(hc, "get_url", lambda _hass: "http://ha:8123")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers in health_coordinator.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_protocol_name() -> None:
|
||||
assert hc._protocol_name(True) == "wslink"
|
||||
assert hc._protocol_name(False) == "wu"
|
||||
|
||||
|
||||
def test_protocol_from_path_all_branches() -> None:
|
||||
assert hc._protocol_from_path(WSLINK_URL) == "wslink"
|
||||
assert hc._protocol_from_path(DEFAULT_URL) == "wu"
|
||||
assert hc._protocol_from_path(HEALTH_URL) == "health"
|
||||
assert hc._protocol_from_path(ECOWITT_URL_PREFIX + "/abc") == "ecowitt"
|
||||
assert hc._protocol_from_path("/something/else") == "unknown"
|
||||
|
||||
|
||||
def test_empty_forwarding_state() -> None:
|
||||
enabled = hc._empty_forwarding_state(True)
|
||||
assert enabled == {
|
||||
"enabled": True,
|
||||
"last_status": "idle",
|
||||
"last_error": None,
|
||||
"last_attempt_at": None,
|
||||
}
|
||||
disabled = hc._empty_forwarding_state(False)
|
||||
assert disabled["enabled"] is False
|
||||
assert disabled["last_status"] == "disabled"
|
||||
|
||||
|
||||
def test_default_health_data() -> None:
|
||||
entry = _make_entry({WSLINK: True, WINDY_ENABLED: True, POCASI_CZ_ENABLED: False})
|
||||
data = hc._default_health_data(entry)
|
||||
|
||||
assert data["configured_protocol"] == "wslink"
|
||||
assert data["active_protocol"] == "wslink"
|
||||
assert data["integration_status"] == "online_wslink"
|
||||
assert data["addon"]["online"] is False
|
||||
assert data["addon"]["paths"] == {"wslink": WSLINK_URL, "wu": DEFAULT_URL}
|
||||
assert data["forwarding"]["windy"]["enabled"] is True
|
||||
assert data["forwarding"]["pocasi"]["enabled"] is False
|
||||
assert data["last_ingress"]["reason"] == "no_data"
|
||||
|
||||
|
||||
def test_default_health_data_wu_default() -> None:
|
||||
data = hc._default_health_data(_make_entry())
|
||||
assert data["configured_protocol"] == "wu"
|
||||
assert data["integration_status"] == "online_wu"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HealthCoordinator construction & persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_runtime_health_success(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
payload = {"hello": "world"}
|
||||
coordinator._store_runtime_health(payload)
|
||||
|
||||
assert entry.runtime_data.health_data == payload
|
||||
# deepcopy: stored value is independent of the source dict
|
||||
assert entry.runtime_data.health_data is not payload
|
||||
|
||||
|
||||
def test_store_runtime_health_missing_runtime_data(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
# entry.runtime_data is unset -> AttributeError branch must be swallowed.
|
||||
assert getattr(entry, "runtime_data", None) is None
|
||||
coordinator._store_runtime_health({"a": 1}) # must not raise
|
||||
|
||||
|
||||
def test_commit_publishes_and_persists(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
new_data = hc._default_health_data(entry)
|
||||
new_data["integration_status"] = "custom"
|
||||
|
||||
result = coordinator._commit(new_data)
|
||||
|
||||
assert result is new_data
|
||||
assert coordinator.data["integration_status"] == "custom"
|
||||
assert entry.runtime_data.health_data["integration_status"] == "custom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _refresh_summary branches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_summary_degraded_by_reason(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
data = hc._default_health_data(entry)
|
||||
data["configured_protocol"] = "wu"
|
||||
data["last_ingress"] = {"protocol": "wu", "accepted": False, "reason": "route_disabled"}
|
||||
|
||||
coordinator._refresh_summary(data)
|
||||
|
||||
assert data["integration_status"] == "degraded"
|
||||
# not accepted -> active falls back to configured protocol
|
||||
assert data["active_protocol"] == "wu"
|
||||
|
||||
|
||||
def test_refresh_summary_degraded_by_protocol_mismatch(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
data = hc._default_health_data(entry)
|
||||
data["configured_protocol"] = "wu"
|
||||
# last protocol differs from configured -> degraded even when accepted
|
||||
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
|
||||
|
||||
coordinator._refresh_summary(data)
|
||||
|
||||
assert data["integration_status"] == "degraded"
|
||||
# accepted + recognized protocol -> active protocol tracks the ingress
|
||||
assert data["active_protocol"] == "wslink"
|
||||
|
||||
|
||||
def test_refresh_summary_online_protocol(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
data = hc._default_health_data(entry)
|
||||
data["configured_protocol"] = "wslink"
|
||||
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
|
||||
|
||||
coordinator._refresh_summary(data)
|
||||
|
||||
assert data["integration_status"] == "online_wslink"
|
||||
assert data["active_protocol"] == "wslink"
|
||||
|
||||
|
||||
def test_refresh_summary_online_idle(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
data = hc._default_health_data(entry)
|
||||
data["configured_protocol"] = "wu"
|
||||
data["last_ingress"] = {"protocol": "unknown", "accepted": False, "reason": "no_data"}
|
||||
|
||||
coordinator._refresh_summary(data)
|
||||
|
||||
assert data["integration_status"] == "online_idle"
|
||||
assert data["active_protocol"] == "wu"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _async_update_data: offline and online paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_async_update_data_addon_offline(hass, monkeypatch) -> None:
|
||||
entry = _make_entry({WSLINK_ADDON_PORT: 8443})
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
session = _FakeSession({"/healthz": ClientConnectionError("boom")})
|
||||
_patch_network(monkeypatch, session)
|
||||
|
||||
data = await coordinator._async_update_data()
|
||||
|
||||
addon = data["addon"]
|
||||
assert addon["online"] is False
|
||||
assert addon["health_url"] == "https://1.2.3.4:8443/healthz"
|
||||
assert addon["info_url"] == "https://1.2.3.4:8443/status/internal"
|
||||
assert addon["home_assistant_url"] == "http://ha:8123"
|
||||
assert addon["home_assistant_source_ip"] == "1.2.3.4"
|
||||
assert addon["raw_status"] is None
|
||||
assert addon["name"] is None
|
||||
|
||||
|
||||
async def test_async_update_data_addon_online(hass, monkeypatch) -> None:
|
||||
entry = _make_entry() # no WSLINK_ADDON_PORT -> default 443
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
raw_status = {
|
||||
"addon": "wslink-addon",
|
||||
"version": "1.2.3",
|
||||
"listen": {"port": 8443, "tls": True},
|
||||
"upstream": {"ha_port": 8123},
|
||||
"paths": {"wslink": "/custom/wslink", "wu": "/custom/wu"},
|
||||
}
|
||||
session = _FakeSession(
|
||||
{
|
||||
"/healthz": _FakeResponse(200),
|
||||
"/status/internal": _FakeResponse(200, json_data=raw_status),
|
||||
}
|
||||
)
|
||||
_patch_network(monkeypatch, session)
|
||||
|
||||
data = await coordinator._async_update_data()
|
||||
|
||||
addon = data["addon"]
|
||||
assert addon["online"] is True
|
||||
assert addon["health_url"] == "https://1.2.3.4:443/healthz"
|
||||
assert addon["name"] == "wslink-addon"
|
||||
assert addon["version"] == "1.2.3"
|
||||
assert addon["listen_port"] == 8443
|
||||
assert addon["tls"] is True
|
||||
assert addon["upstream_ha_port"] == 8123
|
||||
assert addon["paths"] == {"wslink": "/custom/wslink", "wu": "/custom/wu"}
|
||||
assert addon["raw_status"] == raw_status
|
||||
|
||||
|
||||
async def test_async_update_data_info_endpoint_value_error(hass, monkeypatch) -> None:
|
||||
"""Online add-on but the info endpoint returns invalid JSON."""
|
||||
entry = _make_entry()
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
info_resp = _FakeResponse(200, json_exc=ValueError("bad json"))
|
||||
session = _FakeSession(
|
||||
{
|
||||
"/healthz": _FakeResponse(200),
|
||||
"/status/internal": info_resp,
|
||||
}
|
||||
)
|
||||
_patch_network(monkeypatch, session)
|
||||
|
||||
data = await coordinator._async_update_data()
|
||||
|
||||
addon = data["addon"]
|
||||
assert addon["online"] is True
|
||||
assert addon["raw_status"] is None
|
||||
# raw_status falsy -> add-on metadata stays at defaults
|
||||
assert addon["name"] is None
|
||||
assert addon["version"] is None
|
||||
|
||||
|
||||
async def test_async_update_data_info_non_200(hass, monkeypatch) -> None:
|
||||
"""Online add-on but info endpoint replies non-200 -> raw_status stays None."""
|
||||
entry = _make_entry()
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
session = _FakeSession(
|
||||
{
|
||||
"/healthz": _FakeResponse(200),
|
||||
"/status/internal": _FakeResponse(503),
|
||||
}
|
||||
)
|
||||
_patch_network(monkeypatch, session)
|
||||
|
||||
data = await coordinator._async_update_data()
|
||||
assert data["addon"]["online"] is True
|
||||
assert data["addon"]["raw_status"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_routing_none(hass) -> None:
|
||||
entry = _make_entry({WSLINK: True})
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
coordinator.update_routing(None)
|
||||
|
||||
assert coordinator.data["configured_protocol"] == "wslink"
|
||||
# routes block unchanged from default when None passed
|
||||
assert coordinator.data["routes"]["wu_enabled"] is False
|
||||
|
||||
|
||||
def test_update_routing_with_routes(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
routes = Routes()
|
||||
coordinator.update_routing(routes)
|
||||
|
||||
routes_block = coordinator.data["routes"]
|
||||
assert routes_block["wu_enabled"] is False
|
||||
assert routes_block["wslink_enabled"] is False
|
||||
assert routes_block["health_enabled"] is False
|
||||
assert routes_block["snapshot"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record_dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_dispatch_skips_health(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
before = coordinator.data["last_ingress"].copy()
|
||||
|
||||
request = SimpleNamespace(path=HEALTH_URL, method="GET")
|
||||
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
||||
|
||||
# health path is ignored -> last_ingress untouched
|
||||
assert coordinator.data["last_ingress"] == before
|
||||
|
||||
|
||||
def test_record_dispatch_records(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=WSLINK_URL, method="POST")
|
||||
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
||||
|
||||
ingress = coordinator.data["last_ingress"]
|
||||
assert ingress["protocol"] == "wslink"
|
||||
assert ingress["path"] == WSLINK_URL
|
||||
assert ingress["method"] == "POST"
|
||||
assert ingress["route_enabled"] is True
|
||||
assert ingress["accepted"] is False
|
||||
assert ingress["reason"] == "pending"
|
||||
assert ingress["time"] is not None
|
||||
|
||||
|
||||
def test_record_dispatch_records_with_reason(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
||||
coordinator.record_dispatch(request, route_enabled=False, reason="route_disabled")
|
||||
|
||||
ingress = coordinator.data["last_ingress"]
|
||||
assert ingress["reason"] == "route_disabled"
|
||||
# route_disabled reason makes the summary degraded
|
||||
assert coordinator.data["integration_status"] == "degraded"
|
||||
|
||||
|
||||
def test_record_dispatch_masks_ecowitt_webhook_id(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=ECOWITT_URL_PREFIX + "/supersecretid", method="POST")
|
||||
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
||||
|
||||
ingress = coordinator.data["last_ingress"]
|
||||
assert ingress["protocol"] == "ecowitt"
|
||||
# The secret webhook id must never reach the (potentially exposed) snapshot.
|
||||
assert ingress["path"] == ECOWITT_URL_PREFIX + "/***"
|
||||
assert "supersecretid" not in ingress["path"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_ingress_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_ingress_result_accepted(hass) -> None:
|
||||
entry = _make_entry({WSLINK: True})
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=WSLINK_URL, method="POST")
|
||||
coordinator.update_ingress_result(request, accepted=True, authorized=True)
|
||||
|
||||
ingress = coordinator.data["last_ingress"]
|
||||
assert ingress["accepted"] is True
|
||||
assert ingress["authorized"] is True
|
||||
assert ingress["reason"] == "accepted"
|
||||
assert ingress["protocol"] == "wslink"
|
||||
assert coordinator.data["integration_status"] == "online_wslink"
|
||||
|
||||
|
||||
def test_update_ingress_result_rejected_default_reason(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
||||
coordinator.update_ingress_result(request, accepted=False, authorized=False)
|
||||
|
||||
ingress = coordinator.data["last_ingress"]
|
||||
assert ingress["accepted"] is False
|
||||
assert ingress["reason"] == "rejected"
|
||||
|
||||
|
||||
def test_update_ingress_result_explicit_reason(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
||||
coordinator.update_ingress_result(
|
||||
request, accepted=False, authorized=None, reason="unauthorized"
|
||||
)
|
||||
|
||||
assert coordinator.data["last_ingress"]["reason"] == "unauthorized"
|
||||
assert coordinator.data["integration_status"] == "degraded"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_forwarding(hass, entry) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
windy = SimpleNamespace(
|
||||
enabled=True, last_status="ok", last_error=None, last_attempt_at="2026-06-20T10:00:00"
|
||||
)
|
||||
pocasi = SimpleNamespace(
|
||||
enabled=False, last_status="disabled", last_error="oops", last_attempt_at=None
|
||||
)
|
||||
|
||||
coordinator.update_forwarding(windy, pocasi)
|
||||
|
||||
forwarding = coordinator.data["forwarding"]
|
||||
assert forwarding["windy"] == {
|
||||
"enabled": True,
|
||||
"last_status": "ok",
|
||||
"last_error": None,
|
||||
"last_attempt_at": "2026-06-20T10:00:00",
|
||||
}
|
||||
assert forwarding["pocasi"]["last_error"] == "oops"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health_status HTTP endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_health_status_endpoint_authenticated(hass, entry, monkeypatch) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
# Avoid network: stub the refresh that health_status awaits.
|
||||
monkeypatch.setattr(coordinator, "async_request_refresh", AsyncMock(return_value=None))
|
||||
|
||||
# aiohttp Request is dict-like; health_status only reads KEY_AUTHENTICATED.
|
||||
request = {KEY_AUTHENTICATED: True}
|
||||
response = await coordinator.health_status(request) # type: ignore[arg-type]
|
||||
|
||||
assert isinstance(response, aiohttp.web.Response)
|
||||
assert response.status == 200
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_health_status_endpoint_rejects_unauthenticated(hass, entry, monkeypatch) -> None:
|
||||
coordinator = HealthCoordinator(hass, entry)
|
||||
_attach_runtime_data(entry, coordinator)
|
||||
|
||||
refresh = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(coordinator, "async_request_refresh", refresh)
|
||||
|
||||
# No KEY_AUTHENTICATED flag -> unauthenticated -> 401, no refresh triggered.
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.health_status({}) # type: ignore[arg-type]
|
||||
|
||||
refresh.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health_sensor.py module helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_path_nested() -> None:
|
||||
data = {"addon": {"online": True}}
|
||||
assert hs._resolve_path(data, ("addon", "online")) is True
|
||||
|
||||
|
||||
def test_resolve_path_missing_key() -> None:
|
||||
data = {"addon": {}}
|
||||
assert hs._resolve_path(data, ("addon", "missing")) is None
|
||||
|
||||
|
||||
def test_resolve_path_intermediate_not_dict() -> None:
|
||||
# Intermediate value is not a dict -> _resolve_path returns None.
|
||||
data = {"addon": "not-a-dict"}
|
||||
assert hs._resolve_path(data, ("addon", "online")) is None
|
||||
|
||||
|
||||
def test_on_off() -> None:
|
||||
assert hs._on_off(True) == "on"
|
||||
assert hs._on_off(False) == "off"
|
||||
assert hs._on_off(None) == "off"
|
||||
|
||||
|
||||
def test_accepted_state() -> None:
|
||||
assert hs._accepted_state(True) == "accepted"
|
||||
assert hs._accepted_state(False) == "rejected"
|
||||
|
||||
|
||||
def test_authorized_state() -> None:
|
||||
assert hs._authorized_state(None) == "unknown"
|
||||
assert hs._authorized_state(True) == "authorized"
|
||||
assert hs._authorized_state(False) == "unauthorized"
|
||||
|
||||
|
||||
def test_timestamp_or_none() -> None:
|
||||
assert hs._timestamp_or_none(123) is None
|
||||
assert hs._timestamp_or_none(None) is None
|
||||
parsed = hs._timestamp_or_none("2026-06-20T10:00:00+00:00")
|
||||
assert isinstance(parsed, datetime)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health_sensor.py async_setup_entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_async_setup_entry_creates_sensors(hass, entry) -> None:
|
||||
coordinator = MagicMock()
|
||||
coordinator.data = {}
|
||||
entry.runtime_data = SWSRuntimeData(
|
||||
coordinator=MagicMock(),
|
||||
health_coordinator=coordinator,
|
||||
last_options={},
|
||||
)
|
||||
|
||||
added: list[Any] = []
|
||||
|
||||
def _add_entities(entities: Any) -> None:
|
||||
added.extend(entities)
|
||||
|
||||
await hs.async_setup_entry(hass, entry, _add_entities)
|
||||
|
||||
assert len(added) == len(hs.HEALTH_SENSOR_DESCRIPTIONS)
|
||||
assert all(isinstance(sensor, hs.HealthDiagnosticSensor) for sensor in added)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HealthDiagnosticSensor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _description(key: str) -> hs.HealthSensorEntityDescription:
|
||||
"""Return the bundled description for `key`."""
|
||||
return next(d for d in hs.HEALTH_SENSOR_DESCRIPTIONS if d.key == key)
|
||||
|
||||
|
||||
def _stub_coordinator(data: dict[str, Any]) -> Any:
|
||||
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices."""
|
||||
return SimpleNamespace(data=data)
|
||||
|
||||
|
||||
def test_sensor_native_value_without_value_fn() -> None:
|
||||
coordinator = _stub_coordinator({"integration_status": "online_wu"})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
||||
assert sensor.native_value == "online_wu"
|
||||
|
||||
|
||||
def test_sensor_native_value_with_value_fn() -> None:
|
||||
coordinator = _stub_coordinator({"addon": {"online": True}})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("wslink_addon_status"))
|
||||
assert sensor.native_value == "online"
|
||||
|
||||
|
||||
def test_sensor_extra_state_attributes_for_integration_health() -> None:
|
||||
data = {"integration_status": "online_wu", "addon": {"online": False}}
|
||||
coordinator = _stub_coordinator(data)
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
||||
assert sensor.extra_state_attributes == data
|
||||
|
||||
|
||||
def test_sensor_extra_state_attributes_for_other_keys() -> None:
|
||||
coordinator = _stub_coordinator({"active_protocol": "wu"})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
||||
assert sensor.extra_state_attributes is None
|
||||
|
||||
|
||||
def test_sensor_device_info() -> None:
|
||||
coordinator = _stub_coordinator({})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
||||
info = sensor.device_info
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["manufacturer"] == "Schizza"
|
||||
assert info["model"] == "Weather Station SWS 12500"
|
||||
|
||||
|
||||
def test_sensor_unique_id_and_category() -> None:
|
||||
coordinator = _stub_coordinator({})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
||||
assert sensor.unique_id == "active_protocol_health"
|
||||
assert sensor.entity_category == hs.EntityCategory.DIAGNOSTIC
|
||||
|
|
@ -15,13 +15,15 @@ to keep these tests focused on setup logic.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry
|
||||
from custom_components.sws12500.const import DOMAIN
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -43,6 +45,14 @@ async def test_async_setup_entry_creates_runtime_state(
|
|||
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
||||
)
|
||||
|
||||
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
|
||||
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
|
||||
# network I/O) is mocked out to keep this test focused on setup wiring.
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
# Avoid depending on Home Assistant integration loader in this test.
|
||||
# This keeps the test focused on our integration's setup behavior.
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -54,9 +64,12 @@ async def test_async_setup_entry_creates_runtime_state(
|
|||
result = await async_setup_entry(hass, config_entry)
|
||||
assert result is True
|
||||
|
||||
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData), not in
|
||||
# hass.data[DOMAIN][entry_id]. hass.data[DOMAIN] only holds shared route state.
|
||||
assert DOMAIN in hass.data
|
||||
assert config_entry.entry_id in hass.data[DOMAIN]
|
||||
assert isinstance(hass.data[DOMAIN][config_entry.entry_id], dict)
|
||||
assert isinstance(config_entry.runtime_data, SWSRuntimeData)
|
||||
assert config_entry.runtime_data.coordinator is not None
|
||||
assert config_entry.runtime_data.health_coordinator is not None
|
||||
|
||||
|
||||
async def test_async_setup_entry_forwards_sensor_platform(
|
||||
|
|
@ -72,6 +85,14 @@ async def test_async_setup_entry_forwards_sensor_platform(
|
|||
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
||||
)
|
||||
|
||||
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
|
||||
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
|
||||
# network I/O) is mocked out to keep this test focused on setup wiring.
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
# Patch forwarding so we don't need to load real platforms for this unit/integration test.
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
|
||||
|
||||
|
|
@ -93,3 +114,38 @@ async def test_weather_data_update_coordinator_can_be_constructed(
|
|||
coordinator = WeatherDataUpdateCoordinator(hass, config_entry)
|
||||
assert coordinator.hass is hass
|
||||
assert coordinator.config is config_entry
|
||||
|
||||
|
||||
async def test_check_stale_callback_runs_update(
|
||||
hass, config_entry: MockConfigEntry, monkeypatch
|
||||
):
|
||||
"""The hourly _check_stale callback registered during setup runs the stale check."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.register_path",
|
||||
lambda _hass, _coordinator, _coordinator_h, _entry: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
|
||||
|
||||
# Capture the time-interval callback async_setup_entry registers.
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(_hass, action, _interval):
|
||||
captured["cb"] = action
|
||||
return lambda: None
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.async_track_time_interval", _capture)
|
||||
|
||||
stale = MagicMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.update_stale_sensors_issue", stale)
|
||||
|
||||
assert await async_setup_entry(hass, config_entry) is True
|
||||
assert "cb" in captured
|
||||
|
||||
captured["cb"](dt_util.utcnow())
|
||||
stale.assert_called_once_with(hass, config_entry)
|
||||
|
|
|
|||
|
|
@ -9,26 +9,23 @@ from aiohttp.web_exceptions import HTTPUnauthorized
|
|||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500 import (
|
||||
HealthCoordinator,
|
||||
IncorrectDataError,
|
||||
WeatherDataUpdateCoordinator,
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
register_path,
|
||||
update_listener,
|
||||
)
|
||||
from custom_components.sws12500 import async_setup_entry, async_unload_entry, register_path, update_listener
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
SENSORS_TO_LOAD,
|
||||
WSLINK,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from custom_components.sws12500.data import ENTRY_COORDINATOR, ENTRY_LAST_OPTIONS
|
||||
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.health_coordinator import HealthCoordinator
|
||||
|
||||
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -70,16 +67,27 @@ def hass_with_http(hass):
|
|||
return hass
|
||||
|
||||
|
||||
def _mock_health_first_refresh(monkeypatch) -> None:
|
||||
"""Calling async_setup_entry directly leaves the entry NOT_LOADED.
|
||||
|
||||
The health coordinator's first refresh requires SETUP_IN_PROGRESS and does network
|
||||
I/O, so we mock it out to keep these lifecycle tests focused on wiring.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
|
||||
# --- register_path ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
API_ID: "id",
|
||||
API_KEY: "key",
|
||||
WSLINK: False,
|
||||
},
|
||||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
|
|
@ -89,21 +97,19 @@ async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_ht
|
|||
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
|
||||
assert ok is True
|
||||
|
||||
# Router registrations
|
||||
# Router registrations: GET for legacy/wslink/health, POST for wslink + ecowitt.
|
||||
router: _RouterStub = hass_with_http.http.app.router
|
||||
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]
|
||||
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL, ECOWITT_PATH]
|
||||
|
||||
# Dispatcher stored
|
||||
# Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN].
|
||||
assert DOMAIN in hass_with_http.data
|
||||
assert "routes" in hass_with_http.data[DOMAIN]
|
||||
routes = hass_with_http.data[DOMAIN]["routes"]
|
||||
routes = hass_with_http.data[DOMAIN].get("routes")
|
||||
assert routes is not None
|
||||
# show_enabled() should return a string
|
||||
assert isinstance(routes.show_enabled(), str)
|
||||
|
||||
|
||||
|
|
@ -116,18 +122,13 @@ async def test_register_path_raises_config_entry_not_ready_on_router_runtime_err
|
|||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
API_ID: "id",
|
||||
API_KEY: "key",
|
||||
WSLINK: False,
|
||||
},
|
||||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||
)
|
||||
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")
|
||||
|
||||
|
|
@ -145,26 +146,24 @@ async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_no
|
|||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={
|
||||
API_ID: "id",
|
||||
API_KEY: "key",
|
||||
WSLINK: False,
|
||||
},
|
||||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||
)
|
||||
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] = []
|
||||
hass_with_http.data[DOMAIN] = [] # wrong type -> checked(..., dict) fails
|
||||
|
||||
with pytest.raises(ConfigEntryNotReady):
|
||||
register_path(hass_with_http, coordinator, coordinator_health, entry)
|
||||
|
||||
|
||||
# --- async_setup_entry -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards_platforms(
|
||||
async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms(
|
||||
hass_with_http,
|
||||
monkeypatch,
|
||||
):
|
||||
|
|
@ -175,7 +174,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
|||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
# Avoid loading actual platforms via HA loader.
|
||||
_mock_health_first_refresh(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
hass_with_http.config_entries,
|
||||
"async_forward_entry_setups",
|
||||
|
|
@ -185,17 +184,14 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
|||
ok = await async_setup_entry(hass_with_http, entry)
|
||||
assert ok is True
|
||||
|
||||
# Runtime storage exists and is a dict
|
||||
assert DOMAIN in hass_with_http.data
|
||||
assert entry.entry_id in hass_with_http.data[DOMAIN]
|
||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
||||
assert isinstance(entry_data, dict)
|
||||
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData).
|
||||
assert isinstance(entry.runtime_data, SWSRuntimeData)
|
||||
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
|
||||
assert isinstance(entry.runtime_data.last_options, dict)
|
||||
|
||||
# Coordinator stored and last options snapshot stored
|
||||
assert isinstance(entry_data.get(ENTRY_COORDINATOR), WeatherDataUpdateCoordinator)
|
||||
assert isinstance(entry_data.get(ENTRY_LAST_OPTIONS), dict)
|
||||
# Shared dispatcher registered under hass.data[DOMAIN].
|
||||
assert "routes" in hass_with_http.data[DOMAIN]
|
||||
|
||||
# Forwarded setups invoked
|
||||
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
|
||||
|
||||
|
||||
|
|
@ -203,12 +199,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards
|
|||
async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||
hass_with_http, monkeypatch
|
||||
):
|
||||
"""Cover the fatal branch when `register_path` returns False.
|
||||
|
||||
async_setup_entry does:
|
||||
routes_enabled = register_path(...)
|
||||
if not routes_enabled: raise PlatformNotReady
|
||||
"""
|
||||
"""Cover the fatal branch when `register_path` returns False -> PlatformNotReady."""
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
|
||||
entry = MockConfigEntry(
|
||||
|
|
@ -218,17 +209,14 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
|||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
# Ensure there are no pre-registered routes so async_setup_entry calls register_path.
|
||||
# No pre-registered routes -> async_setup_entry calls register_path.
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
hass_with_http.data[DOMAIN].pop("routes", None)
|
||||
|
||||
# Force register_path to return False
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.register_path",
|
||||
lambda _hass, _coordinator, _coordinator_h, _entry: False,
|
||||
)
|
||||
|
||||
# Forwarding shouldn't be reached; patch anyway to avoid accidental loader calls.
|
||||
monkeypatch.setattr(
|
||||
hass_with_http.config_entries,
|
||||
"async_forward_entry_setups",
|
||||
|
|
@ -240,10 +228,11 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes(
|
||||
async def test_async_setup_entry_reuses_route_dispatcher_and_switches_protocol(
|
||||
hass_with_http,
|
||||
monkeypatch,
|
||||
):
|
||||
"""On reload the shared route dispatcher is reused; the coordinator is recreated."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
|
|
@ -251,29 +240,19 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes
|
|||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
# Pretend setup already happened and a coordinator exists
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
existing_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
||||
ENTRY_COORDINATOR: existing_coordinator,
|
||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
||||
}
|
||||
# Pre-register routes once (legacy/WU active).
|
||||
initial_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||
initial_health = HealthCoordinator(hass_with_http, entry)
|
||||
register_path(hass_with_http, initial_coordinator, initial_health, entry)
|
||||
routes_before = hass_with_http.data[DOMAIN]["routes"]
|
||||
assert routes_before.path_enabled(DEFAULT_URL) is True
|
||||
|
||||
# Provide pre-registered routes dispatcher
|
||||
routes = hass_with_http.data[DOMAIN].get("routes")
|
||||
if routes is None:
|
||||
# Create a dispatcher via register_path once
|
||||
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.
|
||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
||||
# Switch to WSLink and run setup again.
|
||||
hass_with_http.config_entries.async_update_entry(
|
||||
entry, options={**dict(entry.options), WSLINK: True}
|
||||
)
|
||||
|
||||
# Avoid loading actual platforms via HA loader.
|
||||
_mock_health_first_refresh(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
hass_with_http.config_entries,
|
||||
"async_forward_entry_setups",
|
||||
|
|
@ -283,34 +262,42 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes
|
|||
ok = await async_setup_entry(hass_with_http, entry)
|
||||
assert ok is True
|
||||
|
||||
# Coordinator reused (same object)
|
||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
||||
assert entry_data[ENTRY_COORDINATOR] is existing_coordinator
|
||||
# Same dispatcher object reused (survives across reloads).
|
||||
assert hass_with_http.data[DOMAIN]["routes"] is routes_before
|
||||
# Protocol switched to WSLink.
|
||||
assert routes_before.path_enabled(WSLINK_URL) is True
|
||||
assert routes_before.path_enabled(DEFAULT_URL) is False
|
||||
# A fresh coordinator is wired onto entry.runtime_data.
|
||||
assert isinstance(entry.runtime_data, SWSRuntimeData)
|
||||
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
|
||||
|
||||
|
||||
# --- update_listener -------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_with_runtime(hass, *, options: dict[str, Any]) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
|
||||
entry.add_to_hass(hass)
|
||||
entry.runtime_data = SWSRuntimeData(
|
||||
coordinator=object(), # type: ignore[arg-type]
|
||||
health_coordinator=object(), # type: ignore[arg-type]
|
||||
last_options=dict(options),
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
||||
hass_with_http,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
entry = _entry_with_runtime(
|
||||
hass_with_http,
|
||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
# Seed hass.data snapshot
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
||||
# Seed the full old options snapshot. If we only store SENSORS_TO_LOAD here,
|
||||
# update_listener will detect differences for other keys (e.g. auth keys) and reload.
|
||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
||||
}
|
||||
|
||||
hass_with_http.config_entries.async_reload = AsyncMock()
|
||||
|
||||
# Only SENSORS_TO_LOAD changes.
|
||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
||||
hass_with_http.config_entries.async_update_entry(
|
||||
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
|
||||
)
|
||||
|
|
@ -318,9 +305,8 @@ async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
|||
await update_listener(hass_with_http, entry)
|
||||
|
||||
hass_with_http.config_entries.async_reload.assert_not_awaited()
|
||||
# Snapshot should be updated
|
||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
||||
assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options)
|
||||
# The snapshot on runtime_data is refreshed.
|
||||
assert entry.runtime_data.last_options == dict(entry.options)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -328,22 +314,13 @@ async def test_update_listener_triggers_reload_when_other_option_changes(
|
|||
hass_with_http,
|
||||
monkeypatch,
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
entry = _entry_with_runtime(
|
||||
hass_with_http,
|
||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {
|
||||
ENTRY_LAST_OPTIONS: dict(entry.options),
|
||||
}
|
||||
|
||||
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
||||
|
||||
# Change a different option.
|
||||
# ConfigEntry.options cannot be changed directly; use async_update_entry.
|
||||
hass_with_http.config_entries.async_update_entry(
|
||||
entry, options={**dict(entry.options), WSLINK: True}
|
||||
)
|
||||
|
|
@ -358,76 +335,58 @@ async def test_update_listener_triggers_reload_when_other_option_changes(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listener_missing_snapshot_stores_current_options_then_reloads(
|
||||
hass_with_http,
|
||||
):
|
||||
"""Cover update_listener branch where the options snapshot is missing/invalid.
|
||||
|
||||
This hits:
|
||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||
and then proceeds to reload.
|
||||
"""
|
||||
async def test_update_listener_without_runtime_snapshot_reloads(hass_with_http):
|
||||
"""When runtime_data is not a valid snapshot, update_listener reloads."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
# Store an invalid snapshot type to force the "No/invalid snapshot" branch.
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_LAST_OPTIONS: "invalid"}
|
||||
# Not an SWSRuntimeData instance -> the skip-reload fast path is bypassed.
|
||||
entry.runtime_data = "invalid"
|
||||
|
||||
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
|
||||
|
||||
await update_listener(hass_with_http, entry)
|
||||
|
||||
entry_data = hass_with_http.data[DOMAIN][entry.entry_id]
|
||||
assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options)
|
||||
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_unload_entry_pops_runtime_data_on_success(hass_with_http):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={API_ID: "id", API_KEY: "key"},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
# --- async_unload_entry ----------------------------------------------------
|
||||
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_unload_entry_returns_true_on_success(hass_with_http):
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
||||
|
||||
ok = await async_unload_entry(hass_with_http, entry)
|
||||
|
||||
assert ok is True
|
||||
assert entry.entry_id not in hass_with_http.data[DOMAIN]
|
||||
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_unload_entry_keeps_runtime_data_on_failure(hass_with_http):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={API_ID: "id", API_KEY: "key"},
|
||||
)
|
||||
async def test_async_unload_entry_returns_false_on_failure(hass_with_http):
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
hass_with_http.data.setdefault(DOMAIN, {})
|
||||
hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()}
|
||||
|
||||
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False)
|
||||
|
||||
ok = await async_unload_entry(hass_with_http, entry)
|
||||
|
||||
assert ok is False
|
||||
assert entry.entry_id in hass_with_http.data[DOMAIN]
|
||||
|
||||
|
||||
# --- coordinator auth (lifecycle-adjacent) ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||
"""A few lifecycle-adjacent assertions to cover coordinator auth behavior in __init__.py."""
|
||||
"""Cover coordinator auth behavior reachable from the webhook entrypoint."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
|
|
@ -447,12 +406,34 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
|||
) # type: ignore[arg-type]
|
||||
|
||||
# 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)
|
||||
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
||||
with pytest.raises(IncorrectDataError):
|
||||
await coordinator2.received_data(
|
||||
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@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)."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||
)
|
||||
entry.add_to_hass(hass_with_http)
|
||||
|
||||
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
|
||||
coordinator_health = HealthCoordinator(hass_with_http, entry)
|
||||
|
||||
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
|
||||
router: _RouterStub = hass_with_http.http.app.router
|
||||
get_calls_after_first = list(router.add_get_calls)
|
||||
post_calls_after_first = list(router.add_post_calls)
|
||||
|
||||
# Routes already a Routes instance -> else branch; nothing re-registered on aiohttp.
|
||||
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
|
||||
assert router.add_get_calls == get_calls_after_first
|
||||
assert router.add_post_calls == post_calls_after_first
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500 import IncorrectDataError, WeatherDataUpdateCoordinator
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
|
|
@ -20,6 +19,8 @@ from custom_components.sws12500.const import (
|
|||
WSLINK,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -60,6 +61,18 @@ def _make_entry(
|
|||
entry = SimpleNamespace()
|
||||
entry.entry_id = "test_entry_id"
|
||||
entry.options = options
|
||||
# DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a
|
||||
# config_entry is passed (see WeatherDataUpdateCoordinator.__init__).
|
||||
entry.async_on_unload = lambda *_args, **_kwargs: None
|
||||
# Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0.
|
||||
# received_data writes last_seen and reads health_coordinator / add_*_entities.
|
||||
entry.runtime_data = SimpleNamespace(
|
||||
health_coordinator=None,
|
||||
add_sensor_entities=None,
|
||||
add_binary_entities=None,
|
||||
last_seen={},
|
||||
started_at=dt_util.utcnow(),
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
|
|
@ -135,13 +148,13 @@ async def test_received_data_success_remaps_and_updates_coordinator_data(
|
|||
# Patch remapping so this test doesn't depend on mapping tables.
|
||||
remapped = {"outside_temp": "10"}
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.remap_items",
|
||||
"custom_components.sws12500.coordinator.remap_items",
|
||||
lambda _data: remapped,
|
||||
)
|
||||
|
||||
# Ensure no autodiscovery triggers
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: [],
|
||||
)
|
||||
|
||||
|
|
@ -162,12 +175,12 @@ async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch)
|
|||
|
||||
remapped = {"ws_temp": "1"}
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.remap_wslink_items",
|
||||
"custom_components.sws12500.coordinator.remap_wslink_items",
|
||||
lambda _data: remapped,
|
||||
)
|
||||
# If the wrong remapper is used, we'd crash because we won't patch it:
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: [],
|
||||
)
|
||||
|
||||
|
|
@ -188,11 +201,11 @@ async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch):
|
|||
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.remap_items",
|
||||
"custom_components.sws12500.coordinator.remap_items",
|
||||
lambda _data: {"k": "v"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: [],
|
||||
)
|
||||
|
||||
|
|
@ -216,11 +229,11 @@ async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch):
|
|||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.remap_wslink_items",
|
||||
"custom_components.sws12500.coordinator.remap_wslink_items",
|
||||
lambda _data: {"k": "v"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: [],
|
||||
)
|
||||
|
||||
|
|
@ -246,35 +259,35 @@ async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sen
|
|||
|
||||
# Arrange: remapped payload contains keys that are disabled.
|
||||
remapped = {"a": "1", "b": "2"}
|
||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||
|
||||
# Autodiscovery finds two sensors to add
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: ["a", "b"],
|
||||
)
|
||||
|
||||
# No previously loaded sensors
|
||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||
|
||||
# translations returns a friendly name for each sensor key
|
||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||
# return something non-None so it's included in human readable string
|
||||
return "Name"
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||
|
||||
translated_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.translated_notification", translated_notification
|
||||
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||
|
||||
add_new_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
|
@ -313,19 +326,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check
|
|||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
remapped = {"a": "1"}
|
||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: ["a"],
|
||||
)
|
||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||
|
||||
# Return a translation so the list comprehension would normally include an item.
|
||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||
return "Name"
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||
|
||||
# Force checked(...) to return None when the code tries to validate translate_sensors as list[str].
|
||||
def _checked_override(value, expected_type):
|
||||
|
|
@ -333,19 +346,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check
|
|||
return None
|
||||
return value
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.checked", _checked_override)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.checked", _checked_override)
|
||||
|
||||
translated_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.translated_notification", translated_notification
|
||||
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||
|
||||
add_new_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
|
@ -371,33 +384,33 @@ async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch(
|
|||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
remapped = {"new": "1"}
|
||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||
|
||||
# Autodiscovery finds one new sensor
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: ["new"],
|
||||
)
|
||||
|
||||
# Pretend there are already loaded sensors in options
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.loaded_sensors", lambda _c: ["existing"]
|
||||
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
|
||||
)
|
||||
|
||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||
return "Name"
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.translated_notification", AsyncMock()
|
||||
"custom_components.sws12500.coordinator.translated_notification", AsyncMock()
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.sensor.add_new_sensors", MagicMock()
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
|
@ -424,31 +437,31 @@ async def test_received_data_autodiscovery_translations_all_none_still_notifies_
|
|||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
remapped = {"a": "1"}
|
||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: ["a"],
|
||||
)
|
||||
monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: [])
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
|
||||
|
||||
# Force translations to return None for every lookup -> translate_sensors becomes None and human_readable ""
|
||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.translations", _translations)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
|
||||
|
||||
translated_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.translated_notification", translated_notification
|
||||
"custom_components.sws12500.coordinator.translated_notification", translated_notification
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.update_options", update_options)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
|
||||
|
||||
add_new_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.sensor.add_new_sensors", add_new_sensors
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
|
@ -469,17 +482,17 @@ async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypa
|
|||
entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: {"k": "v"})
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: {"k": "v"})
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.check_disabled",
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _remaped_items, _config: [],
|
||||
)
|
||||
|
||||
anonymize = MagicMock(return_value={"safe": True})
|
||||
monkeypatch.setattr("custom_components.sws12500.anonymize", anonymize)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize)
|
||||
|
||||
log_info = MagicMock()
|
||||
monkeypatch.setattr("custom_components.sws12500._LOGGER.info", log_info)
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,578 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DEV_DBG,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
POCASI_CZ_ENABLED,
|
||||
SENSORS_TO_LOAD,
|
||||
WINDY_ENABLED,
|
||||
WSLINK,
|
||||
)
|
||||
from custom_components.sws12500.coordinator import WeatherDataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _EcowittRequestStub:
|
||||
"""Minimal aiohttp Request stub for the Ecowitt endpoint.
|
||||
|
||||
The coordinator uses `webdata.match_info.get("webhook_id", "")` and
|
||||
`await webdata.post()`.
|
||||
"""
|
||||
|
||||
match_info: dict[str, Any] = field(default_factory=dict)
|
||||
post_data: dict[str, Any] | None = None
|
||||
|
||||
async def post(self) -> dict[str, Any]:
|
||||
return self.post_data or {}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RequestStub:
|
||||
"""Minimal aiohttp Request stub for the legacy endpoint.
|
||||
|
||||
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(
|
||||
*,
|
||||
wslink: bool = False,
|
||||
api_id: str | None = "id",
|
||||
api_key: str | None = "key",
|
||||
windy_enabled: bool = False,
|
||||
pocasi_enabled: bool = False,
|
||||
dev_debug: bool = False,
|
||||
ecowitt_enabled: bool = False,
|
||||
ecowitt_webhook_id: str | None = None,
|
||||
health: Any = None,
|
||||
) -> Any:
|
||||
"""Create a minimal config entry stub with `.options` and `.entry_id`."""
|
||||
options: dict[str, Any] = {
|
||||
WSLINK: wslink,
|
||||
WINDY_ENABLED: windy_enabled,
|
||||
POCASI_CZ_ENABLED: pocasi_enabled,
|
||||
ECOWITT_ENABLED: ecowitt_enabled,
|
||||
DEV_DBG: dev_debug,
|
||||
}
|
||||
if api_id is not None:
|
||||
options[API_ID] = api_id
|
||||
if api_key is not None:
|
||||
options[API_KEY] = api_key
|
||||
if ecowitt_webhook_id is not None:
|
||||
options[ECOWITT_WEBHOOK_ID] = ecowitt_webhook_id
|
||||
|
||||
entry = SimpleNamespace()
|
||||
entry.entry_id = "test_entry_id"
|
||||
entry.options = options
|
||||
# DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a
|
||||
# config_entry is passed (see WeatherDataUpdateCoordinator.__init__).
|
||||
entry.async_on_unload = lambda *_args, **_kwargs: None
|
||||
# Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0.
|
||||
entry.runtime_data = SimpleNamespace(
|
||||
health_coordinator=health,
|
||||
add_sensor_entities=None,
|
||||
add_binary_entities=None,
|
||||
last_seen={},
|
||||
started_at=dt_util.utcnow(),
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def _make_health_stub() -> Any:
|
||||
"""Create a MagicMock-based stub for the HealthCoordinator branches."""
|
||||
return SimpleNamespace(
|
||||
update_ingress_result=MagicMock(),
|
||||
update_forwarding=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# received_ecowitt_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_disabled_returns_403_and_reports_health(hass, monkeypatch):
|
||||
"""Branch 1: Ecowitt disabled -> 403 and health.update_ingress_result(disabled)."""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(ecowitt_enabled=False, health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 403
|
||||
assert resp.text == "Ecowitt disabled"
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_args, kwargs = health.update_ingress_result.call_args
|
||||
assert kwargs["accepted"] is False
|
||||
assert kwargs["authorized"] is None
|
||||
assert kwargs["reason"] == "ecowitt_disabled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_disabled_no_health(hass, monkeypatch):
|
||||
"""Branch 1 without health: covers the `if health` False edge for disabled."""
|
||||
entry = _make_entry(ecowitt_enabled=False, health=None)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_missing_webhook_id_raises_unauthorized(hass, monkeypatch):
|
||||
"""Branch 2: enabled but expected webhook id missing -> HTTPUnauthorized."""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(
|
||||
ecowitt_enabled=True, ecowitt_webhook_id=None, health=health
|
||||
)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_args, kwargs = health.update_ingress_result.call_args
|
||||
assert kwargs["accepted"] is False
|
||||
assert kwargs["authorized"] is False
|
||||
assert kwargs["reason"] == "ecowitt_invalid_webhook_id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_mismatched_webhook_id_raises_unauthorized(hass, monkeypatch):
|
||||
"""Branch 2: enabled but webhook id mismatch -> HTTPUnauthorized (no health)."""
|
||||
entry = _make_entry(
|
||||
ecowitt_enabled=True, ecowitt_webhook_id="expected", health=None
|
||||
)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "wrong"})
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_forwarding_devlog(
|
||||
hass, monkeypatch
|
||||
):
|
||||
"""Branch 3 success: covers lines 132-172.
|
||||
|
||||
- process_payload returns a mapped dict
|
||||
- check_disabled returns new keys -> autodiscovery (update_options,
|
||||
add_new_binary_sensors, add_new_sensors)
|
||||
- async_set_updated_data + last_seen + update_stale_sensors_issue
|
||||
- health.update_ingress_result(accepted) + windy + pocasi forwarding
|
||||
- health.update_forwarding
|
||||
- dev log via anonymize
|
||||
"""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(
|
||||
ecowitt_enabled=True,
|
||||
ecowitt_webhook_id="hook",
|
||||
windy_enabled=True,
|
||||
pocasi_enabled=True,
|
||||
dev_debug=True,
|
||||
health=health,
|
||||
)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
mapped = {"outside_temp": "20"}
|
||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
|
||||
|
||||
# Autodiscovery: one new sensor key.
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _mapped, _config: ["outside_temp"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_options", update_options
|
||||
)
|
||||
add_new_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||
)
|
||||
add_new_binary_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_binary_sensors",
|
||||
add_new_binary_sensors,
|
||||
)
|
||||
update_stale = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
||||
update_stale,
|
||||
)
|
||||
|
||||
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||
|
||||
anonymize = MagicMock(return_value={"safe": True})
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize)
|
||||
log_info = MagicMock()
|
||||
monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
request = _EcowittRequestStub(
|
||||
match_info={"webhook_id": "hook"}, post_data={"tempf": "68"}
|
||||
)
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 200
|
||||
|
||||
coordinator.ecowitt_bridge.process_payload.assert_awaited_once()
|
||||
|
||||
# Autodiscovery side-effects.
|
||||
update_options.assert_awaited_once()
|
||||
args, _kwargs = update_options.await_args
|
||||
assert args[2] == SENSORS_TO_LOAD
|
||||
assert "outside_temp" in args[3]
|
||||
add_new_sensors.assert_called_once_with(hass, entry, ["outside_temp"])
|
||||
add_new_binary_sensors.assert_called_once_with(hass, entry, ["outside_temp"])
|
||||
|
||||
# Coordinator data + staleness + last_seen.
|
||||
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
||||
update_stale.assert_called_once()
|
||||
assert "outside_temp" in entry.runtime_data.last_seen
|
||||
|
||||
# Forwarding: windy receives the raw data dict + False, pocasi receives "WU".
|
||||
coordinator.windy.push_data_to_windy.assert_awaited_once()
|
||||
w_args, _ = coordinator.windy.push_data_to_windy.await_args
|
||||
assert isinstance(w_args[0], dict)
|
||||
assert w_args[1] is False
|
||||
coordinator.pocasi.push_data_to_server.assert_awaited_once()
|
||||
p_args, _ = coordinator.pocasi.push_data_to_server.await_args
|
||||
assert p_args[1] == "WU"
|
||||
|
||||
# Health branches.
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_ia, ikw = health.update_ingress_result.call_args
|
||||
assert ikw["accepted"] is True
|
||||
assert ikw["authorized"] is True
|
||||
assert ikw["reason"] == "accepted"
|
||||
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
|
||||
|
||||
# Dev log.
|
||||
anonymize.assert_called_once()
|
||||
log_info.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding(
|
||||
hass, monkeypatch
|
||||
):
|
||||
"""Branch 3 success with all optional branches False.
|
||||
|
||||
- health is None (skips both `if health` blocks)
|
||||
- check_disabled returns [] (skips autodiscovery body)
|
||||
- windy/pocasi disabled (skips forwarding)
|
||||
- dev debug off (skips dev log)
|
||||
"""
|
||||
entry = _make_entry(
|
||||
ecowitt_enabled=True,
|
||||
ecowitt_webhook_id="hook",
|
||||
windy_enabled=False,
|
||||
pocasi_enabled=False,
|
||||
dev_debug=False,
|
||||
health=None,
|
||||
)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
mapped = {"outside_temp": "20"}
|
||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _mapped, _config: [],
|
||||
)
|
||||
update_stale = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
||||
update_stale,
|
||||
)
|
||||
|
||||
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 200
|
||||
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
||||
update_stale.assert_called_once()
|
||||
coordinator.windy.push_data_to_windy.assert_not_awaited()
|
||||
coordinator.pocasi.push_data_to_server.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_autodiscovery_extends_with_loaded_sensors(hass, monkeypatch):
|
||||
"""Line 138: cover the `_loaded_sensors := loaded_sensors(...)` extend branch."""
|
||||
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook", health=None)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
mapped = {"new": "1"}
|
||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _mapped, _config: ["new"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
|
||||
)
|
||||
|
||||
update_options = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_options", update_options
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
|
||||
)
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 200
|
||||
update_options.assert_awaited_once()
|
||||
args, _kwargs = update_options.await_args
|
||||
assert args[2] == SENSORS_TO_LOAD
|
||||
assert set(args[3]) >= {"new", "existing"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_coordinator_attribute_error_returns_none(hass, monkeypatch):
|
||||
"""Lines 92-93: runtime_data without health_coordinator -> AttributeError -> None."""
|
||||
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook")
|
||||
# Replace runtime_data with one that lacks `health_coordinator`.
|
||||
entry.runtime_data = SimpleNamespace(last_seen={})
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
assert coordinator._health_coordinator() is None
|
||||
|
||||
mapped = {"outside_temp": "20"}
|
||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _mapped, _config: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
|
||||
)
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatch):
|
||||
"""Branch 3 success but process_payload returns empty -> skips the `if mapped_data` block."""
|
||||
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook", health=None)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={})
|
||||
|
||||
update_stale = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
||||
update_stale,
|
||||
)
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 200
|
||||
coordinator.async_set_updated_data.assert_not_called()
|
||||
update_stale.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# received_data health branches (lines 196, 207, 225, 236, 252, 304, 321)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_wu_missing_credentials_reports_health(hass, monkeypatch):
|
||||
"""Line 196: WU missing credentials -> health.update_ingress_result."""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=False, health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_data(_RequestStub(query={"foo": "bar"})) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["reason"] == "missing_credentials"
|
||||
assert kw["accepted"] is False
|
||||
assert kw["authorized"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_wslink_missing_credentials_reports_health(hass, monkeypatch):
|
||||
"""Line 207: WSLink missing credentials -> health.update_ingress_result."""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=True, health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_data(_RequestStub(query={"foo": "bar"})) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["reason"] == "missing_credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_missing_api_id_reports_health(hass, monkeypatch):
|
||||
"""Line 225: missing API ID -> health.update_ingress_result(config_missing_api_id)."""
|
||||
from custom_components.sws12500.coordinator import IncorrectDataError
|
||||
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=False, api_id=None, api_key="key", health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
with pytest.raises(IncorrectDataError):
|
||||
await coordinator.received_data(
|
||||
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["reason"] == "config_missing_api_id"
|
||||
assert kw["authorized"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_missing_api_key_reports_health(hass, monkeypatch):
|
||||
"""Line 236: missing API KEY -> health.update_ingress_result(config_missing_api_key)."""
|
||||
from custom_components.sws12500.coordinator import IncorrectDataError
|
||||
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=False, api_id="id", api_key=None, health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
with pytest.raises(IncorrectDataError):
|
||||
await coordinator.received_data(
|
||||
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["reason"] == "config_missing_api_key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_wrong_credentials_reports_health(hass, monkeypatch):
|
||||
"""Line 252: wrong credentials -> health.update_ingress_result(unauthorized)."""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=False, api_id="id", api_key="key", health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_data(
|
||||
_RequestStub(query={"ID": "id", "PASSWORD": "wrong"})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
health.update_ingress_result.assert_called_once()
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["reason"] == "unauthorized"
|
||||
assert kw["authorized"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_received_data_success_with_health_autodiscovery_and_binary(hass, monkeypatch):
|
||||
"""Lines 304 + 321: success path with health stub and autodiscovery.
|
||||
|
||||
- check_disabled returns a key -> add_new_binary_sensors (line 294/304-region)
|
||||
- health accepted branch (line 304) + health.update_forwarding (line 321)
|
||||
"""
|
||||
health = _make_health_stub()
|
||||
entry = _make_entry(wslink=False, api_id="id", api_key="key", health=health)
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
remapped = {"x": "1"}
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.remap_items", lambda _d: remapped
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.check_disabled",
|
||||
lambda _r, _c: ["x"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []
|
||||
)
|
||||
|
||||
async def _translations(_hass, _domain, _key, **_kwargs):
|
||||
return "Name"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.translations", _translations
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.translated_notification", AsyncMock()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.update_options", AsyncMock()
|
||||
)
|
||||
add_new_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
|
||||
)
|
||||
add_new_binary_sensors = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.coordinator.add_new_binary_sensors",
|
||||
add_new_binary_sensors,
|
||||
)
|
||||
|
||||
coordinator.async_set_updated_data = MagicMock()
|
||||
|
||||
resp = await coordinator.received_data(
|
||||
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert resp.status == 200
|
||||
add_new_binary_sensors.assert_called_once()
|
||||
|
||||
# Health accepted (line 304) and forwarding (line 321).
|
||||
assert health.update_ingress_result.call_count == 1
|
||||
_a, kw = health.update_ingress_result.call_args
|
||||
assert kw["accepted"] is True
|
||||
assert kw["reason"] == "accepted"
|
||||
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""Additional Routes coverage: __str__, ingress observer calls, canonical fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aiohttp.web import Response
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.routes import RouteInfo, Routes
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RouteStub:
|
||||
method: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RequestStub:
|
||||
method: str
|
||||
path: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def routes() -> Routes:
|
||||
return Routes()
|
||||
|
||||
|
||||
async def _handler(_request) -> Response:
|
||||
return Response(text="OK", status=200)
|
||||
|
||||
|
||||
def test_routeinfo_str_contains_fields() -> None:
|
||||
info = RouteInfo("/a", route=_RouteStub(method="GET"), handler=_handler, enabled=True)
|
||||
text = str(info)
|
||||
assert "RouteInfo(" in text
|
||||
assert "url_path=/a" in text
|
||||
assert "enabled=True" in text
|
||||
|
||||
|
||||
async def test_dispatch_unknown_path_notifies_observer(routes: Routes) -> None:
|
||||
observer = MagicMock()
|
||||
routes.set_ingress_observer(observer)
|
||||
|
||||
await routes.dispatch(_RequestStub(method="GET", path="/nope")) # type: ignore[arg-type]
|
||||
|
||||
observer.assert_called_once()
|
||||
args = observer.call_args.args
|
||||
assert args[1] is False
|
||||
assert args[2] == "route_not_registered"
|
||||
|
||||
|
||||
async def test_dispatch_known_path_notifies_observer(routes: Routes) -> None:
|
||||
routes.add_route("/a", _RouteStub(method="GET"), _handler, enabled=True)
|
||||
observer = MagicMock()
|
||||
routes.set_ingress_observer(observer)
|
||||
|
||||
resp = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
|
||||
assert resp.status == 200
|
||||
|
||||
observer.assert_called_once()
|
||||
args = observer.call_args.args
|
||||
assert args[1] is True
|
||||
assert args[2] is None
|
||||
|
||||
|
||||
async def test_dispatch_resolves_via_canonical_resource(routes: Routes) -> None:
|
||||
"""A path with a parameter resolves through the aiohttp resource canonical URL."""
|
||||
canonical = "/weatherhub/{webhook_id}"
|
||||
routes.add_route(canonical, _RouteStub(method="POST"), _handler, enabled=True)
|
||||
|
||||
# Direct key "POST:/weatherhub/abc" is absent; fall back to resource.canonical.
|
||||
request = SimpleNamespace(
|
||||
method="POST",
|
||||
path="/weatherhub/abc",
|
||||
match_info=SimpleNamespace(route=SimpleNamespace(resource=SimpleNamespace(canonical=canonical))),
|
||||
)
|
||||
|
||||
resp = await routes.dispatch(request) # type: ignore[arg-type]
|
||||
assert resp.status == 200
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -17,11 +17,7 @@ from custom_components.sws12500.const import (
|
|||
WIND_SPEED,
|
||||
WSLINK,
|
||||
)
|
||||
from custom_components.sws12500.data import (
|
||||
ENTRY_ADD_ENTITIES,
|
||||
ENTRY_COORDINATOR,
|
||||
ENTRY_DESCRIPTIONS,
|
||||
)
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.sensor import (
|
||||
WeatherSensor,
|
||||
_auto_enable_derived_sensors,
|
||||
|
|
@ -32,25 +28,55 @@ from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
|
|||
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ConfigEntryStub:
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
class _EcowittBridgeStub:
|
||||
"""Records the platform callback the sensor setup wires into the bridge."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.add_entities_cb: Any = None
|
||||
|
||||
def set_add_entities(self, callback: Any) -> None:
|
||||
self.add_entities_cb = callback
|
||||
|
||||
|
||||
class _CoordinatorStub:
|
||||
"""Minimal coordinator stub for WeatherSensor and platform setup."""
|
||||
|
||||
def __init__(
|
||||
self, data: dict[str, Any] | None = None, *, config: Any | None = None
|
||||
) -> None:
|
||||
def __init__(self, data: dict[str, Any] | None = None, *, options: dict[str, Any] | None = None) -> None:
|
||||
self.data = data if data is not None else {}
|
||||
self.config = config
|
||||
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag.
|
||||
self.config = SimpleNamespace(options=options if options is not None else {})
|
||||
self.ecowitt_bridge = _EcowittBridgeStub()
|
||||
|
||||
|
||||
class _HealthCoordinatorStub:
|
||||
"""Stand-in for HealthCoordinator (health diagnostic sensors subscribe to it)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _make_entry(
|
||||
*, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None
|
||||
) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]:
|
||||
"""Build a config-entry stub carrying typed runtime_data, like the integration does."""
|
||||
coordinator = coordinator or _CoordinatorStub()
|
||||
runtime = SWSRuntimeData(
|
||||
coordinator=coordinator, # type: ignore[arg-type]
|
||||
health_coordinator=_HealthCoordinatorStub(), # type: ignore[arg-type]
|
||||
last_options={},
|
||||
)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="test_entry_id",
|
||||
options=options if options is not None else {},
|
||||
runtime_data=runtime,
|
||||
)
|
||||
return entry, coordinator, runtime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hass():
|
||||
# Use a very small hass-like object; sensor platform uses only `hass.data`.
|
||||
# Sensor platform setup only forwards hass to health_sensor.async_setup_entry,
|
||||
# which ignores it, and add_new_sensors deletes it. A tiny stub is enough.
|
||||
class _Hass:
|
||||
def __init__(self) -> None:
|
||||
self.data: dict[str, Any] = {}
|
||||
|
|
@ -58,11 +84,6 @@ def hass():
|
|||
return _Hass()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_entry() -> _ConfigEntryStub:
|
||||
return _ConfigEntryStub(entry_id="test_entry_id", options={})
|
||||
|
||||
|
||||
def _capture_add_entities():
|
||||
captured: list[Any] = []
|
||||
|
||||
|
|
@ -72,207 +93,118 @@ def _capture_add_entities():
|
|||
return captured, _add_entities
|
||||
|
||||
|
||||
def _weather_keys(captured: list[Any]) -> set[str]:
|
||||
return {e.entity_description.key for e in captured if isinstance(e, WeatherSensor)}
|
||||
|
||||
|
||||
# --- _auto_enable_derived_sensors ------------------------------------------
|
||||
|
||||
|
||||
def test_auto_enable_derived_sensors_wind_azimut():
|
||||
requested = {WIND_DIR}
|
||||
expanded = _auto_enable_derived_sensors(requested)
|
||||
expanded = _auto_enable_derived_sensors({WIND_DIR})
|
||||
assert WIND_DIR in expanded
|
||||
assert WIND_AZIMUT in expanded
|
||||
|
||||
|
||||
def test_auto_enable_derived_sensors_heat_index():
|
||||
requested = {OUTSIDE_TEMP, OUTSIDE_HUMIDITY}
|
||||
expanded = _auto_enable_derived_sensors(requested)
|
||||
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY})
|
||||
assert HEAT_INDEX in expanded
|
||||
|
||||
|
||||
def test_auto_enable_derived_sensors_chill_index():
|
||||
requested = {OUTSIDE_TEMP, WIND_SPEED}
|
||||
expanded = _auto_enable_derived_sensors(requested)
|
||||
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED})
|
||||
assert CHILL_INDEX in expanded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_async_setup_entry_no_coordinator_is_noop(hass, config_entry):
|
||||
# No entry dict created by integration yet; async_setup_entry should be defensive and no-op.
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
await async_setup_entry(hass, config_entry, add_entities)
|
||||
|
||||
assert captured == []
|
||||
# --- async_setup_entry -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_async_setup_entry_stores_callback_and_descriptions_even_if_no_sensors_to_load(
|
||||
hass, config_entry
|
||||
):
|
||||
# Prepare runtime entry data and coordinator like integration does.
|
||||
hass.data.setdefault("sws12500", {})
|
||||
hass.data["sws12500"][config_entry.entry_id] = {
|
||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
||||
}
|
||||
|
||||
async def test_setup_stores_callback_and_descriptions_even_without_sensors_to_load(hass):
|
||||
entry, coordinator, runtime = _make_entry()
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
# No SENSORS_TO_LOAD set -> early return, but it should still store callback + descriptions.
|
||||
await async_setup_entry(hass, config_entry, add_entities)
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
entry_data = hass.data["sws12500"][config_entry.entry_id]
|
||||
assert entry_data[ENTRY_ADD_ENTITIES] is add_entities
|
||||
assert isinstance(entry_data[ENTRY_DESCRIPTIONS], dict)
|
||||
assert captured == []
|
||||
# Callback + description map persisted for dynamic entity creation.
|
||||
assert runtime.add_sensor_entities is add_entities
|
||||
assert isinstance(runtime.sensor_descriptions, dict)
|
||||
# Ecowitt bridge wired up even though there are no sensors to load yet.
|
||||
assert coordinator.ecowitt_bridge.add_entities_cb is add_entities
|
||||
# No weather sensors created (only health diagnostics, which we ignore here).
|
||||
assert _weather_keys(captured) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_async_setup_entry_selects_weather_api_descriptions_when_wslink_disabled(
|
||||
hass, config_entry
|
||||
):
|
||||
hass.data.setdefault("sws12500", {})
|
||||
hass.data["sws12500"][config_entry.entry_id] = {
|
||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
||||
}
|
||||
async def test_setup_selects_weather_api_descriptions_when_wslink_disabled(hass):
|
||||
entry, _coordinator, runtime = _make_entry(options={WSLINK: False})
|
||||
_captured, add_entities = _capture_add_entities()
|
||||
|
||||
captured, add_entities = _capture_add_entities()
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
# Explicitly disabled WSLINK
|
||||
config_entry.options[WSLINK] = False
|
||||
|
||||
await async_setup_entry(hass, config_entry, add_entities)
|
||||
|
||||
descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS]
|
||||
assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
|
||||
assert captured == []
|
||||
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_async_setup_entry_selects_wslink_descriptions_when_wslink_enabled(
|
||||
hass, config_entry
|
||||
):
|
||||
hass.data.setdefault("sws12500", {})
|
||||
hass.data["sws12500"][config_entry.entry_id] = {
|
||||
ENTRY_COORDINATOR: _CoordinatorStub()
|
||||
}
|
||||
async def test_setup_selects_wslink_descriptions_when_wslink_enabled(hass):
|
||||
entry, _coordinator, runtime = _make_entry(options={WSLINK: True})
|
||||
_captured, add_entities = _capture_add_entities()
|
||||
|
||||
captured, add_entities = _capture_add_entities()
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
config_entry.options[WSLINK] = True
|
||||
|
||||
await async_setup_entry(hass, config_entry, add_entities)
|
||||
|
||||
descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS]
|
||||
assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
|
||||
assert captured == []
|
||||
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_async_setup_entry_adds_requested_entities_and_auto_enables_derived(
|
||||
hass, config_entry
|
||||
):
|
||||
hass.data.setdefault("sws12500", {})
|
||||
coordinator = _CoordinatorStub()
|
||||
hass.data["sws12500"][config_entry.entry_id] = {ENTRY_COORDINATOR: coordinator}
|
||||
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
# Request WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED -> should auto-add derived keys too.
|
||||
config_entry.options[WSLINK] = False
|
||||
config_entry.options[SENSORS_TO_LOAD] = [
|
||||
WIND_DIR,
|
||||
OUTSIDE_TEMP,
|
||||
OUTSIDE_HUMIDITY,
|
||||
WIND_SPEED,
|
||||
]
|
||||
|
||||
await async_setup_entry(hass, config_entry, add_entities)
|
||||
|
||||
# We should have at least those requested + derived in the added entities.
|
||||
keys_added = {
|
||||
e.entity_description.key for e in captured if isinstance(e, WeatherSensor)
|
||||
}
|
||||
assert WIND_DIR in keys_added
|
||||
assert OUTSIDE_TEMP in keys_added
|
||||
assert OUTSIDE_HUMIDITY in keys_added
|
||||
assert WIND_SPEED in keys_added
|
||||
|
||||
# Derived:
|
||||
assert WIND_AZIMUT in keys_added
|
||||
assert HEAT_INDEX in keys_added
|
||||
assert CHILL_INDEX in keys_added
|
||||
|
||||
|
||||
def test_add_new_sensors_is_noop_when_domain_missing(hass, config_entry):
|
||||
called = False
|
||||
|
||||
def add_entities(_entities: list[Any]) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
# No hass.data["sws12500"] at all.
|
||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_add_new_sensors_is_noop_when_entry_missing(hass, config_entry):
|
||||
hass.data["sws12500"] = {}
|
||||
called = False
|
||||
|
||||
def add_entities(_entities: list[Any]) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_add_new_sensors_is_noop_when_callback_or_descriptions_missing(
|
||||
hass, config_entry
|
||||
):
|
||||
hass.data["sws12500"] = {
|
||||
config_entry.entry_id: {ENTRY_COORDINATOR: _CoordinatorStub()}
|
||||
}
|
||||
called = False
|
||||
|
||||
def add_entities(_entities: list[Any]) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
# Missing ENTRY_ADD_ENTITIES + ENTRY_DESCRIPTIONS -> no-op.
|
||||
add_new_sensors(hass, config_entry, keys=["anything"])
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_add_new_sensors_ignores_unknown_keys(hass, config_entry):
|
||||
hass.data["sws12500"] = {
|
||||
config_entry.entry_id: {
|
||||
ENTRY_COORDINATOR: _CoordinatorStub(),
|
||||
ENTRY_ADD_ENTITIES: MagicMock(),
|
||||
ENTRY_DESCRIPTIONS: {}, # nothing known
|
||||
async def test_setup_adds_requested_entities_and_auto_enables_derived(hass):
|
||||
entry, _coordinator, _runtime = _make_entry(
|
||||
options={
|
||||
WSLINK: False,
|
||||
SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED],
|
||||
}
|
||||
}
|
||||
)
|
||||
captured, add_entities = _capture_add_entities()
|
||||
|
||||
add_new_sensors(hass, config_entry, keys=["unknown_key"])
|
||||
await async_setup_entry(hass, entry, add_entities)
|
||||
|
||||
hass.data["sws12500"][config_entry.entry_id][ENTRY_ADD_ENTITIES].assert_not_called()
|
||||
keys_added = _weather_keys(captured)
|
||||
# Requested.
|
||||
assert {WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED} <= keys_added
|
||||
# Derived.
|
||||
assert {WIND_AZIMUT, HEAT_INDEX, CHILL_INDEX} <= keys_added
|
||||
|
||||
|
||||
def test_add_new_sensors_adds_known_keys(hass, config_entry):
|
||||
coordinator = _CoordinatorStub()
|
||||
# --- add_new_sensors -------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_new_sensors_is_noop_when_callback_missing(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
# Platform not set up yet -> no stored callback.
|
||||
assert runtime.add_sensor_entities is None
|
||||
|
||||
# Must not raise.
|
||||
add_new_sensors(hass, entry, keys=["anything"])
|
||||
|
||||
|
||||
def test_add_new_sensors_ignores_unknown_keys(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
add_entities = MagicMock()
|
||||
runtime.add_sensor_entities = add_entities
|
||||
runtime.sensor_descriptions = {} # nothing known
|
||||
|
||||
# Use one known description from the weather API list.
|
||||
add_new_sensors(hass, entry, keys=["unknown_key"])
|
||||
|
||||
add_entities.assert_not_called()
|
||||
|
||||
|
||||
def test_add_new_sensors_adds_known_keys(hass):
|
||||
entry, _coordinator, runtime = _make_entry()
|
||||
add_entities = MagicMock()
|
||||
known_desc = SENSOR_TYPES_WEATHER_API[0]
|
||||
runtime.add_sensor_entities = add_entities
|
||||
runtime.sensor_descriptions = {known_desc.key: known_desc}
|
||||
|
||||
hass.data["sws12500"] = {
|
||||
config_entry.entry_id: {
|
||||
ENTRY_COORDINATOR: coordinator,
|
||||
ENTRY_ADD_ENTITIES: add_entities,
|
||||
ENTRY_DESCRIPTIONS: {known_desc.key: known_desc},
|
||||
}
|
||||
}
|
||||
|
||||
add_new_sensors(hass, config_entry, keys=[known_desc.key])
|
||||
add_new_sensors(hass, entry, keys=[known_desc.key])
|
||||
|
||||
add_entities.assert_called_once()
|
||||
(entities_arg,) = add_entities.call_args.args
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
"""Tests for stale-sensor and legacy-battery Repairs issues.
|
||||
|
||||
Covers:
|
||||
- staleness.py: warmup short-circuit, stale detection (never seen / seen long ago),
|
||||
fresh sensors clearing the issue, and the create/delete branches of
|
||||
`update_stale_sensors_issue`.
|
||||
- legacy.py: orphan legacy battery sensor detection and the create/delete branches
|
||||
of `update_legacy_battery_issue`.
|
||||
|
||||
Uses the real `hass` fixture so the issue/entity registries behave like production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500.const import DOMAIN, SENSORS_TO_LOAD
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.legacy import _legacy_battery_issue_id, update_legacy_battery_issue
|
||||
from custom_components.sws12500.staleness import _find_stale_keys, _stale_sensor_issue_id, update_stale_sensors_issue
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def _make_entry(hass, options: dict) -> MockConfigEntry:
|
||||
"""Create a config entry with typed runtime data attached and added to hass."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
|
||||
entry.add_to_hass(hass)
|
||||
entry.runtime_data = SWSRuntimeData(
|
||||
coordinator=object(), # type: ignore[arg-type]
|
||||
health_coordinator=object(), # type: ignore[arg-type]
|
||||
last_options=dict(options),
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# staleness.py
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_warmup_returns_no_stale_keys_and_no_issue(hass):
|
||||
"""During the warmup period no key is considered stale and no issue is raised."""
|
||||
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
# started_at = now -> within WARMUP_PERIOD even with a never-seen key.
|
||||
entry.runtime_data.started_at = dt_util.utcnow()
|
||||
|
||||
assert _find_stale_keys(entry) == []
|
||||
|
||||
update_stale_sensors_issue(hass, entry)
|
||||
|
||||
issue_id = _stale_sensor_issue_id(entry)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_never_seen_key_is_stale_and_creates_issue(hass):
|
||||
"""Past warmup, a loaded key that was never seen is stale -> issue created."""
|
||||
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
|
||||
# last_seen left empty -> outside_temp never reported.
|
||||
|
||||
assert _find_stale_keys(entry) == ["outside_temp"]
|
||||
|
||||
update_stale_sensors_issue(hass, entry)
|
||||
|
||||
issue_id = _stale_sensor_issue_id(entry)
|
||||
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "stale_sensors_detected"
|
||||
assert issue.translation_placeholders == {"sensors": "outside_temp"}
|
||||
|
||||
|
||||
async def test_recently_seen_key_is_not_stale_and_clears_issue(hass):
|
||||
"""Past warmup, a key seen just now is fresh -> issue absent/cleared."""
|
||||
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
|
||||
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow()}
|
||||
|
||||
assert _find_stale_keys(entry) == []
|
||||
|
||||
update_stale_sensors_issue(hass, entry)
|
||||
|
||||
issue_id = _stale_sensor_issue_id(entry)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_key_seen_long_ago_is_stale(hass):
|
||||
"""A key last seen beyond STALE_THRESHOLD (24h) is stale."""
|
||||
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
|
||||
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow() - timedelta(hours=48)}
|
||||
|
||||
assert _find_stale_keys(entry) == ["outside_temp"]
|
||||
|
||||
|
||||
async def test_existing_stale_issue_is_deleted_when_keys_become_fresh(hass):
|
||||
"""A previously-created stale issue is cleared once the sensor reports again."""
|
||||
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
|
||||
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
|
||||
|
||||
# First pass: stale -> issue created.
|
||||
update_stale_sensors_issue(hass, entry)
|
||||
issue_id = _stale_sensor_issue_id(entry)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
# Sensor reports -> second pass clears the issue.
|
||||
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow()}
|
||||
update_stale_sensors_issue(hass, entry)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# legacy.py
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_legacy_battery_sensor_creates_issue(hass):
|
||||
"""A legacy (non-binary) battery sensor in the entity registry raises an issue."""
|
||||
entry = _make_entry(hass, {})
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
ent_reg.async_get_or_create("sensor", DOMAIN, "outside_battery", config_entry=entry)
|
||||
|
||||
update_legacy_battery_issue(hass, entry)
|
||||
|
||||
issue_id = _legacy_battery_issue_id(entry)
|
||||
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "legacy_battery_sensor_deprecated"
|
||||
assert issue.translation_placeholders is not None
|
||||
assert issue.translation_placeholders["remove_version"] == "2.1.0"
|
||||
|
||||
|
||||
async def test_no_legacy_battery_sensor_clears_issue(hass):
|
||||
"""With no legacy battery sensor present, the issue is deleted/absent."""
|
||||
entry = _make_entry(hass, {})
|
||||
|
||||
update_legacy_battery_issue(hass, entry)
|
||||
|
||||
issue_id = _legacy_battery_issue_id(entry)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
|
@ -70,11 +70,13 @@ def test_t9_keys_are_remapped() -> None:
|
|||
|
||||
|
||||
def test_connection_gated_sensors_definition() -> None:
|
||||
assert CONNECTION_GATED_SENSORS == {"t9cn": [HCHO, VOC, T9_BATTERY]}
|
||||
# The T9 HCHO/VOC probe is gated by its own connection flag. (Multi-channel
|
||||
# CH2-CH8 probes have their own gates too; we only assert the T9 one here.)
|
||||
assert CONNECTION_GATED_SENSORS["t9cn"] == [HCHO, VOC, T9_BATTERY]
|
||||
|
||||
|
||||
def test_t9_battery_is_non_binary_only() -> None:
|
||||
assert BATTERY_NON_BINARY == [T9_BATTERY]
|
||||
assert BATTERY_NON_BINARY == (T9_BATTERY,)
|
||||
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
||||
assert T9_BATTERY not in BATTERY_LIST
|
||||
|
||||
|
|
@ -84,7 +86,7 @@ def test_voc_level_map_is_complete_and_ordered() -> None:
|
|||
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
|
||||
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
|
||||
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
|
||||
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELENT
|
||||
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELLENT
|
||||
assert [member.value for member in VOCLevel] == [
|
||||
"unhealthy",
|
||||
"poor",
|
||||
|
|
@ -109,7 +111,7 @@ def test_voc_level_to_text_handles_empty(empty) -> None:
|
|||
("2", VOCLevel.POOR),
|
||||
("3", VOCLevel.MODERATE),
|
||||
("4", VOCLevel.GOOD),
|
||||
("5", VOCLevel.EXCELENT),
|
||||
("5", VOCLevel.EXCELLENT),
|
||||
(3, VOCLevel.MODERATE),
|
||||
],
|
||||
)
|
||||
|
|
@ -184,8 +186,8 @@ def test_hcho_entity_description(wslink_descriptions) -> None:
|
|||
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
|
||||
assert description.state_class is SensorStateClass.MEASUREMENT
|
||||
# value_fn is a pass-through (typing.cast is a no-op at runtime; HA coerces the str)
|
||||
assert description.value_fn("57") == "57"
|
||||
# HCHO is a numeric ppb concentration, so value_fn coerces to int.
|
||||
assert description.value_fn("57") == 57
|
||||
|
||||
|
||||
def test_voc_entity_description(wslink_descriptions) -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
"""Coverage for to_int / to_float edge cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.utils import to_float, to_int
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("x", None),
|
||||
("5", 5),
|
||||
(7, 7),
|
||||
],
|
||||
)
|
||||
def test_to_int_edge_cases(value, expected) -> None:
|
||||
assert to_int(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("x", None),
|
||||
("5.5", 5.5),
|
||||
(7, 7.0),
|
||||
],
|
||||
)
|
||||
def test_to_float_edge_cases(value, expected) -> None:
|
||||
assert to_float(value) == expected
|
||||
|
|
@ -5,8 +5,6 @@ from types import SimpleNamespace
|
|||
from typing import Any, Callable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.const import DOMAIN
|
||||
from custom_components.sws12500.sensor import WeatherSensor
|
||||
|
||||
|
|
@ -33,7 +31,9 @@ class _CoordinatorStub:
|
|||
self, data: dict[str, Any] | None = None, *, config: Any | None = None
|
||||
):
|
||||
self.data = data if data is not None else {}
|
||||
self.config = config
|
||||
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag,
|
||||
# so default to a config with empty options when the test doesn't supply one.
|
||||
self.config = config if config is not None else SimpleNamespace(options={})
|
||||
|
||||
|
||||
def test_native_value_prefers_value_from_data_fn_success():
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
"""Additional Windy branch coverage: duplicate (409), rate limit (429), 3-strike disable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeResponse:
|
||||
status: int
|
||||
|
||||
async def __aenter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, response: _FakeResponse) -> None:
|
||||
self._response = response
|
||||
|
||||
def get(self, url: str, *, params=None, headers=None):
|
||||
return self._response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hass():
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
def _make_entry(**options: Any):
|
||||
defaults = {
|
||||
WINDY_LOGGER_ENABLED: False,
|
||||
WINDY_ENABLED: True,
|
||||
WINDY_STATION_ID: "station",
|
||||
WINDY_STATION_PW: "token",
|
||||
}
|
||||
defaults.update(options)
|
||||
return SimpleNamespace(options=defaults)
|
||||
|
||||
|
||||
def test_verify_response_duplicate_raises(hass):
|
||||
wp = WindyPush(hass, _make_entry())
|
||||
with pytest.raises(WindyDuplicatePayloadDetected):
|
||||
wp.verify_windy_response(_FakeResponse(status=409))
|
||||
|
||||
|
||||
def test_verify_response_rate_limit_raises(hass):
|
||||
wp = WindyPush(hass, _make_entry())
|
||||
with pytest.raises(WindyRateLimitExceeded):
|
||||
wp.verify_windy_response(_FakeResponse(status=429))
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
lambda _h: _FakeSession(_FakeResponse(status=409)),
|
||||
)
|
||||
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
assert ok is True
|
||||
assert wp.last_status == "duplicate"
|
||||
assert wp.invalid_response_count == 1
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
lambda _h: _FakeSession(_FakeResponse(status=429)),
|
||||
)
|
||||
|
||||
before = datetime.now()
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
assert ok is True
|
||||
assert wp.last_status == "rate_limited_remote"
|
||||
# next_update pushed ~5 minutes out by the rate-limit handler.
|
||||
assert wp.next_update > before + timedelta(minutes=4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
|
||||
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(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
lambda _h: _FakeSession(_FakeResponse(status=409)),
|
||||
)
|
||||
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
assert ok is True
|
||||
assert wp.invalid_response_count == 3
|
||||
update_options.assert_awaited_once_with(hass, wp.config, WINDY_ENABLED, False)
|
||||
Loading…
Reference in New Issue