Lukas Svoboda 2026-07-25 23:24:30 +00:00 committed by GitHub
commit 08e9891925
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 12527 additions and 1076 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "tools/test-station-server"]
path = tools/test-station-server
url = https://github.com/schizza/test-station-server.git

119
README.md
View File

@ -5,24 +5,26 @@
This integration will listen for data from your station and passes them to respective sensors. It also provides the ability to push data to `Windy API` or `Pocasi Meteo`.
### Ecowitt support is coming in the next major release
### Ecowitt support
As of April 11, 2026, Ecowitt stations are supported in the pre-release version
Ecowitt stations are supported as of the pre-release version
[v2.0.0pre1](https://github.com/schizza/SWS-12500-custom-component/releases/tag/v2.0.0pre1).
You can download this pre-release in HACS under `target version`, where you can pick the exact
version of the integration. Please be aware that this pre-release is really for testing
purposes only.
See [Ecowitt stations](#ecowitt-stations) for the setup steps.
---
### Integration rename is planned for the next major release
The current name no longer reflects what the integration does. It was initially developed
primarily for the SWS 12500 station, but it already supports other weather stations as well
(Bresser, Garni and others), and Ecowitt support is on the way — so the current name has
become misleading. The transition will be announced via an update, and I'm also planning to
offer a full data migration from the existing integration to the new one, so you won't lose
any of your historical data.
(Bresser, Garni, Ecowitt and others) — so the current name has become misleading. The
transition will be announced via an update, and I'm also planning to offer a full data
migration from the existing integration to the new one, so you won't lose any of your
historical data.
- The transition date hasn't been set yet, but it's currently expected to happen within the
next ~23 months. At the moment, I'm working on a full refactor and general code cleanup.
@ -95,6 +97,8 @@ Web server repo is reachable here: <https://github.com/schizza/test-station-serv
- SWS 10500 (newer firmware revisions are also supported via the [WSLink SSL proxy add-on](https://github.com/schizza/wslink-addon))
- Bresser stations that support custom server upload — [this model is known to work](https://www.bresser.com/p/bresser-wi-fi-clearview-weather-station-with-7-in-1-sensor-7002586), for example
- Garni stations with WSLink support or custom server support
- Ecowitt stations that support the `Customized` / `Ecowitt` upload protocol — see
[Ecowitt stations](#ecowitt-stations)
- and a bunch of other models that aren't listed here are also supported
## Supported sensors
@ -107,16 +111,31 @@ Beyond the standard set (outdoor / indoor temperature and humidity, barometric p
wind speed / direction / gust, rain rate and daily / weekly / monthly / yearly totals, dew
point, UV index, solar irradiance) the WSLink protocol also exposes:
- additional channels **CH2** and **CH3** for temperature and humidity
- additional channels **CH2** **CH8** for temperature and humidity
- **WBGT** index, **heat index**, **wind chill**
- sensor battery levels (outdoor, indoor, CH2)
- **Formaldehyde (HCHO)** in ppb and **VOC level** as a 15 air-quality index
(1 = worst, 5 = best) from the WH46 / 7-in-1 air-quality combo sensor
- sensor battery state for the outdoor, indoor and **CH2** **CH8** probes, exposed as
binary `battery` sensors (`normal` / `low`) — these used to be regular sensors, so on an
upgraded install the old entities are left behind; a `Repairs` issue lists them and
tells you how to remove them
- **Formaldehyde (HCHO)** in ppb and **VOC level** from the WH46 / 7-in-1 air-quality
combo sensor, reported as a named air-quality level (`unhealthy`, `poor`, `moderate`,
`good`, `excellent` — the station's 15 index, where 1 is worst)
- **HCHO/VOC sensor battery** reported as a percentage
HCHO / VOC entities are only created when the station reports the air-quality module as
connected (`t9cn = 1`), so they don't clutter the device when no such sensor is attached.
Ecowitt stations map onto the same standard set and additionally report whatever their own
firmware sends — absolute pressure, event / hourly / weekly / monthly / yearly / total /
24h rain, feels-like temperature, indoor dew point and CO₂ readings among them. Only the
fields your station actually sends are created.
The integration also creates diagnostic entities describing its own state — the active
protocol, whether each endpoint is registered, details of the last received payload, the
status of the WSLink proxy add-on, and the state of the Windy / Pocasi Meteo forwarders.
They are grouped under `Diagnostic` on the device page, and the same data is included in
the config entry's `Download diagnostics` file.
## Installation
### For stations that send data through WSLink API
@ -159,7 +178,11 @@ For manual installation you must have access to your Home Assistant's `/config`
6. Save your configuration.
![station_setup](README/station_hint.png)
Once integration is added to Home Assistant, configuration dialog will ask you for `API_ID` and `API_KEY` as you set them in your station:
Once the integration is added to Home Assistant, the first dialog asks which type of
station you have. Choose `PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground
compatible)` — for Ecowitt stations see [Ecowitt stations](#ecowitt-stations) instead.
The next dialog asks for `API_ID` and `API_KEY` as you set them in your station:
```plain
API_ID: ID in station's config
@ -171,12 +194,38 @@ API_KEY: PASSWORD in station's config
If you change `API ID` or `API KEY` in the station, you have to reconfigure integration to accept data from your station.
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
- In dialog box choose `Basic - Configure credentials`
- In dialog box choose `Basic - configure credentials for Weather Station`
![reconfigure dialog](README/reconfigure.png)
As soon as the integration is added into Home Assistant it will listen for incoming data from the station and starts to fill sensors as soon as data will first arrive.
## Ecowitt stations
Ecowitt stations do not use the `API ID` / `API KEY` pair — they are authorized by a
secret webhook ID that the integration generates for you.
- When adding the integration, pick `Ecowitt` in the station-type dialog. For an entry
that already exists, go to `Settings` -> `Devices & services` -> SWS12500 ->
`Configure` and choose `Ecowitt configuration`.
- The dialog shows the endpoint your station has to post to and pre-fills a freshly
generated webhook ID:
```plain
<your Home Assistant host>:<port>/weatherhub/<webhook_id>
```
- Enter that path as the custom-server path in the Ecowitt app (`WS View` / `Customized`
upload), keep the protocol on `Ecowitt`, and tick `Enable Ecowitt station data`.
> [!IMPORTANT]
> The legacy PWS/WSLink endpoint and the Ecowitt endpoint cannot be enabled at the same
> time — both feed the same sensor entities, which would mix up units and blank out
> readings. The config flow refuses to enable one while the other is on. Picking
> `Ecowitt` when you first add the integration turns the legacy endpoint off for you; to
> switch back, disable Ecowitt first and then tick `Enable PWS/WSLink endpoint` under
> `Basic - configure credentials for Weather Station`.
## Upgrading from PWS to WSLink
If you upgrade your station — which was previously sending data in the PWS protocol — to a
@ -190,19 +239,26 @@ measurement scale.
## Resending data to Windy API
- First of all you need to create account at [Windy stations](https://stations.windy.com).
- Once you have an account created, copy your Windy API Key.
![windy api key](README/windy_key.png)
- Once you have an account created, open
[your stations](https://stations.windy.com/stations) and copy the **station ID** and the
**station password** of the station you want to feed.
![windy station credentials](README/windy_key.png)
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
- In dialog box choose `Windy configuration`.
![config dialog](README/cfg.png)
- Fill in `Key` you were provided at `Windy stations`.
- Tick `Enable` checkbox.
- Fill in `Station ID obtained form Windy` and `Station password obtained from Windy`.
Both are required — enabling the forwarder with either one empty is rejected.
- Tick `Enable resending data to Windy`.
![enable windy](README/windy_cfg.png)
- You are done.
If Windy rejects the data three times in a row, the integration disables resending on its
own, writes the reason to the log and raises a persistent notification — fix the
credentials and tick `Enable resending data to Windy` again.
## Resending data to Pocasi Meteo
- If you are willing to use [Pocasi Meteo Application](https://pocasimeteo.cz) you can enable resending your data to their servers
@ -210,10 +266,15 @@ measurement scale.
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
- In dialog box choose `Pocasi Meteo configuration`.
- Fill in `ID` and `KEY` you were provided at `Pocasi Meteo`.
- Tick `Enable` checkbox.
- Optionally adjust `Resend interval in seconds` (minimum 12s, default 30s).
- Tick `Enable resending data to Pocasi Meteo`.
- You are done.
As with Windy, three unexpected responses in a row switch resending off automatically and
log the reason. The `Forwarding to Počasí Meteo` and `Forwarding status to Počasí Meteo`
diagnostic sensors show the current state.
## WSLink notes
If your station sends WSLink data over SSL (see the
@ -226,12 +287,16 @@ for this integration to support WSLink on non-SSL installations of Home Assistan
### Configuration
- Set your station up as [described above](#configure-your-station-in-ap-mode), but for
`HA port` use the port the add-on is listening on (4443 by default) — **not** the port
of your Home Assistant instance.
`HA port` use the port the add-on is listening on — **not** the port of your Home
Assistant instance.
The add-on listens on port `443` by default. The example below uses `4443` to show what a
remapped port looks like — you would pick a port like that only if you changed the add-on's
port mapping, for instance because `443` is already taken on the host.
```plain
Home Assistant is at 192.168.0.2:8123
WSLink proxy add-on is listening on port 4443
WSLink proxy add-on has been remapped to port 4443
→ set the station URL to: 192.168.0.2:4443
```
@ -239,3 +304,17 @@ WSLink proxy add-on is listening on port 4443
- Your station will send data to the SSL proxy and the add-on will handle the rest.
_Most stations do not care about self-signed certificates on the server side._
### Add-on health monitoring
The integration probes the add-on's health endpoint so the diagnostic entities can report
whether the proxy is online, which version it runs and which ports it uses. It has to know
the port to reach it on:
- In `Settings` -> `Devices & services` find SWS12500, click `Configure` and choose
`WSLink add-on port`.
- Set it to the same port the add-on listens on — the one you pointed the station at
above. This option defaults to `443`, which matches the add-on's own default, so you
only need to change it if you remapped the add-on's port (`4443` in the example above).
This setting only affects the health probe; incoming station data is unaffected by it.

View File

@ -1,269 +1,273 @@
"""The Sencor SWS 12500 Weather Station integration."""
"""Sencor SWS 12500 Weather Station integration (push/webhook based).
Architecture overview
---------------------
This integration is *push-based*: the weather station calls our HTTP endpoint and we
receive a query payload. We do not poll the station.
Key building blocks:
- `WeatherDataUpdateCoordinator` acts as an in-memory "data bus" for the latest payload.
On each webhook request we call `async_set_updated_data(...)` and all `CoordinatorEntity`
sensors get notified and update their states.
- `hass.data[DOMAIN][entry_id]` is a per-entry *dict* that stores runtime state
(coordinator instance, options snapshot, and sensor platform callbacks). Keeping this
structure consistent is critical; mixing different value types under the same key can
break listener wiring and make the UI appear "frozen".
Auto-discovery
--------------
When the station starts sending a new field, we:
1) persist the new sensor key into options (`SENSORS_TO_LOAD`)
2) dynamically add the new entity through the sensor platform (without reloading)
Why avoid reload?
Reloading a config entry unloads platforms temporarily, which removes coordinator listeners.
With a high-frequency push source (webhook), a reload at the wrong moment can lead to a
period where no entities are subscribed, causing stale states until another full reload/restart.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import InvalidStateError, PlatformNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from .conflicts import effective_protocols, update_protocol_conflict_issue
from .const import (
API_ID,
API_KEY,
DEFAULT_URL,
DEV_DBG,
DOMAIN,
ECOWITT_URL_PREFIX,
HEALTH_URL,
POCASI_CZ_ENABLED,
POCASI_CZ_ENABLED_LEGACY,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
WSLINK_URL,
)
from .pocasti_cz import PocasiPush
from .routes import Routes, unregistred
from .utils import (
anonymize,
check_disabled,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
)
from .windy_func import WindyPush
from .coordinator import WeatherDataUpdateCoordinator
from .data import SWSConfigEntry, SWSRuntimeData
from .health_coordinator import HealthCoordinator
from .legacy import update_legacy_battery_issue
from .routes import Routes
from .staleness import update_stale_sensors_issue
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
# Keep in sync with `ConfigFlowHandler.VERSION`.
CONFIG_ENTRY_VERSION: int = 2
class IncorrectDataError(InvalidStateError):
"""Invalid exception."""
async def async_migrate_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Migrate an old config entry.
Version 2 renames the Pocasi Meteo enable flag from the misspelled
`pocasi_enabled_chcekbox` to `pocasi_enabled_checkbox`. Without moving the persisted
value, forwarding would silently switch off for every existing install.
"""
class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
"""Manage fetched data."""
if entry.version > CONFIG_ENTRY_VERSION:
# Downgrades are not supported - the entry was written by a newer version.
return False
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
"""Init global updater."""
self.hass = hass
self.config = config
self.config_entry = config
self.windy = WindyPush(hass, config)
self.pocasi: PocasiPush = PocasiPush(hass, config)
super().__init__(hass, _LOGGER, name=DOMAIN)
if entry.version < 2:
options = dict(entry.options)
if POCASI_CZ_ENABLED_LEGACY in options:
legacy_value = options.pop(POCASI_CZ_ENABLED_LEGACY)
# Never clobber an already-correct key (both may exist after a partial upgrade).
options.setdefault(POCASI_CZ_ENABLED, legacy_value)
_LOGGER.debug("Migrated Pocasi Meteo enable flag to %s.", POCASI_CZ_ENABLED)
async def recieved_data(self, webdata: aiohttp.web.Request):
"""Handle incoming data query."""
_wslink = self.config_entry.options.get(WSLINK)
get_data = webdata.query
post_data = await webdata.post()
data = dict(get_data) | dict(post_data)
response = None
if not _wslink and ("ID" not in data or "PASSWORD" not in data):
_LOGGER.error("Invalid request. No security data provided!")
raise HTTPUnauthorized
if _wslink and ("wsid" not in data or "wspw" not in data):
_LOGGER.error("Invalid request. No security data provided!")
raise HTTPUnauthorized
if _wslink:
id_data = data["wsid"]
key_data = data["wspw"]
else:
id_data = data["ID"]
key_data = data["PASSWORD"]
_id = self.config_entry.options.get(API_ID)
_key = self.config_entry.options.get(API_KEY)
if id_data != _id or key_data != _key:
_LOGGER.error("Unauthorised access!")
raise HTTPUnauthorized
if self.config_entry.options.get(WINDY_ENABLED):
_ = await self.windy.push_data_to_windy(data, _wslink)
if self.config.options.get(POCASI_CZ_ENABLED):
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
remaped_items = (
remap_wslink_items(data)
if self.config_entry.options.get(WSLINK)
else remap_items(data)
)
if sensors := check_disabled(self.hass, remaped_items, self.config):
translate_sensors = [
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
]
human_readable = "\n".join(translate_sensors)
await translated_notification(
self.hass,
DOMAIN,
"added",
{"added_sensors": f"{human_readable}\n"},
)
if _loaded_sensors := loaded_sensors(self.config_entry):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config_entry, SENSORS_TO_LOAD, sensors)
# await self.hass.config_entries.async_reload(self.config.entry_id)
self.async_set_updated_data(remaped_items)
if self.config_entry.options.get(DEV_DBG):
_LOGGER.info("Dev log: %s", anonymize(data))
response = response or "OK"
return aiohttp.web.Response(body=f"{response or 'OK'}", status=200)
def register_path(
hass: HomeAssistant,
url_path: str,
coordinator: WeatherDataUpdateCoordinator,
config: ConfigEntry,
):
"""Register path to handle incoming data."""
hass_data = hass.data.setdefault(DOMAIN, {})
debug = config.options.get(DEV_DBG)
_wslink = config.options.get(WSLINK, False)
routes: Routes = hass_data.get("routes", Routes())
if not routes.routes:
routes = Routes()
_LOGGER.info("Routes not found, creating new routes")
if debug:
_LOGGER.debug("Enabled route is: %s, WSLink is %s", url_path, _wslink)
try:
default_route = hass.http.app.router.add_get(
DEFAULT_URL,
coordinator.recieved_data if not _wslink else unregistred,
name="weather_default_url",
)
if debug:
_LOGGER.debug("Default route: %s", default_route)
wslink_route = hass.http.app.router.add_get(
WSLINK_URL,
coordinator.recieved_data if _wslink else unregistred,
name="weather_wslink_url",
)
if debug:
_LOGGER.debug("WSLink route: %s", wslink_route)
wslink_post_route = hass.http.app.router.add_post(
WSLINK_URL,
coordinator.recieved_data if _wslink else unregistred,
name="weather_wslink_post_route_url",
)
if debug:
_LOGGER.debug("WSLink route: %s", wslink_post_route)
routes.add_route(
DEFAULT_URL,
default_route,
coordinator.recieved_data if not _wslink else unregistred,
not _wslink,
)
routes.add_route(
WSLINK_URL, wslink_route, coordinator.recieved_data, _wslink
)
routes.add_route(
WSLINK_URL, wslink_post_route, coordinator.recieved_data, _wslink
)
hass_data["routes"] = routes
except RuntimeError as Ex: # pylint: disable=(broad-except)
if (
"Added route will never be executed, method GET is already registered"
in Ex.args
):
_LOGGER.info("Handler to URL (%s) already registred", url_path)
return False
_LOGGER.error("Unable to register URL handler! (%s)", Ex.args)
return False
_LOGGER.info(
"Registered path to handle weather data: %s",
routes.get_enabled(), # pylint: disable=used-before-assignment
)
if _wslink:
routes.switch_route(coordinator.recieved_data, WSLINK_URL)
else:
routes.switch_route(coordinator.recieved_data, DEFAULT_URL)
return routes
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the config entry for my device."""
coordinator = WeatherDataUpdateCoordinator(hass, entry)
hass_data = hass.data.setdefault(DOMAIN, {})
hass_data[entry.entry_id] = coordinator
_wslink = entry.options.get(WSLINK)
debug = entry.options.get(DEV_DBG)
if debug:
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
route = register_path(
hass, DEFAULT_URL if not _wslink else WSLINK_URL, coordinator, entry
)
if not route:
_LOGGER.error("Fatal: path not registered!")
raise PlatformNotReady
hass_data["route"] = route
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
hass.config_entries.async_update_entry(entry, options=options, version=2)
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Update setup listener."""
def register_path(
hass: HomeAssistant,
coordinator: WeatherDataUpdateCoordinator,
coordinator_h: HealthCoordinator,
config: SWSConfigEntry,
) -> bool:
"""Register webhook paths.
We register both possible endpoints and use an internal dispatcher (`Routes`) to
enable exactly one of them. This lets us toggle WSLink mode without re-registering
routes on the aiohttp router.
"""
hass.data.setdefault(DOMAIN, {})
if (hass_data := checked(hass.data[DOMAIN], dict[str, Any])) is None:
raise ConfigEntryNotReady
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
# Legacy and Ecowitt share one entity namespace, so only one may be wired up.
_legacy, _ecowitt_enabled = effective_protocols(config)
# Load registred routes
routes: Routes | None = hass_data.get("routes", None)
if not isinstance(routes, Routes):
routes = Routes()
routes.set_ingress_observer(coordinator_h.record_dispatch)
# Register webhooks in HomeAssistant with dispatcher
try:
_default_route = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch, name="_default_route")
_wslink_post_route = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch, name="_wslink_post_route")
_wslink_get_route = hass.http.app.router.add_get(WSLINK_URL, routes.dispatch, name="_wslink_get_route")
_health_route = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch, name="_health_route")
# Ecowitt URL contains {webhook_id} as a parameter.
# Station is configured to send data to: http://ha:8123/weatherhub/<webhook_id>
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
_ecowitt_route = hass.http.app.router.add_post(_ecowitt_path, routes.dispatch, name="_ecowitt_route")
# Save initialised routes
hass_data["routes"] = routes
except RuntimeError as Ex:
_LOGGER.critical("Routes cannot be added. Integration will not work as expected. %s", Ex)
raise ConfigEntryNotReady from Ex
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
routes.add_route(DEFAULT_URL, _default_route, coordinator.received_data, enabled=_legacy and not _wslink)
routes.add_route(WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_legacy and _wslink)
routes.add_route(WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_legacy and _wslink)
# Make health route `sticky` so it will not change upon updating options.
routes.add_route(
HEALTH_URL,
_health_route,
coordinator_h.health_status,
enabled=True,
sticky=True,
)
routes.add_route(
_ecowitt_path,
_ecowitt_route,
coordinator.received_ecowitt_data,
enabled=_ecowitt_enabled,
sticky=True,
)
else:
routes.set_ingress_observer(coordinator_h.record_dispatch)
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True
async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Set up a config entry.
Per-entry state is held on `entry.runtime_data`. Only the shared aiohttp route dispatcher
lives in `hass.data[DOMAIN]` because it must outlive a single entry reload. This separation is critical
to avoid issues where entities stop receiving updates after a reload because the coordinator instance they
are subscribed to is replaced in `hass.data[DOMAIN]` but the aiohttp route dispatcher still calls the old instance.
"""
hass.data.setdefault(DOMAIN, {})
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator_health = HealthCoordinator(hass, entry)
entry.runtime_data = SWSRuntimeData(
coordinator=coordinator,
health_coordinator=coordinator_health,
last_options=dict(entry.options),
)
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
# Legacy and Ecowitt share one entity namespace, so only one may be wired up.
_legacy, _ecowitt_enabled = effective_protocols(entry)
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
_LOGGER.debug("WS Link is %s", "enabled" if _wslink else "disabled")
routes: Routes | None = hass.data[DOMAIN].get("routes")
if routes is not None:
_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())
else:
if not register_path(hass, coordinator, coordinator_health, entry):
_LOGGER.error("Fatal: path not registered!")
raise ConfigEntryNotReady("Webhook routes could not be registered")
routes = hass.data[DOMAIN].get("routes")
if routes is not None:
coordinator_health.update_routing(routes)
await coordinator_health.async_config_entry_first_refresh()
coordinator_health.update_forwarding(coordinator.windy, coordinator.pocasi)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _check_stale(_now: datetime) -> None:
update_stale_sensors_issue(hass, entry)
entry.async_on_unload(async_track_time_interval(hass, _check_stale, timedelta(hours=1)))
entry.async_on_unload(entry.add_update_listener(update_listener))
update_legacy_battery_issue(hass, entry)
update_protocol_conflict_issue(hass, entry)
return True
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
"""Handle config entry option updates.
We skip reloading when only live-read options change:
- `SENSORS_TO_LOAD` (auto-discovery updates it as new payload fields appear), and
- the forwarding enable flags (`WINDY_ENABLED`/`POCASI_CZ_ENABLED`), which the
forwarders read on every push - so a forwarder that auto-disables itself from the
hot path no longer triggers a disruptive reload.
Why:
- Reloading a push-based integration temporarily unloads platforms and removes
coordinator listeners, which can make the UI appear "stuck" until restart.
"""
runtime = getattr(entry, "runtime_data", None)
if isinstance(runtime, SWSRuntimeData):
old_options = runtime.last_options
new_options = dict(entry.options)
changed_keys = {k for k in set(old_options) | set(new_options) if old_options.get(k) != new_options.get(k)}
runtime.last_options = new_options
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
return
update_legacy_battery_issue(hass, entry)
await hass.config_entries.async_reload(entry.entry_id)
_LOGGER.info("Settings updated")
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
async def async_unload_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Unload a config entry.
_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if _ok:
hass.data[DOMAIN].pop(entry.entry_id)
`entry.runtime_data` becomes irrelevant once entry is unloaded.
On next `async_setup_entry` we overwrite it. The shared `hass.data[DOMAIN]["routes"]` survives by design.
aiohttp routes stay registered and the dispatcher is re-wired on the next setup.
"""
return _ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,65 @@
"""Battery binary sensor entities for SWS 12500.
Expose low-batter warnings as binary sensors.
"""
from __future__ import annotations
from functools import cached_property
from typing import Any
from py_typecheck import checked_or
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .data import build_device_info
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, BinarySensorEntity
):
"""Represent a low-battery binary sensor.
Station payload uses:
- ``0`` => low battery (binary sensor is ``on``)
- ``1`` => battery OK (binary sensor is ``off``)
"""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: Any,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize the battery binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{description.key}_binary"
@property
def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return low-battery state.
``True`` means low battery for ``BinarySensorDeviceClass.BATTERY``.
"""
data = checked_or(self.coordinator.data, dict[str, Any], {})
raw: Any = data.get(self.entity_description.key)
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
return value == 0
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -0,0 +1,48 @@
"""Battery sensor templates.
Entity descriptions for both battery kinds are *generated* from the classification
tuples in `const`, so those tuples are the single source of truth:
- ``BATTERY_LIST`` -> 0/1 low/normal -> binary sensors
- ``BATTERY_NON_BINARY`` -> 0-5 level -> percentage sensors
Generating both from one place is what prevents the conflict: a key listed in both
tuples would otherwise get a binary sensor (collapsing 0-5 into low/not-low and
throwing away four fifths of the reading) *and* a percentage sensor for the same
battery. `tests/test_battery_classification.py` fails if the tuples ever overlap or
if a `*_BATTERY` constant is left unclassified.
Which of these are actually loaded is gated in the coordinator by SENSORS_TO_LOAD.
"""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import PERCENTAGE
from .const import BATTERY_LIST, BATTERY_NON_BINARY
from .sensors_common import WeatherSensorEntityDescription
from .utils import battery_5step_to_pct
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = tuple(
BinarySensorEntityDescription(
key=key,
translation_key=key,
device_class=BinarySensorDeviceClass.BATTERY,
)
for key in BATTERY_LIST
)
BATTERY_LEVEL_SENSORS: tuple[WeatherSensorEntityDescription, ...] = tuple(
WeatherSensorEntityDescription(
key=key,
translation_key=key,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=battery_5step_to_pct,
)
for key in BATTERY_NON_BINARY
)

View File

@ -0,0 +1,83 @@
"""Binary sensor platform for SWS12500.
Exposes low-battery warnings as binary sensors.
Auto-discovery adds entities without reloading the entry, using callbacks stored on `runtime_data`.
"""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .battery_sensors import BatteryBinarySensor
from .battery_sensors_def import BATTERY_BINARY_SENSORS
from .const import SENSORS_TO_LOAD
from .data import SWSConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: SWSConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up battery binary sensors."""
del hass
runtime = entry.runtime_data
coordinator = runtime.coordinator
# Persist platform callback + description map for dynamic entity creation.
runtime.add_binary_entities = async_add_entities
runtime.binary_descriptions = {desc.key: desc for desc in BATTERY_BINARY_SENSORS}
runtime.added_binary_keys = set()
# Initial entities for battery keys that station already reports.
# `SENSORS_TO_LOAD` accumulates all discovered keys across runs.
loaded = set(entry.options.get(SENSORS_TO_LOAD, []))
entities: list[BatteryBinarySensor] = []
for desc in BATTERY_BINARY_SENSORS:
if desc.key in loaded:
entities.append(BatteryBinarySensor(coordinator, desc))
runtime.added_binary_keys.add(desc.key)
if entities:
async_add_entities(entities)
def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: list[str]) -> None:
"""Dynamic add newly discovered binary sensors without reloading the entry.
Called from webhook handler in __init__.py.
Safe no-op if the platform hasn't finished setting up yet (e.g. callback/description map missing).
Duplicate keys are ignored (only keys with an entity description that haven't been added yet are added).
"""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = getattr(entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_binary_entities
if add_entities is None:
return
descriptions = runtime.binary_descriptions
coordinator = runtime.coordinator
added = runtime.added_binary_keys
new_entities: list[BinarySensorEntity] = []
for key in keys:
if key in added:
continue
if (desc := descriptions.get(key)) is None:
continue
new_entities.append(BatteryBinarySensor(coordinator, desc))
added.add(key)
if new_entities:
add_entities(new_entities)

View File

@ -1,19 +1,28 @@
"""Config flow for Sencor SWS 12500 Weather Station integration."""
from __future__ import annotations
import secrets
from typing import Any
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import selector
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
from .const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
INVALID_CREDENTIALS,
LEGACY_ENABLED,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
@ -26,15 +35,36 @@ from .const import (
WINDY_STATION_ID,
WINDY_STATION_PW,
WSLINK,
WSLINK_ADDON_PORT,
)
class CannotConnect(HomeAssistantError):
"""We can not connect. - not used in push mechanism."""
# Masked text input for secret fields (API keys / station passwords).
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
class InvalidAuth(HomeAssistantError):
"""Invalid auth exception."""
def _is_invalid_credential(value: Any) -> bool:
"""Return True when a PWS/WSLink credential is unusable.
Blank (or whitespace-only) values are as useless as the placeholder strings in
`INVALID_CREDENTIALS`: `_validate_credentials` rejects every incoming packet if
either option is empty, so the entry would be created but never receive data.
"""
return not isinstance(value, str) or not value.strip() or value in INVALID_CREDENTIALS
def _ha_url_placeholders(hass: HomeAssistant) -> tuple[str, str]:
"""Return (host, port) of the Home Assistant URL for the Ecowitt instructions.
`get_url` raises `NoURLAvailableError` when HA cannot resolve any of its own
URLs. These values are shown as setup instructions only, so fall back to a
placeholder instead of letting the config flow abort.
"""
try:
url: URL = URL(get_url(hass))
except NoURLAvailableError:
return "UNKNOWN", "UNKNOWN"
return url.host or "UNKNOWN", str(url.port)
class ConfigOptionsFlowHandler(OptionsFlow):
@ -49,63 +79,64 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.windy_data_schema = {}
self.user_data: dict[str, Any] = {}
self.user_data_schema = {}
self.sensors: dict[str, Any] = {}
self.migrate_schema = {}
self.pocasi_cz: dict[str, Any] = {}
self.pocasi_cz_schema = {}
self.ecowitt: dict[str, Any] = {}
self.ecowitt_schema = {}
self.wslink_addon_port: dict[str, int] = {}
self.wslink_addod_schema = {}
async def _get_entry_data(self):
"""Get entry data."""
entry_data = {**self.config_entry.data, **self.config_entry.options}
self.user_data = {
API_ID: entry_data.get(API_ID),
API_KEY: entry_data.get(API_KEY),
WSLINK: entry_data.get(WSLINK, False),
DEV_DBG: entry_data.get(DEV_DBG, False),
API_ID: self.config_entry.options.get(API_ID, ""),
API_KEY: self.config_entry.options.get(API_KEY, ""),
LEGACY_ENABLED: self.config_entry.options.get(LEGACY_ENABLED, True),
WSLINK: self.config_entry.options.get(WSLINK, False),
DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
}
self.user_data_schema = {
vol.Required(API_ID, default=self.user_data.get(API_ID, "")): str,
vol.Required(API_KEY, default=self.user_data.get(API_KEY, "")): str,
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): _PASSWORD_SELECTOR,
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
}
self.sensors = {
SENSORS_TO_LOAD: (
entry_data.get(SENSORS_TO_LOAD) if isinstance(entry_data.get(SENSORS_TO_LOAD), list) else []
)
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
}
self.windy_data = {
WINDY_STATION_ID: entry_data.get(WINDY_STATION_ID),
WINDY_STATION_PW: entry_data.get(WINDY_STATION_PW),
WINDY_ENABLED: entry_data.get(WINDY_ENABLED, False),
WINDY_LOGGER_ENABLED: entry_data.get(WINDY_LOGGER_ENABLED, False),
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
WINDY_LOGGER_ENABLED: self.config_entry.options.get(WINDY_LOGGER_ENABLED, False),
WINDY_ENABLED: self.config_entry.options.get(WINDY_ENABLED, False),
}
self.windy_data_schema = {
vol.Optional(WINDY_STATION_ID, default=self.windy_data.get(WINDY_STATION_ID, "")): str,
vol.Optional(WINDY_STATION_PW, default=self.windy_data.get(WINDY_STATION_PW, "")): str,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool or False,
vol.Optional(
WINDY_STATION_PW,
default=self.windy_data.get(WINDY_STATION_PW, ""),
): _PASSWORD_SELECTOR,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
vol.Optional(
WINDY_LOGGER_ENABLED,
default=self.windy_data[WINDY_LOGGER_ENABLED],
): bool or False,
): bool,
}
self.pocasi_cz = {
POCASI_CZ_API_ID: entry_data.get(POCASI_CZ_API_ID, ""),
POCASI_CZ_API_KEY: entry_data.get(POCASI_CZ_API_KEY, ""),
POCASI_CZ_ENABLED: entry_data.get(POCASI_CZ_ENABLED, False),
POCASI_CZ_LOGGER_ENABLED: entry_data.get(POCASI_CZ_LOGGER_ENABLED, False),
POCASI_CZ_SEND_INTERVAL: entry_data.get(POCASI_CZ_SEND_INTERVAL, 30),
POCASI_CZ_API_ID: self.config_entry.options.get(POCASI_CZ_API_ID, ""),
POCASI_CZ_API_KEY: self.config_entry.options.get(POCASI_CZ_API_KEY, ""),
POCASI_CZ_ENABLED: self.config_entry.options.get(POCASI_CZ_ENABLED, False),
POCASI_CZ_LOGGER_ENABLED: self.config_entry.options.get(POCASI_CZ_LOGGER_ENABLED, False),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(POCASI_CZ_SEND_INTERVAL, 30),
}
self.pocasi_cz_schema = {
vol.Required(POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): str,
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): _PASSWORD_SELECTOR,
vol.Required(
POCASI_CZ_SEND_INTERVAL,
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
@ -117,13 +148,28 @@ class ConfigOptionsFlowHandler(OptionsFlow):
): bool,
}
async def async_step_init(self, user_input=None):
"""Manage the options - show menu first."""
return self.async_show_menu(step_id="init", menu_options=["basic", "windy", "pocasi"])
self.ecowitt = {
ECOWITT_WEBHOOK_ID: self.config_entry.options.get(ECOWITT_WEBHOOK_ID, ""),
ECOWITT_ENABLED: self.config_entry.options.get(ECOWITT_ENABLED, False),
}
async def async_step_basic(self, user_input=None):
"""Manage basic options - credentials."""
errors = {}
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] | None = None):
"""Manage the options - show menu first."""
_ = user_input
return self.async_show_menu(
step_id="init", menu_options=["basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"]
)
async def async_step_basic(self, user_input: Any = None):
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credentials empty.
"""
errors: dict[str, str] = {}
await self._get_entry_data()
@ -134,22 +180,20 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
if user_input[API_ID] in INVALID_CREDENTIALS:
errors[API_ID] = "valid_credentials_api"
elif user_input[API_KEY] in INVALID_CREDENTIALS:
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
else:
# retain windy data
user_input.update(self.windy_data)
# retain sensors
user_input.update(self.sensors)
# retain pocasi data
user_input.update(self.pocasi_cz)
if user_input.get(LEGACY_ENABLED):
# Both endpoints remap onto the same internal sensor keys, so enabling the
# legacy one while Ecowitt is active would corrupt those entities.
if self.ecowitt.get(ECOWITT_ENABLED):
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
elif _is_invalid_credential(user_input.get(API_ID)):
errors[API_ID] = "valid_credentials_api"
elif _is_invalid_credential(user_input.get(API_KEY)):
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
if not errors:
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
self.user_data = user_input
@ -161,9 +205,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
async def async_step_windy(self, user_input=None):
async def async_step_windy(self, user_input: Any = None):
"""Manage windy options."""
errors = {}
errors: dict[str, str] = {}
await self._get_entry_data()
@ -174,36 +218,24 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
station_id = (user_input.get(WINDY_STATION_ID) or "").strip()
station_pw = (user_input.get(WINDY_STATION_PW) or "").strip()
if user_input.get(WINDY_ENABLED):
if not station_id:
errors[WINDY_STATION_ID] = "windy_id_required"
if not station_pw:
errors[WINDY_STATION_PW] = "windy_pw_required"
if errors:
return self.async_show_form(
step_id="windy",
data_schema=vol.Schema(self.windy_data_schema),
errors=errors,
)
if (user_input[WINDY_ENABLED] is True) and (
(user_input[WINDY_STATION_ID] == "") or (user_input[WINDY_STATION_PW] == "")
):
errors[WINDY_STATION_ID] = "windy_key_required"
return self.async_show_form(
step_id="windy",
data_schema=vol.Schema(self.windy_data_schema),
errors=errors,
)
# retain user_data
user_input.update(self.user_data)
# retain senors
user_input.update(self.sensors)
# retain pocasi cz
user_input.update(self.pocasi_cz)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_pocasi(self, user_input: Any = None) -> ConfigFlowResult:
"""Handle the pocasi step."""
errors = {}
errors: dict[str, str] = {}
await self._get_entry_data()
@ -229,58 +261,178 @@ class ConfigOptionsFlowHandler(OptionsFlow):
data_schema=vol.Schema(self.pocasi_cz_schema),
errors=errors,
)
# retain user data
user_input.update(self.user_data)
# retain senors
user_input.update(self.sensors)
# retain windy
user_input.update(self.windy_data)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
errors: dict[str, str] = {}
await self._get_entry_data()
if not (webhook := self.ecowitt.get(ECOWITT_WEBHOOK_ID)):
webhook = secrets.token_hex(8)
if user_input is not None:
# Both endpoints remap onto the same internal sensor keys, so enabling
# Ecowitt while the legacy endpoint is active would corrupt those entities.
if user_input.get(ECOWITT_ENABLED) and self.user_data.get(LEGACY_ENABLED):
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
else:
return self.async_create_entry(title=DOMAIN, data=self.retain_data(user_input))
host, port = _ha_url_placeholders(self.hass)
ecowitt_schema = {
vol.Required(
ECOWITT_WEBHOOK_ID,
default=webhook,
): str,
vol.Optional(
ECOWITT_ENABLED,
default=self.ecowitt.get(ECOWITT_ENABLED, False),
): bool,
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": port,
"webhook_id": webhook,
},
errors=errors,
)
async def async_step_wslink_port_setup(self, user_input: Any = None) -> ConfigFlowResult:
"""WSLink Addon port setup."""
errors: dict[str, str] = {}
await self._get_entry_data()
if not (port := self.wslink_addon_port.get(WSLINK_ADDON_PORT)):
port = 443
wslink_port_schema = {
vol.Required(WSLINK_ADDON_PORT, default=port): int,
}
if user_input is None:
return self.async_show_form(
step_id="wslink_port_setup",
data_schema=vol.Schema(wslink_port_schema),
errors=errors,
)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Merge the submitted step over every other section's current values.
`SENSORS_TO_LOAD` is re-read here rather than taken from the `_get_entry_data`
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
left open while the station reports a new field would otherwise roll that
discovery back on submit.
"""
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
return {
**self.user_data,
**self.windy_data,
**self.pocasi_cz,
**self.ecowitt,
**self.wslink_addon_port,
**dict(data),
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
}
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
data_schema = {
pws_schema = {
vol.Required(API_ID): str,
vol.Required(API_KEY): str,
vol.Required(API_KEY): _PASSWORD_SELECTOR,
vol.Optional(WSLINK): bool,
vol.Optional(DEV_DBG): bool,
}
VERSION = 1
VERSION = 2
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input: Any = None):
"""Handle the initial step."""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_show_menu(
step_id="user",
menu_options=["pws", "ecowitt"],
)
async def async_step_pws(self, user_input: Any = None) -> ConfigFlowResult:
"""PWS/WSLink credentials setup."""
errors: dict[str, str] = {}
if user_input is None:
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_show_form(step_id="pws", data_schema=vol.Schema(self.pws_schema), errors=errors)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(self.data_schema),
)
errors = {}
if user_input[API_ID] in INVALID_CREDENTIALS:
if _is_invalid_credential(user_input.get(API_ID)):
errors[API_ID] = "valid_credentials_api"
elif user_input[API_KEY] in INVALID_CREDENTIALS:
elif _is_invalid_credential(user_input.get(API_KEY)):
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
else:
return self.async_create_entry(title=DOMAIN, data=user_input, options=user_input)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: True,
ECOWITT_ENABLED: False,
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(self.data_schema),
step_id="pws",
data_schema=vol.Schema(self.pws_schema),
errors=errors,
)
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
if user_input is None:
webhook = secrets.token_hex(8)
host, port = _ha_url_placeholders(self.hass)
ecowitt_schema = {
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
vol.Optional(ECOWITT_ENABLED, default=True): bool,
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": port,
"webhook_id": webhook,
},
)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: False,
WSLINK: False,
API_ID: "",
API_KEY: "",
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:

View File

@ -0,0 +1,90 @@
"""Protocol conflict handling.
The legacy (PWS/WSLink) and Ecowitt endpoints both remap their payloads onto the *same*
internal sensor keys (`REMAP_ITEMS` / `REMAP_WSLINK_ITEMS` vs `REMAP_ECOWITT_COMPAT`) and
push them through the same coordinator. Running both at once is therefore unsound:
1. Units clash. The entity descriptions are chosen by the WSLink flag alone, so Ecowitt's
imperial payload (`tempf`, `baromrelin`, `windspeedmph`, ...) is rendered with the
WSLink set's metric units - 18 of the 26 Ecowitt-mapped keys disagree.
2. Payloads blank each other. `async_set_updated_data` *replaces* the coordinator payload,
so every Ecowitt push blanks the keys only the legacy protocol reports (and vice versa),
flipping those entities to `unknown` on every other push.
3. One entity, two sources. A single `sensor.outside_temp` would alternate between whatever
the two endpoints report.
The initial config flow already keeps them exclusive (the `pws` step disables Ecowitt, the
`ecowitt` step disables legacy); only the options flow could turn both on. This module is
the single place that states the rule, so the config flow, the route wiring and the device
model all agree on it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from py_typecheck import checked_or
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN, ECOWITT_ENABLED, LEGACY_ENABLED
if TYPE_CHECKING:
# Type-only: `data` imports `effective_protocols` from here at runtime, so a
# runtime import back into `data` would close an import cycle.
from .data import SWSConfigEntry
# Shown in the config/options flow when a submit would enable both protocols.
ERROR_MUTUALLY_EXCLUSIVE: Final = "protocols_mutually_exclusive"
@callback
def protocols_conflict(entry: SWSConfigEntry) -> bool:
"""Return whether this entry has both protocols enabled."""
return checked_or(entry.options.get(LEGACY_ENABLED), bool, True) and checked_or(
entry.options.get(ECOWITT_ENABLED), bool, False
)
@callback
def effective_protocols(entry: SWSConfigEntry) -> tuple[bool, bool]:
"""Return the `(legacy, ecowitt)` state to actually wire up.
Options written before this rule existed may still have both flags set. Rather than
ingesting two protocols into one entity namespace, legacy wins - matching the
precedence already documented in `health_coordinator._configured_protocol` - and
`update_protocol_conflict_issue` tells the user what happened.
"""
legacy = checked_or(entry.options.get(LEGACY_ENABLED), bool, True)
ecowitt = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
if legacy and ecowitt:
return True, False
return legacy, ecowitt
def _issue_id(entry: SWSConfigEntry) -> str:
"""Return the Repairs issue id for this config entry."""
return f"protocol_conflict_{entry.entry_id}"
@callback
def update_protocol_conflict_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear the Repairs issue for a legacy/Ecowitt conflict."""
issue_id = _issue_id(entry)
if protocols_conflict(entry):
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="protocol_conflict",
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -1,49 +1,20 @@
"""Constants."""
from __future__ import annotations
from enum import StrEnum
from typing import Final
# Integration specific constants.
DOMAIN = "sws12500"
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
DATABASE_PATH = "/config/home-assistant_v2.db"
DEV_DBG: Final = "dev_debug_checkbox"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
ICON = "mdi:weather"
# Common constants
API_KEY = "API_KEY"
API_ID = "API_ID"
SENSORS_TO_LOAD: Final = "sensors_to_load"
SENSOR_TO_MIGRATE: Final = "sensor_to_migrate"
DEV_DBG: Final = "dev_debug_checkbox"
WSLINK: Final = "wslink"
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
WINDY_STATION_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD"
WINDY_ENABLED: Final = "windy_enabled_checkbox"
WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox"
WINDY_NOT_INSERTED: Final = (
"Data was succefuly sent to Windy, but not inserted by Windy API. Does anyone else sent data to Windy?"
)
WINDY_INVALID_KEY: Final = (
"Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again."
)
WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully inserted by Windy API"
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
INVALID_CREDENTIALS: Final = [
"API",
@ -57,29 +28,8 @@ INVALID_CREDENTIALS: Final = [
"_KEY",
]
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
"wspw",
"wsid",
]
PURGE_DATA_POCAS: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
]
# Sensor constants
BARO_PRESSURE: Final = "baro_pressure"
OUTSIDE_TEMP: Final = "outside_temp"
DEW_POINT: Final = "dew_point"
@ -108,9 +58,27 @@ CH2_BATTERY: Final = "ch2_battery"
CH3_TEMP: Final = "ch3_temp"
CH3_HUMIDITY: Final = "ch3_humidity"
CH3_CONNECTION: Final = "ch3_connection"
CH3_BATTERY: Final = "ch3_battery"
CH4_TEMP: Final = "ch4_temp"
CH4_HUMIDITY: Final = "ch4_humidity"
CH4_CONNECTION: Final = "ch4_connection"
CH4_BATTERY: Final = "ch4_battery"
CH5_TEMP: Final = "ch5_temp"
CH5_HUMIDITY: Final = "ch5_humidity"
CH5_CONNECTION: Final = "ch5_connection"
CH5_BATTERY: Final = "ch5_battery"
CH6_TEMP: Final = "ch6_temp"
CH6_HUMIDITY: Final = "ch6_humidity"
CH6_CONNECTION: Final = "ch6_connection"
CH6_BATTERY: Final = "ch6_battery"
CH7_TEMP: Final = "ch7_temp"
CH7_HUMIDITY: Final = "ch7_humidity"
CH7_CONNECTION: Final = "ch7_connection"
CH7_BATTERY: Final = "ch7_battery"
CH8_TEMP: Final = "ch8_temp"
CH8_HUMIDITY: Final = "ch8_humidity"
CH8_CONNECTION: Final = "ch8_connection"
CH8_BATTERY: Final = "ch8_battery"
HEAT_INDEX: Final = "heat_index"
CHILL_INDEX: Final = "chill_index"
WBGT_TEMP: Final = "wbgt_temp"
@ -120,6 +88,62 @@ T9_BATTERY: Final = "t9_battery" # T9 sensors are HCHO and VOC
T9_CONN: Final = "t9_conn"
# Health specific constants
HEALTH_URL = "/station/health"
# PWS specific constants
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"wsid",
"wspw",
"passkey",
"PASSKEY",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
]
"""NOTE: These are sensors that should be available with PWS protocol according to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
I have no option to test, if it will work correctly. So their implementation will be in future releases.
leafwetness - [%]
+ for sensor 2 use leafwetness2
visibility - [nm visibility]
pweather - [text] -- metar style (+RA)
clouds - [text] -- SKC, FEW, SCT, BKN, OVC
Pollution Fields:
AqNO - [ NO (nitric oxide) ppb ]
AqNO2T - (nitrogen dioxide), true measure ppb
AqNO2 - NO2 computed, NOx-NO ppb
AqNO2Y - NO2 computed, NOy-NO ppb
AqNOX - NOx (nitrogen oxides) - ppb
AqNOY - NOy (total reactive nitrogen) - ppb
AqNO3 - NO3 ion (nitrate, not adjusted for ammonium ion) UG/M3
AqSO4 - SO4 ion (sulfate, not adjusted for ammonium ion) UG/M3
AqSO2 - (sulfur dioxide), conventional ppb
AqSO2T - trace levels ppb
AqCO - CO (carbon monoxide), conventional ppm
AqCOT -CO trace levels ppb
AqEC - EC (elemental carbon) PM2.5 UG/M3
AqOC - OC (organic carbon, not adjusted for oxygen and hydrogen) PM2.5 UG/M3
AqBC - BC (black carbon at 880 nm) UG/M3
AqUV-AETH - UV-AETH (second channel of Aethalometer at 370 nm) UG/M3
AqPM2.5 - PM2.5 mass - UG/M3
AqPM10 - PM10 mass - PM10 mass
AqOZONE - Ozone - ppb
"""
REMAP_ITEMS: dict[str, str] = {
"baromin": BARO_PRESSURE,
"tempf": OUTSIDE_TEMP,
@ -140,8 +164,90 @@ REMAP_ITEMS: dict[str, str] = {
"soilmoisture2": CH3_HUMIDITY,
"soiltemp3f": CH4_TEMP,
"soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY,
"soiltemp5f": CH6_TEMP,
"soilmoisture5": CH6_HUMIDITY,
}
WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
POCASI_CZ_MAX_RETRIES: Final = 3 # failed sends in a row before resending is disabled
WSLINK: Final = "wslink"
LEGACY_ENABLED: Final = "legacy_enabled"
WINDY_MAX_RETRIES: Final = 3
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
ECOWITT: Final = "ecowitt"
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
ECOWITT_ENABLED: Final = "ecowitt_enabled"
ECOWITT_URL_PREFIX: Final = "/weatherhub"
REMAP_ECOWITT_COMPAT: dict[str, str] = {
"tempf": OUTSIDE_TEMP,
"humidity": OUTSIDE_HUMIDITY,
"dewpointf": DEW_POINT,
"windspeedmph": WIND_SPEED,
"windgustmph": WIND_GUST,
"winddir": WIND_DIR,
"dailyrainin": DAILY_RAIN,
"solarradiation": SOLAR_RADIATION,
"tempinf": INDOOR_TEMP,
"humidityin": INDOOR_HUMIDITY,
"uv": UV,
"baromrelin": BARO_PRESSURE,
"temp1f": CH2_TEMP,
"humidity1": CH2_HUMIDITY,
"temp2f": CH3_TEMP,
"humidity2": CH3_HUMIDITY,
"temp3f": CH4_TEMP,
"humidity3": CH4_HUMIDITY,
"temp4f": CH5_TEMP,
"humidity4": CH5_HUMIDITY,
"temp5f": CH6_TEMP,
"humidity5": CH6_HUMIDITY,
"temp6f": CH7_TEMP,
"humidity6": CH7_HUMIDITY,
"temp7f": CH8_TEMP,
"humidity7": CH8_HUMIDITY,
}
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_checkbox"
# Misspelled key used by config entries created before version 2 (see `async_migrate_entry`).
POCASI_CZ_ENABLED_LEGACY: Final = "pocasi_enabled_chcekbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = (
f"Pocasi Meteo responded unexpectedly {POCASI_CZ_MAX_RETRIES} times in row. Resending is now disabled!"
)
WINDY_STATION_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD"
WINDY_ENABLED: Final = "windy_enabled_checkbox"
WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox"
WINDY_NOT_INSERTED: Final = "Windy responded with 400 error. Invalid ID/password combination?"
WINDY_INVALID_KEY: Final = (
"Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again."
)
WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully inserted by Windy API"
WINDY_UNEXPECTED: Final = (
f"Windy responded unexpectedly {WINDY_MAX_RETRIES} times in a row. Send to Windy is now disabled!"
)
REMAP_WSLINK_ITEMS: dict[str, str] = {
"intem": INDOOR_TEMP,
"inhum": INDOOR_HUMIDITY,
@ -158,9 +264,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1uvi": UV,
"t234c1tem": CH2_TEMP,
"t234c1hum": CH2_HUMIDITY,
"t1cn": OUTSIDE_CONNECTION,
"t234c1cn": CH2_CONNECTION,
"t234c2cn": CH3_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,
@ -169,36 +276,127 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1rainyr": YEARLY_RAIN,
"t234c2tem": CH3_TEMP,
"t234c2hum": CH3_HUMIDITY,
"t234c3tem": CH4_TEMP,
"t234c3hum": CH4_HUMIDITY,
"t234c4tem": CH5_TEMP,
"t234c4hum": CH5_HUMIDITY,
"t234c5tem": CH6_TEMP,
"t234c5hum": CH6_HUMIDITY,
"t234c6tem": CH7_TEMP,
"t234c6hum": CH7_HUMIDITY,
"t234c7tem": CH8_TEMP,
"t234c7hum": CH8_HUMIDITY,
"t1bat": OUTSIDE_BATTERY,
"inbat": INDOOR_BATTERY,
"t234c1bat": CH2_BATTERY,
"t234c2bat": CH3_BATTERY,
"t234c3bat": CH4_BATTERY,
"t234c4bat": CH5_BATTERY,
"t234c5bat": CH6_BATTERY,
"t234c6bat": CH7_BATTERY,
"t234c7bat": CH8_BATTERY,
"t1wbgt": WBGT_TEMP,
"t9hcho": HCHO,
"t9voclv": VOC,
"t9bat": T9_BATTERY, # T9 battery is 0-5, where 5 is full
}
DISABLED_BY_DEFAULT: Final = [
CH2_TEMP,
CH2_HUMIDITY,
CH2_BATTERY,
CH3_TEMP,
CH3_HUMIDITY,
CH4_TEMP,
CH4_HUMIDITY,
OUTSIDE_BATTERY,
WBGT_TEMP,
]
# NOTE: Add more sensors
#
# 'inbat' indoor battery level (1 normal, 0 low)
# 't1bat': outdoor battery level (1 normal, 0 low)
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
#
# In the following there are sensors that should be available by WSLink.
# We need to compare them to PWS API to make sure, we have the same internal
# representation of same sensors.
BATTERY_LIST = [
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
# &t5lst= Last Lightning strike time integer
# &t5lskm= Lightning distance integer km
# &t5lsf= Lightning strike count last 1 Hours integer
# &t5ls5mtc= Lightning count total of during 5 minutes integer
# &t5ls30mtc= Lightning count total of during 30 minutes integer
# &t5ls1htc= Lightning count total of during 1 Hour integer
# &t5ls1dtc= Lightning count total of during 1 day integer
# &t5lsbat= Lightning Sensor battery (Normal=1, Low battery=0) integer
# &t5lscn= Lightning Sensor connection (Connected=1, No connect=0) integer
# &t6c1wls= Water leak sensor CH1 (Leak=1, No leak=0) integer
# &t6c1bat= Water leak sensor CH1 battery (Normal=1, Low battery=0) integer
# &t6c1cn= Water leak sensor CH1 connection (Connected=1, No connect=0) integer
# &t6c2wls= Water leak sensor CH2 (Leak=1, No leak=0) integer
# &t6c2bat= Water leak sensor CH2 battery (Normal=1, Low battery=0) integer
# &t6c2cn= Water leak sensor CH2 connection (Connected=1, No connect=0) integer
# &t6c3wls= Water leak sensor CH3 (Leak=1, No leak=0) integer
# &t6c3bat= Water leak sensor CH3 battery (Normal=1, Low battery=0) integer
# &t6c3cn= Water leak sensor CH3 connection (Connected=1, No connect=0) integer
# &t6c4wls= Water leak sensor CH4 (Leak=1, No leak=0) integer
# &t6c4bat= Water leak sensor CH4 battery (Normal=1, Low battery=0) integer
# &t6c4cn= Water leak sensor CH4 connection (Connected=1, No connect=0) integer
# &t6c5wls= Water leak sensor CH5 (Leak=1, No leak=0) integer
# &t6c5bat= Water leak sensor CH5 battery (Normal=1, Low battery=0) integer
# &t6c5cn= Water leak sensor CH5 connection (Connected=1, No connect=0) integer
# &t6c6wls= Water leak sensor CH6 (Leak=1, No leak=0) integer
# &t6c6bat= Water leak sensor CH6 battery (Normal=1, Low battery=0) integer
# &t6c6cn= Water leak sensor CH6 connection (Connected=1, No connect=0) integer
# &t6c7wls= Water leak sensor CH7 (Leak=1, No leak=0) integer
# &t6c7bat= Water leak sensor CH7 battery (Normal=1, Low battery=0) integer
# &t6c7cn= Water leak sensor CH7 connection (Connected=1, No connect=0) integer
# &t8pm25= PM2.5 concentration integer ug/m3
# &t8pm10= PM10 concentration integer ug/m3
# &t8pm25ai= PM2.5 AQI integer
# &t8pm10ai = PM10 AQI integer
# &t8bat= PM sensor battery level (0~5) remark: 5 is full integer
# &t8cn= PM sensor connection (Connected=1, No connect=0) integer
# &t9hcho= HCHO concentration integer ppb
# &t9voclv= VOC level (1~5) 1 is the highest level, 5 is the lowest VOC level integer
# &t9bat= HCHO / VOC sensor battery level (0~5) remark: 5 is full integer
# &t9cn= HCHO / VOC sensor connection (Connected=1, No connect=0) integer
# &t10co2= CO2 concentration integer ppm
# &t10bat= CO2 sensor battery level (0~5) remark: 5 is full integer
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
# &t11co= CO concentration integer ppm
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
# &t11cn= CO sensor connection (Connected=1, No connect=0) integer
#
# How the station reports each battery decides which entity it becomes. The two tuples
# below are the single source of truth for that split - `battery_sensors_def` generates
# both entity description sets from them, so a key cannot end up with two entities.
#
# They must stay disjoint, and every `*_BATTERY` constant must appear in exactly one of
# them; `tests/test_battery_classification.py` enforces both. That matters because the
# WSLink API has more of each kind still to be implemented (see the TODO block above):
# `t5lsbat` / `t6c1-7bat` are 0/1, while `t8bat` / `t10bat` / `t11bat` are 0-5.
# Reported as 0/1 (low/normal) -> BinarySensorDeviceClass.BATTERY.
BATTERY_LIST: Final[tuple[str, ...]] = (
OUTSIDE_BATTERY,
INDOOR_BATTERY,
CH2_BATTERY,
]
CH3_BATTERY,
CH4_BATTERY,
CH5_BATTERY,
CH6_BATTERY,
CH7_BATTERY,
CH8_BATTERY,
)
# Reported as a 0-5 level, 5 being full -> percentage SensorDeviceClass.BATTERY.
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,)
BATTERY_NON_BINARY: list[str] = [T9_BATTERY]
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
# Multi-channel temp/humidity probes (CH2 - CH8)
"t234c1cn": [CH2_TEMP, CH2_HUMIDITY, CH2_BATTERY],
"t234c2cn": [CH3_TEMP, CH3_HUMIDITY, CH3_BATTERY],
"t234c3cn": [CH4_TEMP, CH4_HUMIDITY, CH4_BATTERY],
"t234c4cn": [CH5_TEMP, CH5_HUMIDITY, CH5_BATTERY],
"t234c5cn": [CH6_TEMP, CH6_HUMIDITY, CH6_BATTERY],
"t234c6cn": [CH7_TEMP, CH7_HUMIDITY, CH7_BATTERY],
"t234c7cn": [CH8_TEMP, CH8_HUMIDITY, CH8_BATTERY],
# T9 HCHO/VOC probe
"t9cn": [HCHO, VOC, T9_BATTERY],
}
@ -268,11 +466,5 @@ class UnitOfBat(StrEnum):
LOW = "low"
NORMAL = "normal"
UNKNOWN = "unknown"
UNKNOWN = "drained"
BATTERY_LEVEL: list[UnitOfBat] = [
UnitOfBat.LOW,
UnitOfBat.NORMAL,
UnitOfBat.UNKNOWN,
]

View File

@ -0,0 +1,338 @@
"""Push coordinator for the SWS-12500 weather station integration.
This module is the runtime heart of the integration:
- `WeatherDataUpdateCoordinator` is a fan-out hub for push payloads.
Webhook handlers call `async_set_updated_data(...)` and every CoordinatorEntity
subscribed to the coordinator updates its state.
- `received_data` handles the legacy PWS / WSLink endpoints.
- `received_ecowitt_data` handles the Ecowitt endpoint via the aioecowitt parser.
- `IncorrectDataError` is raised when the integration's auth options are missing.
Kept separate from `__init__.py` so the platforms (sensor / binary_sensor) can
import `WeatherDataUpdateCoordinator` without re-entering the package's
initialization machinery no more local-import `# noqa: PLC0415` shims.
"""
from __future__ import annotations
import hmac
import logging
from typing import Any
import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import InvalidStateError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .binary_sensor import add_new_binary_sensors
from .const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
)
from .data import SWSConfigEntry
from .ecowitt import EcowittBridge
from .health_coordinator import HealthCoordinator
from .pocasti_cz import PocasiPush
from .sensor import add_new_sensors
from .utils import (
anonymize,
check_disabled,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
)
from .windy_func import WindyPush
_LOGGER = logging.getLogger(__name__)
class IncorrectDataError(InvalidStateError):
"""Raised when the integration's auth options are missing or invalid."""
class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator for push updates.
Even though Home Assistant's `DataUpdateCoordinator` is often used for polling,
it also works well as a fan-out mechanism for push integrations:
- webhook handler updates `self.data` via `async_set_updated_data`
- all `CoordinatorEntity` instances subscribed update themselves
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize the coordinator."""
self.hass: HomeAssistant = hass
self.config: SWSConfigEntry = config
self.windy: WindyPush = WindyPush(hass, config)
self.pocasi: PocasiPush = PocasiPush(hass, config)
self.ecowitt_bridge: EcowittBridge = EcowittBridge(hass, config)
super().__init__(hass, _LOGGER, config_entry=config, name=DOMAIN)
def _health_coordinator(self) -> HealthCoordinator | None:
"""Return the health coordinator for this config entry."""
try:
return self.config.runtime_data.health_coordinator
except AttributeError:
return None
def _validate_credentials(
self,
data: dict[str, Any],
webdata: aiohttp.web.Request,
*,
wslink: bool,
health: HealthCoordinator | None,
) -> None:
"""Validate station credentials for the legacy / WSLink endpoint.
Raises HTTPUnauthorized (missing/empty/wrong credentials) or IncorrectDataError
(integration not configured); returns None on success.
"""
id_key, pw_key = ("wsid", "wspw") if wslink else ("ID", "PASSWORD")
if id_key not in data or pw_key not in data:
_LOGGER.error("Invalid request. No security data provided!")
if health:
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="missing_credentials")
raise HTTPUnauthorized
id_data = data.get(id_key, "")
key_data = data.get(pw_key, "")
if (_id := checked(self.config.options.get(API_ID), str)) is None:
_LOGGER.error("We don't have API ID set! Update your config!")
if health:
health.update_ingress_result(webdata, accepted=False, authorized=None, reason="config_missing_api_id")
raise IncorrectDataError
if (_key := checked(self.config.options.get(API_KEY), str)) is None:
_LOGGER.error("We don't have API KEY set! Update your config!")
if health:
health.update_ingress_result(webdata, accepted=False, authorized=None, reason="config_missing_api_key")
raise IncorrectDataError
# Defense-in-depth: reject empty configured/incoming credentials so this handler
# is self-protecting regardless of how options were set.
if not _id or not _key:
_LOGGER.error("API ID/KEY is empty! Update your config!")
if health:
health.update_ingress_result(
webdata, accepted=False, authorized=None, reason="config_missing_credentials"
)
raise IncorrectDataError
if not id_data or not key_data:
_LOGGER.error("Unauthorised access! Empty credentials.")
if health:
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="unauthorized")
raise HTTPUnauthorized
# Constant-time comparison; both operands are always compared (no short-circuit)
# and encoded to bytes so non-ASCII credentials are handled safely.
id_ok = hmac.compare_digest(id_data.encode("utf-8"), _id.encode("utf-8"))
key_ok = hmac.compare_digest(key_data.encode("utf-8"), _key.encode("utf-8"))
if not (id_ok & key_ok):
_LOGGER.error("Unauthorised access!")
if health:
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="unauthorized")
raise HTTPUnauthorized
async def received_ecowitt_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
"""Handle incoming Ecowitt webhook payload.
Uses aioecowitt for payload parsing. Sensors with internal mapping
join the SWS pipeline; sensors without mapping create native Ecowitt
entities through the bridge callback.
"""
# See received_data: reject cleanly if the entry is mid-reload (no runtime_data).
if getattr(self.config, "runtime_data", None) is None:
return aiohttp.web.Response(text="Integration is reloading.", status=503)
health = self._health_coordinator()
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=None,
reason="ecowitt_disabled",
)
return aiohttp.web.Response(text="Ecowitt disabled", status=403)
expected_webhook = self.config.options.get(ECOWITT_WEBHOOK_ID, "")
actual_webhook = webdata.match_info.get("webhook_id", "")
# 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(
webdata,
accepted=False,
authorized=False,
reason="ecowitt_invalid_webhook_id",
)
raise HTTPUnauthorized
post_data = await webdata.post()
data: dict[str, Any] = dict(post_data)
# Record the Ecowitt station model (used as the device model) before parsing,
# so native entities created during process_payload report it.
if model := checked(data.get("model"), str):
self.config.runtime_data.ecowitt_model = model
mapped_data = await self.ecowitt_bridge.process_payload(data)
if mapped_data:
if sensors := check_disabled(mapped_data, self.config):
newly_discovered = list(sensors)
if _loaded_sensors := loaded_sensors(self.config):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
add_new_binary_sensors(self.hass, self.config, newly_discovered)
add_new_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(mapped_data)
now = dt_util.utcnow()
for key in mapped_data:
self.config.runtime_data.last_seen[key] = now
if health:
health.update_ingress_result(
webdata,
accepted=True,
authorized=True,
reason="accepted",
)
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, False)
# TODO: create ecowitt protocol to send full payload to Pocasi CZ
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WU")
if health:
health.update_forwarding(self.windy, self.pocasi)
if checked_or(self.config.options.get(DEV_DBG), bool, False):
_LOGGER.info("Dev log (ecowitt): %s", anonymize(data))
return aiohttp.web.Response(body="OK", status=200)
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
"""Handle incoming webhook payload from the station.
- validates authentication (different keys for WU vs WSLink)
- optionally forwards data to third-party services (Windy / Pocasi)
- remaps payload keys to internal sensor keys
- auto-discovers new sensor fields and adds entities dynamically
- updates coordinator data so existing entities refresh immediately
"""
# The aiohttp routes outlive a config-entry reload and keep pointing at this
# bound method. If a payload arrives while the entry is unloaded, runtime_data
# is gone; reject cleanly with 503 instead of raising AttributeError (500).
if getattr(self.config, "runtime_data", None) is None:
return aiohttp.web.Response(text="Integration is reloading.", status=503)
# WSLink uses different auth and payload field naming than the legacy endpoint.
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
get_data = webdata.query
post_data = await webdata.post()
data: dict[str, Any] = {**dict(get_data), **dict(post_data)}
health = self._health_coordinator()
self._validate_credentials(data, webdata, wslink=_wslink, health=health)
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
if sensors := check_disabled(remaped_items, self.config):
# 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",
)
if name is not None:
translated_sensors.append(name)
human_readable = "\n".join(translated_sensors)
await translated_notification(
self.hass,
DOMAIN,
"added",
{"added_sensors": f"{human_readable}\n"},
)
newly_discovered = list(sensors)
if _loaded_sensors := loaded_sensors(self.config):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
# Dynamic adds avoid the listener-drop window of a full reload.
add_new_sensors(self.hass, self.config, newly_discovered)
add_new_binary_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(remaped_items)
now = dt_util.utcnow()
for key in remaped_items:
self.config.runtime_data.last_seen[key] = now
if health:
health.update_ingress_result(
webdata,
accepted=True,
authorized=True,
reason="accepted",
)
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, _wslink)
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
if health:
health.update_forwarding(self.windy, self.pocasi)
if checked_or(self.config.options.get(DEV_DBG), bool, False):
_LOGGER.info("Dev log: %s", anonymize(data))
return aiohttp.web.Response(body="OK", status=200)

View File

@ -0,0 +1,100 @@
"""Shared keys for storing integration runtime data.
HA 2025+ pattern: typed `ConfigEntry[SWSRuntimeData]` replaces ad-hoc `hass.data[DOMAIN][entry_id]` dicts.
All per-entry state lives here. Cross-reload shared state (aiohttp route registrations) stays under
hass.data[DOMAIN]["routes"] because it must outlive a single entry reload.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from py_typecheck import checked_or
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .conflicts import effective_protocols
from .const import DOMAIN, WSLINK
if TYPE_CHECKING:
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
from .coordinator import WeatherDataUpdateCoordinator
from .health_coordinator import HealthCoordinator
from .sensors_common import WeatherSensorEntityDescription
@dataclass
class SWSRuntimeData:
"""Per-entry runtime state for SWS12500 integration.
Stored in entry.runtime_data. Type-safe.
"""
# Core coordinators - required - create during `async_setup_entry`.
coordinator: WeatherDataUpdateCoordinator
health_coordinator: HealthCoordinator
last_options: dict[str, Any]
# Sensor platform callbacks (set by sensor.async_setup_entry)
add_sensor_entities: AddEntitiesCallback | None = None
sensor_descriptions: dict[str, WeatherSensorEntityDescription] = field(default_factory=dict)
# Binary sensor platform callbacks
add_binary_entities: AddEntitiesCallback | None = None
binary_descriptions: dict[str, BinarySensorEntityDescription] = field(default_factory=dict)
added_binary_keys: set[str] = field(default_factory=set)
# Health data cache for diagnostics - refreshed by `HealthCoordinator` on each tick.
health_data: dict[str, Any] | None = None
# Staleness tracking - in-memory, resets on reload.
started_at: datetime = field(default_factory=dt_util.utcnow)
last_seen: dict[str, datetime] = field(default_factory=dict)
# Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload.
ecowitt_model: str | None = None
# Type alias for typed ConfigEntry
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
def _station_model(entry: SWSConfigEntry) -> str:
"""Return the device model label reflecting the running station type.
Ecowitt (with the learned model when available), else WSLink, else PWS.
Uses the *effective* protocols so a stale both-enabled config reports the endpoint
that is actually wired up, rather than one that is being ignored.
"""
legacy, ecowitt = effective_protocols(entry)
if ecowitt:
runtime = getattr(entry, "runtime_data", None)
model = getattr(runtime, "ecowitt_model", None) if runtime is not None else None
return f"Ecowitt {model}" if model else "Ecowitt"
if legacy and checked_or(entry.options.get(WSLINK), bool, False):
return "WSLink"
return "PWS"
def build_device_info(entry: SWSConfigEntry) -> DeviceInfo:
"""Single device shared by all entities (SWS, battery, health, native Ecowitt).
Keeps the existing ``{(DOMAIN,)}`` identifier so no device-registry migration is
needed; the model reflects the active station type.
"""
return DeviceInfo(
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
manufacturer="Schizza",
model=_station_model(entry),
)

View File

@ -0,0 +1,63 @@
"""Diagnostics support for the SWS12500 integration."""
from __future__ import annotations
from copy import deepcopy
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,
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,
WINDY_STATION_PW,
"ID",
"PASSWORD",
"wsid",
"wspw",
# Internal network details from the health snapshot (admin-only download, but
# diagnostics are commonly shared in bug reports).
"home_assistant_source_ip",
"home_assistant_url",
"health_url",
"info_url",
"raw_status",
}
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: SWSConfigEntry) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
del hass # Unused, but required by the interface
runtime = entry.runtime_data
health_data = runtime.health_data
if health_data is None:
# Fallback to the live coordinator snapshot if no payload has been persisted yet.
health_data = runtime.health_coordinator.data
return {
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
"health_data": async_redact_data(
deepcopy(health_data) if health_data else {},
TO_REDACT,
),
}

View File

@ -0,0 +1,404 @@
"""Ecowitt bridge.
Uses the ecowitt library for payload parsing and sensor discovery,
but does NOT start a separate HTTP server. Instead, the HA webhook handler
feeds raw POST data into EcoWittListener.process_data().
Sensors that have an internal mapping (REMAP_ECOWITT_COMPACT) are unified
with the existing SWS sensor pipeline. Unmapped sensors are exposed as
native Ecowitt entities for forward compatibility.
"""
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any, Final
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
try:
# Internal to aioecowitt; see `_build_unit_twins` for why this is tolerated.
from aioecowitt.sensor import SENSOR_MAP
except ImportError: # pragma: no cover - defensive, module moved upstream
SENSOR_MAP = {}
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import REMAP_ECOWITT_COMPAT
from .data import SWSConfigEntry, build_device_info
_LOGGER = logging.getLogger(__name__)
# Reverse mapping: internal key to ecowitt field name
# we need to know which key is internally covered.
_MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
# Upper bound on auto-created native Ecowitt entities. Bounds entity-registry growth
# from an (authenticated) sender that fabricates many distinct sensor keys.
MAX_NATIVE_ECOWITT_SENSORS: Final = 64
def _build_unit_twins() -> dict[str, frozenset[str]]:
"""Group aioecowitt keys that are unit variants of the same quantity.
aioecowitt exposes both metric and imperial sensors for many readings (e.g.
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
`SENSOR_MAP` is aioecowitt-internal (not part of its public API), so an upstream
rename must not take the whole integration down with an ImportError/AttributeError
at module import. Degrading to "no twins known" only costs us the duplicate-unit
dedup in `_on_new_sensor`.
"""
try:
sensor_map = SENSOR_MAP.items()
except AttributeError: # pragma: no cover - defensive, shape changed upstream
_LOGGER.warning("aioecowitt SENSOR_MAP is not a mapping; unit-variant dedup disabled")
return {}
by_name: dict[str, set[str]] = {}
for key, meta in sensor_map:
name = getattr(meta, "name", None)
if name is None: # pragma: no cover - defensive
continue
by_name.setdefault(name, set()).add(key)
twins: dict[str, frozenset[str]] = {}
for keys in by_name.values():
if len(keys) > 1:
group = frozenset(keys)
for key in keys:
twins[key] = group
return twins
# key -> set of its unit-variant twin keys (incl. itself).
_UNIT_TWINS: dict[str, frozenset[str]] = _build_unit_twins()
# Curated translation keys for the common native (unmapped) Ecowitt sensors. Both unit
# variants map to the same slug so the name is stable regardless of the station's units.
# Long-tail / multi-channel sensors fall back to aioecowitt's English name.
_ECOWITT_TRANSLATIONS: dict[str, str] = {
"baromabsin": "ecowitt_absolute_pressure",
"baromabshpa": "ecowitt_absolute_pressure",
"rainratein": "ecowitt_rain_rate",
"rainratemm": "ecowitt_rain_rate",
"eventrainin": "ecowitt_event_rain",
"eventrainmm": "ecowitt_event_rain",
"hourlyrainin": "ecowitt_hourly_rain",
"hourlyrainmm": "ecowitt_hourly_rain",
"weeklyrainin": "ecowitt_weekly_rain",
"weeklyrainmm": "ecowitt_weekly_rain",
"monthlyrainin": "ecowitt_monthly_rain",
"monthlyrainmm": "ecowitt_monthly_rain",
"yearlyrainin": "ecowitt_yearly_rain",
"yearlyrainmm": "ecowitt_yearly_rain",
"totalrainin": "ecowitt_total_rain",
"totalrainmm": "ecowitt_total_rain",
"last24hrainin": "ecowitt_24h_rain",
"last24hrainmm": "ecowitt_24h_rain",
"tempfeelsc": "ecowitt_feels_like",
"tempfeelsf": "ecowitt_feels_like",
"dewpointinc": "ecowitt_indoor_dewpoint",
"dewpointinf": "ecowitt_indoor_dewpoint",
"co2in": "ecowitt_console_co2",
"co2in_24h": "ecowitt_console_co2_24h",
"co2": "ecowitt_co2",
"co2_24h": "ecowitt_co2_24h",
}
# aioecowitt sensor type to HA device class + unit
# We cover most common types, additional will be covered later.
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
EcoWittSensorTypes.TEMPERATURE_C: (
SensorDeviceClass.TEMPERATURE,
"°C",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.TEMPERATURE_F: (
SensorDeviceClass.TEMPERATURE,
"°F",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.HUMIDITY: (
SensorDeviceClass.HUMIDITY,
"%",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PRESSURE_HPA: (
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
"hPa",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PRESSURE_INHG: (
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
"inHg",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SPEED_KPH: (
SensorDeviceClass.WIND_SPEED,
"km/h",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SPEED_MPH: (
SensorDeviceClass.WIND_SPEED,
"mph",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.DEGREE: (
None,
"°",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.WATT_METERS_SQUARED: (
SensorDeviceClass.IRRADIANCE,
"W/m²",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.UV_INDEX: (
None,
"UV index",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PM25: (
SensorDeviceClass.PM25,
"µg/m³",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PM10: (
SensorDeviceClass.PM10,
"µg/m³",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.CO2_PPM: (
SensorDeviceClass.CO2,
"ppm",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.RAIN_RATE_MM: (
SensorDeviceClass.PRECIPITATION_INTENSITY,
"mm/h",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.RAIN_COUNT_MM: (
SensorDeviceClass.PRECIPITATION,
"mm",
SensorStateClass.TOTAL_INCREASING,
),
EcoWittSensorTypes.RAIN_RATE_INCHES: (
SensorDeviceClass.PRECIPITATION_INTENSITY,
"in/h",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.RAIN_COUNT_INCHES: (
SensorDeviceClass.PRECIPITATION,
"in",
SensorStateClass.TOTAL_INCREASING,
),
EcoWittSensorTypes.LIGHTNING_COUNT: (
None,
"strikes",
SensorStateClass.TOTAL_INCREASING,
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: (
SensorDeviceClass.DISTANCE,
"km",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SOIL_MOISTURE: (
SensorDeviceClass.MOISTURE,
"%",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.BATTERY_VOLTAGE: (
SensorDeviceClass.VOLTAGE,
"V",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.BATTERY_PERCENTAGE: (
SensorDeviceClass.BATTERY,
"%",
SensorStateClass.MEASUREMENT,
),
}
class EcowittBridge:
"""Bridge between HA webhook and aioecowitt parsing.
We do not run EcoWittListener.start() - this would start separate HTTP server.
Instead we are calling listener.process_data() manually from our webhook handler
and we are just using parsing/discovery logic.
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize bridge."""
self.hass = hass
self.config = config
# Listener without start - just parser
self._listener = EcoWittListener()
# Callback for new sensors
self._listener.new_sensor_cb.append(self._on_new_sensor)
# We need to know which native ecowitt sensors have an entity
self._know_native_keys: set[str] = set()
# Callback for new entities
self._add_entities_cb: AddEntitiesCallback | None = None
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
"""Store the platform callback for dynamic entity creation.
aioecowitt fires `new_sensor_cb` only once per key. If a payload arrived
before the platform was ready (callback unset), those unmapped sensors were
skipped and would never get an entity. Flush them now so nothing is lost.
"""
self._add_entities_cb = callback
for sensor in self.unmapped_sensor.values():
self._on_new_sensor(sensor)
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
"""Process raw Ecowitt POST payload.
Returns:
Dict of internal sensor keys -> values (for mapped sensors).
Unmapped sensors are handled via _on_new_sensor callback.
"""
# Let aioecowitt parse payload and derive values,
# then call new_sensors_cb for new sensors
self._listener.process_data(data)
# Get values for internally mapped sensors
mapped_result: dict[str, str] = {}
for ecowitt_key, internal_key in REMAP_ECOWITT_COMPAT.items():
if ecowitt_key in data:
mapped_result[internal_key] = data[ecowitt_key]
return mapped_result
def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
"""Call me by aioecowitt when a new sensor is discovered.
If the sensor does not have internal mapping,
create native Ecowitt entity.
"""
# Sensors with internal mapping handle with current pipeline.
if sensor.key in _MAPPED_ECOWITT_KEYS:
_LOGGER.debug(
"Ecowitt sensor %s has internal mapping, skipping native entity",
sensor.key,
)
return
# Is entity created?
if sensor.key in self._know_native_keys:
return
# Skip unit-variant duplicates: if a twin (e.g. the °C form of an already
# mapped °F sensor, or the other unit of an already-created native one) is
# handled, don't create a second entity. HA converts units via device_class.
twins = _UNIT_TWINS.get(sensor.key, frozenset())
if any(twin in _MAPPED_ECOWITT_KEYS or twin in self._know_native_keys for twin in twins):
_LOGGER.debug("Ecowitt sensor %s skipped: unit-variant twin already handled", sensor.key)
return
if self._add_entities_cb is None:
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
return
if len(self._know_native_keys) >= MAX_NATIVE_ECOWITT_SENSORS:
_LOGGER.warning(
"Reached the cap of %s native Ecowitt sensors; ignoring new key %s",
MAX_NATIVE_ECOWITT_SENSORS,
sensor.key,
)
return
self._know_native_keys.add(sensor.key)
entity = EcoWittNativeSensor(sensor, self.config)
self._add_entities_cb([entity])
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
@property
def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
"""Return all sensors that don't have an internal mapping."""
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
@property
def all_sensors(self) -> dict[str, EcoWittSensor]:
"""Return every sensor aioecowitt has parsed so far."""
return self._listener.sensors
class EcoWittNativeSensor(SensorEntity):
"""Sensor entity for Ecowitt sensors without internal mapping.
These entities are "pass-through" - their values are directly from EcoWittSensor
and maps `stype` to HA device class. They do not have coordinator, because
EcoWittSensor have his own update_cb callback mechanism.
"""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
"""Initialize native EcoWittSensor."""
self._ecowitt_sensor = sensor
self._attr_unique_id = f"ecowitt_{sensor.key}"
# Use a curated translation_key for common native sensors; otherwise fall back
# to aioecowitt's English name. _attr_translation_key is always set (None when
# there is no curated translation) so the attribute is well defined.
self._attr_translation_key = _ECOWITT_TRANSLATIONS.get(sensor.key)
if self._attr_translation_key is None:
self._attr_name = sensor.name
# 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
self._attr_device_class = device_class
self._attr_native_unit_of_measurement = unit
self._attr_state_class = state_class
# Share the single integration device (see data.build_device_info); the station
# type is reflected in the device model rather than a separate Ecowitt device.
self._attr_device_info = build_device_info(config)
@property
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]
"""Current value from Ecowitt sensor."""
value = self._ecowitt_sensor.value
if value is None or value == "":
return None
return value
async def async_added_to_hass(self) -> None:
"""Register update callback when entity is added to HA."""
self._ecowitt_sensor.update_cb.append(self._handle_update)
async def async_will_remove_from_hass(self) -> None:
"""Remove update callback when entity is removed."""
if self._handle_update in self._ecowitt_sensor.update_cb:
self._ecowitt_sensor.update_cb.remove(self._handle_update)
@callback
def _handle_update(self) -> None:
"""Handle sensor values update from aioecowitt."""
self.async_write_ha_state()

View File

@ -0,0 +1,453 @@
"""Health and diagnostics coordinator for the SWS12500 integration.
This module owns the integration's runtime health model. The intent is to keep
all support/debug state in one place so it can be surfaced consistently via:
- diagnostic entities (`health_sensor.py`)
- diagnostics download (`diagnostics.py`)
- the `/station/health` HTTP endpoint
The coordinator is intentionally separate from the weather data coordinator.
Weather payload handling is push-based, while health metadata is lightweight
polling plus event-driven updates (route dispatch, ingress result, forwarding).
"""
from __future__ import annotations
from asyncio import timeout
from copy import deepcopy
from datetime import timedelta
import logging
from typing import Any, Final
import aiohttp
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
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_URL,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_URL_PREFIX,
HEALTH_URL,
LEGACY_ENABLED,
POCASI_CZ_ENABLED,
WINDY_ENABLED,
WSLINK,
WSLINK_ADDON_PORT,
WSLINK_URL,
)
from .data import SWSConfigEntry
from .pocasti_cz import PocasiPush
from .routes import Routes
from .windy_func import WindyPush
_LOGGER = logging.getLogger(__name__)
# Protocols that represent a real, accepted ingress (not health / unknown).
_REAL_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink", "ecowitt"})
_LEGACY_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink"})
# Expected ways the optional add-on probe can fail. All of them simply mean
# "add-on not reachable" and are logged at debug level (see `_async_update_data`).
# - TimeoutError : `asyncio.timeout` expiry, e.g. a firewall dropping packets
# - aiohttp.ClientError: connection refused, TLS failures, bad response, ...
# - OSError : raw socket errors not wrapped by aiohttp
# - NoURLAvailableError: HA cannot resolve its own URL
_PROBE_ERRORS: Final = (TimeoutError, aiohttp.ClientError, OSError, NoURLAvailableError)
def _configured_protocol(config: SWSConfigEntry) -> str:
"""Return the primary configured protocol (wu / wslink / ecowitt).
The legacy PWS/WSLink endpoint takes precedence when enabled; otherwise an
Ecowitt-only setup reports "ecowitt". (Legacy and Ecowitt can be enabled at the
same time; this just labels the primary protocol for the summary.)
"""
if checked_or(config.options.get(LEGACY_ENABLED), bool, True):
return "wslink" if checked_or(config.options.get(WSLINK), bool, False) else "wu"
if checked_or(config.options.get(ECOWITT_ENABLED), bool, False):
return "ecowitt"
return "wu"
def _protocol_from_path(path: str) -> str:
"""Infer an ingress protocol label from a request path."""
if path == WSLINK_URL:
return "wslink"
if path == DEFAULT_URL:
return "wu"
if path == HEALTH_URL:
return "health"
if path.startswith(ECOWITT_URL_PREFIX + "/"):
return "ecowitt"
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
# Fields under "addon" that reveal internal network topology. They must not be
# exposed via entity attributes (readable by any HA user, incl. non-admins).
_SENSITIVE_ADDON_FIELDS: frozenset[str] = frozenset(
{"health_url", "info_url", "home_assistant_url", "home_assistant_source_ip", "raw_status"}
)
def public_health_snapshot(data: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of the health snapshot safe to expose to any HA user.
Removes internal network details (HA source IP, internal URLs, raw add-on
status). The full snapshot stays available only via the authenticated
`/station/health` endpoint and the admin-only (redacted) diagnostics download.
"""
public: dict[str, Any] = deepcopy(data)
addon = checked(public.get("addon"), dict[str, Any])
if addon is not None:
for field in _SENSITIVE_ADDON_FIELDS:
addon.pop(field, None)
return public
def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
"""Build the default forwarding status payload."""
return {
"enabled": enabled,
"last_status": "disabled" if not enabled else "idle",
"last_error": None,
"last_attempt_at": None,
}
def _default_health_data(config: SWSConfigEntry) -> dict[str, Any]:
"""Build the default health/debug payload for this config entry."""
configured_protocol = _configured_protocol(config)
return {
"integration_status": f"online_{configured_protocol}",
"configured_protocol": configured_protocol,
"active_protocol": configured_protocol,
"addon": {
"online": False,
"health_endpoint": "/healthz",
"info_endpoint": "/status/internal",
"name": None,
"version": None,
"listen_port": None,
"tls": None,
"upstream_ha_port": None,
"paths": {
"wslink": WSLINK_URL,
"wu": DEFAULT_URL,
},
"raw_status": None,
},
"routes": {
"wu_enabled": False,
"wslink_enabled": False,
"health_enabled": False,
"snapshot": {},
},
"last_ingress": {
"time": None,
"protocol": "unknown",
"path": None,
"method": None,
"route_enabled": False,
"accepted": False,
"authorized": None,
"reason": "no_data",
},
"forwarding": {
"windy": _empty_forwarding_state(checked_or(config.options.get(WINDY_ENABLED), bool, False)),
"pocasi": _empty_forwarding_state(checked_or(config.options.get(POCASI_CZ_ENABLED), bool, False)),
},
}
class HealthCoordinator(DataUpdateCoordinator):
"""Maintain the integration health snapshot.
The coordinator combines:
- periodic add-on reachability checks
- live ingress observations from the HTTP dispatcher
- ingress processing results from the main webhook handler
- forwarding status from Windy/Pocasi helpers
All of that is stored as one structured JSON-like dict in `self.data`.
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize the health coordinator."""
self.hass: HomeAssistant = hass
self.config: SWSConfigEntry = config
super().__init__(
hass,
logger=_LOGGER,
config_entry=config,
name=f"{DOMAIN}_health",
update_interval=timedelta(minutes=1),
)
self.data: dict[str, Any] = _default_health_data(config)
def _store_runtime_health(self, data: dict[str, Any]) -> None:
"""Persist the latest health payload into entry runtime storage."""
try:
self.config.runtime_data.health_data = deepcopy(data)
except AttributeError:
# runtime_data may not be set up yet during early initialization; that's fine, we'll populate it on the next tick.
return
def _commit(self, data: dict[str, Any]) -> dict[str, Any]:
"""Publish a new health snapshot."""
self.async_set_updated_data(data)
self._store_runtime_health(data)
return data
def _refresh_summary(self, data: dict[str, Any]) -> None:
"""Derive top-level integration status from the detailed health payload."""
configured_protocol = data.get("configured_protocol", "wu")
ingress = data.get("last_ingress", {})
last_protocol = ingress.get("protocol", "unknown")
accepted = bool(ingress.get("accepted"))
reason = ingress.get("reason")
# A WU vs WSLink mismatch means the station is misconfigured for the legacy
# endpoint. Ecowitt coexists with the legacy endpoint, so it never counts as a
# mismatch - it is a valid protocol whenever a payload arrives on its route.
legacy_mismatch = (
last_protocol in _LEGACY_PROTOCOLS
and configured_protocol in _LEGACY_PROTOCOLS
and last_protocol != configured_protocol
)
if (reason in {"route_disabled", "route_not_registered", "unauthorized"}) or legacy_mismatch:
integration_status = "degraded"
elif accepted and last_protocol in _REAL_PROTOCOLS:
integration_status = f"online_{last_protocol}"
else:
integration_status = "online_idle"
data["integration_status"] = integration_status
data["active_protocol"] = (
last_protocol if accepted and last_protocol in _REAL_PROTOCOLS else configured_protocol
)
async def _probe_addon(self, addon: dict[str, Any]) -> None:
"""Fill `addon` with live WSLink proxy metadata.
May raise: turning a failed probe into `online: False` is the caller's job.
Fields are written as they are resolved, so a failure part-way through still
leaves the snapshot with everything learned up to that point.
"""
session = async_get_clientsession(self.hass, False)
ip = await async_get_source_ip(self.hass)
port = checked_or(self.config.options.get(WSLINK_ADDON_PORT), int, 443)
health_url = f"https://{ip}:{port}/healthz"
info_url = f"https://{ip}:{port}/status/internal"
addon["health_url"] = health_url
addon["info_url"] = info_url
addon["home_assistant_source_ip"] = str(ip)
# Informational only, and independently fallible (`NoURLAvailableError`),
# so it must not prevent the reachability probe below from running.
try:
addon["home_assistant_url"] = get_url(self.hass)
except NoURLAvailableError:
_LOGGER.debug("No Home Assistant URL available for the health snapshot")
async with timeout(5), session.get(health_url) as response:
addon["online"] = checked(response.status, int) == 200
if not addon["online"]:
return
raw_status: dict[str, Any] | None = None
try:
async with timeout(5), session.get(info_url) as info_response:
if checked(info_response.status, int) == 200:
# A non-dict body (or malformed JSON) is treated as "no status".
raw_status = checked(await info_response.json(content_type=None), dict[str, Any])
except (*_PROBE_ERRORS, ValueError):
raw_status = None
addon["raw_status"] = raw_status
if not raw_status:
return
listen = checked_or(raw_status.get("listen"), dict[str, Any], {})
upstream = checked_or(raw_status.get("upstream"), dict[str, Any], {})
paths = checked_or(raw_status.get("paths"), dict[str, Any], {})
addon["name"] = raw_status.get("addon")
addon["version"] = raw_status.get("version")
addon["listen_port"] = listen.get("port")
addon["tls"] = listen.get("tls")
addon["upstream_ha_port"] = upstream.get("ha_port")
addon["paths"] = {
"wslink": paths.get("wslink", WSLINK_URL),
"wu": paths.get("wu", DEFAULT_URL),
}
async def _async_update_data(self) -> dict[str, Any]:
"""Refresh add-on health metadata from the WSLink proxy.
The proxy add-on can front any protocol (WU / WSLink / Ecowitt), so the probe
is not gated on a specific protocol option - it always runs.
The add-on is *optional* and this coordinator only produces diagnostics, so the
probe must never fail the update. `async_config_entry_first_refresh` turns any
exception raised here into `ConfigEntryNotReady`, which would take the station
webhook - the actual job of this integration - down with it. An unreachable or
misbehaving add-on is therefore recorded as `online: False`, never raised.
"""
data = deepcopy(self.data)
addon = data["addon"]
addon["online"] = False
addon["raw_status"] = None
try:
await self._probe_addon(addon)
except _PROBE_ERRORS as err:
_LOGGER.debug("WSLink add-on probe failed (%s): %s", type(err).__name__, err)
addon["online"] = False
except Exception: # noqa: BLE001 - diagnostics must never fail the config entry
_LOGGER.exception("Unexpected error while probing the WSLink add-on")
addon["online"] = False
self._refresh_summary(data)
return self._commit(data)
def update_routing(self, routes: Routes | None) -> None:
"""Store the currently enabled routes for diagnostics."""
data = deepcopy(self.data)
data["configured_protocol"] = _configured_protocol(self.config)
if routes is not None:
data["routes"] = {
"wu_enabled": routes.path_enabled(DEFAULT_URL),
"wslink_enabled": routes.path_enabled(WSLINK_URL),
"health_enabled": routes.path_enabled(HEALTH_URL),
"snapshot": routes.snapshot(),
}
self._refresh_summary(data)
self._commit(data)
def record_dispatch(self, request: aiohttp.web.Request, route_enabled: bool, reason: str | None) -> None:
"""Record every ingress observed by the dispatcher.
This runs before the actual webhook handler. It lets diagnostics answer:
- which endpoint the station is calling
- whether the route was enabled
- whether the request was rejected before processing
"""
# We do not want to process health requests
if request.path == HEALTH_URL:
return
data = deepcopy(self.data)
data["last_ingress"] = {
"time": dt_util.utcnow().isoformat(),
"protocol": _protocol_from_path(request.path),
"path": _sanitize_path(request.path),
"method": request.method,
"route_enabled": route_enabled,
"accepted": False,
"authorized": None,
"reason": reason or "pending",
}
self._refresh_summary(data)
self._commit(data)
def update_ingress_result(
self,
request: aiohttp.web.Request,
*,
accepted: bool,
authorized: bool | None,
reason: str | None = None,
) -> None:
"""Store the final processing result of a webhook request."""
data = deepcopy(self.data)
ingress = data.get("last_ingress", {})
ingress.update(
{
"time": dt_util.utcnow().isoformat(),
"protocol": _protocol_from_path(request.path),
"path": _sanitize_path(request.path),
"method": request.method,
"accepted": accepted,
"authorized": authorized,
"reason": reason or ("accepted" if accepted else "rejected"),
}
)
data["last_ingress"] = ingress
self._refresh_summary(data)
self._commit(data)
def update_forwarding(self, windy: WindyPush, pocasi: PocasiPush) -> None:
"""Store forwarding subsystem statuses for diagnostics."""
data = deepcopy(self.data)
data["forwarding"] = {
"windy": {
"enabled": windy.enabled,
"last_status": windy.last_status,
"last_error": windy.last_error,
"last_attempt_at": windy.last_attempt_at,
},
"pocasi": {
"enabled": pocasi.enabled,
"last_status": pocasi.last_status,
"last_error": pocasi.last_error,
"last_attempt_at": pocasi.last_attempt_at,
},
}
self._refresh_summary(data)
self._commit(data)
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)

View File

@ -0,0 +1,267 @@
"""Health diagnostic sensors for SWS-12500."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any
from py_typecheck import checked, checked_or
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .data import SWSConfigEntry, build_device_info
from .health_coordinator import HealthCoordinator, public_health_snapshot
if TYPE_CHECKING:
from .health_coordinator import HealthCoordinator
@dataclass(frozen=True, kw_only=True)
class HealthSensorEntityDescription(SensorEntityDescription):
"""Description for health diagnostic sensors."""
data_path: tuple[str, ...]
value_fn: Callable[[Any], Any] | None = None
def _resolve_path(data: dict[str, Any], path: tuple[str, ...]) -> Any:
"""Resolve a nested path from a dictionary."""
current: Any = data
for key in path:
if checked(current, dict[str, Any]) is None:
return None
current = current.get(key)
return current
def _on_off(value: Any) -> str:
"""Render a boolean-ish value as `on` / `off`."""
return "on" if bool(value) else "off"
def _accepted_state(value: Any) -> str:
"""Render ingress acceptance state."""
return "accepted" if bool(value) else "rejected"
def _authorized_state(value: Any) -> str:
"""Render ingress authorization state."""
if value is None:
return "unknown"
return "authorized" if bool(value) else "unauthorized"
def _timestamp_or_none(value: Any) -> Any:
"""Convert ISO timestamp string to datetime for HA rendering."""
if not isinstance(value, str):
return None
return dt_util.parse_datetime(value)
HEALTH_SENSOR_DESCRIPTIONS: tuple[HealthSensorEntityDescription, ...] = (
HealthSensorEntityDescription(
key="integration_health",
translation_key="integration_health",
icon="mdi:heart-pulse",
data_path=("integration_status",),
),
HealthSensorEntityDescription(
key="active_protocol",
translation_key="active_protocol",
icon="mdi:swap-horizontal",
data_path=("active_protocol",),
),
HealthSensorEntityDescription(
key="wslink_addon_status",
translation_key="wslink_addon_status",
icon="mdi:server-network",
data_path=("addon", "online"),
value_fn=lambda value: "online" if value else "offline",
),
HealthSensorEntityDescription(
key="wslink_addon_name",
translation_key="wslink_addon_name",
icon="mdi:package-variant-closed",
data_path=("addon", "name"),
),
HealthSensorEntityDescription(
key="wslink_addon_version",
translation_key="wslink_addon_version",
icon="mdi:label-outline",
data_path=("addon", "version"),
),
HealthSensorEntityDescription(
key="wslink_addon_listen_port",
translation_key="wslink_addon_listen_port",
icon="mdi:lan-connect",
data_path=("addon", "listen_port"),
),
HealthSensorEntityDescription(
key="wslink_upstream_ha_port",
translation_key="wslink_upstream_ha_port",
icon="mdi:transit-connection-variant",
data_path=("addon", "upstream_ha_port"),
),
HealthSensorEntityDescription(
key="route_wu_enabled",
translation_key="route_wu_enabled",
icon="mdi:transit-connection-horizontal",
data_path=("routes", "wu_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="route_wslink_enabled",
translation_key="route_wslink_enabled",
icon="mdi:transit-connection-horizontal",
data_path=("routes", "wslink_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="last_ingress_time",
translation_key="last_ingress_time",
icon="mdi:clock-outline",
device_class=SensorDeviceClass.TIMESTAMP,
data_path=("last_ingress", "time"),
value_fn=_timestamp_or_none,
),
HealthSensorEntityDescription(
key="last_ingress_protocol",
translation_key="last_ingress_protocol",
icon="mdi:download-network",
data_path=("last_ingress", "protocol"),
),
HealthSensorEntityDescription(
key="last_ingress_route_enabled",
translation_key="last_ingress_route_enabled",
icon="mdi:check-network",
data_path=("last_ingress", "route_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="last_ingress_accepted",
translation_key="last_ingress_accepted",
icon="mdi:check-decagram",
data_path=("last_ingress", "accepted"),
value_fn=_accepted_state,
),
HealthSensorEntityDescription(
key="last_ingress_authorized",
translation_key="last_ingress_authorized",
icon="mdi:key",
data_path=("last_ingress", "authorized"),
value_fn=_authorized_state,
),
HealthSensorEntityDescription(
key="last_ingress_reason",
translation_key="last_ingress_reason",
icon="mdi:message-alert-outline",
data_path=("last_ingress", "reason"),
),
HealthSensorEntityDescription(
key="forward_windy_enabled",
translation_key="forward_windy_enabled",
icon="mdi:weather-windy",
data_path=("forwarding", "windy", "enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="forward_windy_status",
translation_key="forward_windy_status",
icon="mdi:weather-windy",
data_path=("forwarding", "windy", "last_status"),
),
HealthSensorEntityDescription(
key="forward_pocasi_enabled",
translation_key="forward_pocasi_enabled",
icon="mdi:cloud-upload-outline",
data_path=("forwarding", "pocasi", "enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="forward_pocasi_status",
translation_key="forward_pocasi_status",
icon="mdi:cloud-upload-outline",
data_path=("forwarding", "pocasi", "last_status"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SWSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up health diagnostic sensors."""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
coordinator = entry.runtime_data.health_coordinator
entities = [
HealthDiagnosticSensor(coordinator=coordinator, description=description)
for description in HEALTH_SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, SensorEntity
):
"""Health diagnostic sensor for SWS-12500."""
entity_description: HealthSensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: HealthCoordinator,
description: HealthSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_unique_id = f"{description.key}_health"
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
@property
def native_value(self) -> Any: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the current diagnostic value."""
data = checked_or(self.coordinator.data, dict[str, Any], {})
value = _resolve_path(data, self.entity_description.data_path)
if self.entity_description.value_fn is not None:
return self.entity_description.value_fn(value)
return value
@property
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Expose the health JSON on the main health sensor for debugging.
Entity attributes are readable by any HA user, so internal network details
(source IP, internal URLs, raw add-on status) are stripped here; they remain
available via the authenticated endpoint and the admin-only diagnostics.
"""
if self.entity_description.key != "integration_health":
return None
data = checked_or(self.coordinator.data, dict[str, Any], None)
if data is None:
return None
return public_health_snapshot(data)
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -0,0 +1,14 @@
{
"entity": {
"sensor": {
"indoor_battery": {
"default": "mdi:battery-unknown",
"state": {
"low": "mdi:battery-low",
"normal": "mdi:battery",
"drained": "mdi:battery-alert"
}
}
}
}
}

View File

@ -0,0 +1,78 @@
"""Legacy battery sensor deprecation.
The integration used to expose battery state as regular SensorEntity instance
(unique_id == battery key), they have been migrated to BinarySensorEntity (unique_id == `key`_binary). Old entity-registry entries from
pre-migration installs orphan. This module raises a Repairs issue so user can clean them up.
"""
from __future__ import annotations
from typing import Final
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN
from .data import SWSConfigEntry
LEGACY_REMOVE_VERSION: Final = "2.1.0"
LEGACY_BATTERY_KEYS: Final[frozenset[str]] = frozenset(
{
"outside_battery",
"indoor_battery",
"ch2_battery",
"ch3_battery",
"ch4_battery",
"ch5_battery",
"ch6_battery",
"ch7_battery",
"ch8_battery",
}
)
def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id for this config entry."""
return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
@callback
def _orphan_legacy_battery_entries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]:
"""Return entity_ids of legacy battery sensors still present in entity registry.
Old non-binary battery entities have:
- domain == "sensor"
- unique_id matches a LEGACY_BATTERY_KEYS entry (without `_binary` suffix)
"""
ent_reg = er.async_get(hass)
return [
ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
if ent.domain == "sensor" and ent.unique_id in LEGACY_BATTERY_KEYS
]
@callback
def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for orphan legacy battery sensors."""
issue_id = _legacy_battery_issue_id(entry=entry)
orphans = _orphan_legacy_battery_entries(hass, entry)
if orphans:
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="legacy_battery_sensor_deprecated",
translation_placeholders={
"remove_version": LEGACY_REMOVE_VERSION,
"entities": ", ".join(orphans),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -9,11 +9,16 @@
"http"
],
"documentation": "https://github.com/schizza/SWS-12500-custom-component",
"homekit": {},
"integration_type": "device",
"iot_class": "local_push",
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
"requirements": [],
"ssdp": [],
"version": "1.8.6",
"zeroconf": []
"loggers": [
"aioecowitt"
],
"requirements": [
"typecheck-runtime==0.2.0",
"aioecowitt==2025.9.2"
],
"single_config_entry": true,
"version": "2.0.0-pre1"
}

View File

@ -1,14 +1,18 @@
"""Pocasi CZ resend functions."""
from datetime import datetime, timedelta
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any, Literal
from aiohttp import ClientError
from py_typecheck.core import checked_or
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_URL,
@ -16,6 +20,7 @@ from .const import (
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_MAX_RETRIES,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SUCCESS,
POCASI_CZ_UNEXPECTED,
@ -23,60 +28,88 @@ from .const import (
POCASI_INVALID_KEY,
WSLINK_URL,
)
from .utils import update_options
from .utils import anonymize, update_options
_LOGGER = logging.getLogger(__name__)
class PocasiNotInserted(Exception):
"""NotInserted state."""
class PocasiSuccess(Exception):
"""WindySucces state."""
class PocasiApiKeyError(Exception):
"""Windy API Key error."""
# Outcome of a single send, derived from the HTTP status (see `verify_response`).
type PocasiResult = Literal["ok", "auth_error", "unexpected_response"]
class PocasiPush:
"""Push data to Windy."""
"""Forward station payloads to the Pocasi Meteo server."""
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
"""Init."""
self.hass = hass
self.config = config
self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None
self.last_attempt_at: str | None = None
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
self.last_update = datetime.now()
self.next_update = datetime.now() + timedelta(seconds=self._interval)
self.last_update = dt_util.utcnow()
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
self.invalid_response_count = 0
def verify_response(
self,
response: str,
) -> PocasiNotInserted | PocasiSuccess | PocasiApiKeyError | None:
"""Verify answer form server."""
@property
def enabled(self) -> bool:
"""Whether forwarding is currently on, read live from the options.
Toggling this option does not reload the entry (see `update_listener`), so a
cached copy would leave the diagnostics sensor reporting a stale value until
the next push - or forever, since a disabled forwarder is never called again.
"""
return checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
def verify_response(self, status: int, body: str) -> PocasiResult:
"""Classify a send by its HTTP status.
The server returns no meaningful body, so the status code is all we have:
- 2xx -> "ok"
- 401 / 403 -> "auth_error" (bad ID/key combination)
- anything else -> "unexpected_response", counted toward the retry limit
"""
if self.log:
_LOGGER.debug("Pocasi CZ responded: %s", response)
_LOGGER.debug("Pocasi CZ responded: [%s] %s", status, body)
# Server does not provide any responses.
# This is placeholder if future state is changed
if 200 <= status < 300:
return "ok"
if status in (401, 403):
return "auth_error"
return "unexpected_response"
return None
async def _disable_pocasi(self, reason: str) -> None:
"""Turn resending off and persist it, so it survives a restart."""
async def push_data_to_server(
self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]
):
self.last_error = reason
if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False):
_LOGGER.debug("Failed to set Pocasi Meteo options to false.")
async def push_data_to_server(self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]):
"""Pushes weather data to server."""
_data = data.copy()
_api_id = self.config.options.get(POCASI_CZ_API_ID)
_api_key = self.config.options.get(POCASI_CZ_API_KEY)
self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None
# An empty string is still a `str`, so `checked` alone would let unconfigured
# credentials through and send a request that can only ever be rejected.
if not (_api_id := checked_or(self.config.options.get(POCASI_CZ_API_ID), str, "")):
_LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error"
self.last_error = "Missing API ID."
return
if not (_api_key := checked_or(self.config.options.get(POCASI_CZ_API_KEY), str, "")):
_LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error"
self.last_error = "Missing API key."
return
if self.log:
_LOGGER.info(
@ -85,13 +118,17 @@ class PocasiPush:
str(self.next_update),
)
if self.next_update > datetime.now():
if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local"
_LOGGER.debug(
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s",
"Triggered update interval limit of %s seconds. Next possible update is set to: %s",
self._interval,
self.next_update,
)
return False
return
# Reserve the next send window before the await to avoid concurrent double-sends.
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
request_url: str = ""
if mode == "WSLINK":
@ -104,40 +141,61 @@ class PocasiPush:
_data["PASSWORD"] = _api_key
request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}"
session = async_get_clientsession(self.hass, verify_ssl=False)
session = async_get_clientsession(self.hass)
_LOGGER.debug(
"Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] = %s",
mode,
request_url,
_data,
anonymize(_data),
)
try:
async with session.get(request_url, params=_data) as resp:
status = await resp.text()
try:
self.verify_response(status)
result = self.verify_response(resp.status, await resp.text())
http_status = resp.status
except PocasiApiKeyError:
# log despite of settings
_LOGGER.critical(POCASI_INVALID_KEY)
await update_options(
self.hass, self.config, POCASI_CZ_ENABLED, False
)
except PocasiSuccess:
if self.log:
_LOGGER.info(POCASI_CZ_SUCCESS)
if result == "ok":
self.invalid_response_count = 0
self.last_status = "ok"
self.last_error = None
if self.log:
_LOGGER.info(POCASI_CZ_SUCCESS)
except ClientError as ex:
elif result == "auth_error":
# Credentials will not fix themselves - disable immediately instead of
# burning through the retry budget. Logged regardless of the log setting.
self.last_status = "auth_error"
_LOGGER.critical(POCASI_INVALID_KEY)
await self._disable_pocasi(POCASI_INVALID_KEY)
else:
self.last_status = "unexpected_response"
self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo."
self.invalid_response_count += 1
_LOGGER.warning(
"Unexpected HTTP status %s from Pocasi Meteo. Retries before disabling resend: %s",
http_status,
POCASI_CZ_MAX_RETRIES - self.invalid_response_count,
)
if self.invalid_response_count >= POCASI_CZ_MAX_RETRIES:
_LOGGER.critical(POCASI_CZ_UNEXPECTED)
await self._disable_pocasi(POCASI_CZ_UNEXPECTED)
# TimeoutError is not a ClientError: an `async_timeout`/`asyncio` timeout would
# otherwise escape into the webhook handler and answer the station with HTTP 500,
# even though the measured data was already stored.
except (ClientError, TimeoutError) as ex:
self.last_status = "client_error"
# Store only the exception class - last_error is surfaced via entity
# attributes; str(ex) could embed the request URL.
self.last_error = type(ex).__name__
_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 >= POCASI_CZ_MAX_RETRIES:
_LOGGER.critical(POCASI_CZ_UNEXPECTED)
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
await self._disable_pocasi(POCASI_CZ_UNEXPECTED)
self.last_update = datetime.now()
self.next_update = datetime.now() + timedelta(seconds=self._interval)
self.last_update = dt_util.utcnow()
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
if self.log:
_LOGGER.info("Next update: %s", str(self.next_update))
return None

View File

@ -1,79 +1,233 @@
"""Store routes info."""
"""Routes implementation.
from collections.abc import Callable
from dataclasses import dataclass
from logging import getLogger
Why this dispatcher exists
--------------------------
Home Assistant registers aiohttp routes on startup. Re-registering or removing routes at runtime
is awkward and error-prone (and can raise if routes already exist). This integration supports two
different push endpoints (legacy WU-style vs WSLink). To allow switching between them without
touching the aiohttp router, we register both routes once and use this in-process dispatcher to
decide which one is currently enabled.
from aiohttp.web import AbstractRoute, Response
Important note:
- Each route stores a *bound method* handler (e.g. `coordinator.received_data`). That means the
route points to a specific coordinator instance. When the integration reloads, we must keep the
same coordinator instance or update the stored handler accordingly. Otherwise requests may go to
an old coordinator while entities listen to a new one (result: UI appears "frozen").
"""
_LOGGER = getLogger(__name__)
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import logging
from typing import Any
from aiohttp.web import AbstractRoute, Request, Response
_LOGGER = logging.getLogger(__name__)
Handler = Callable[[Request], Awaitable[Response]]
IngressObserver = Callable[[Request, bool, str | None], None]
@dataclass
class Route:
"""Store route info."""
class RouteInfo:
"""Route definition held by the dispatcher.
- `handler` is the real webhook handler (bound method).
- `fallback` is used when the route exists but is currently disabled.
"""
url_path: str
route: AbstractRoute
handler: Callable
handler: Handler
enabled: bool = False
sticky: bool = False
fallback: Handler = field(default_factory=lambda: unregistered)
def __str__(self):
"""Return string representation."""
return f"{self.url_path} -> {self.handler}"
return f"RouteInfo(url_path={self.url_path}, route={self.route}, handler={self.handler}, enabled={self.enabled}, fallback={self.fallback})"
class Routes:
"""Store routes info."""
"""Simple route dispatcher.
We register aiohttp routes once and direct traffic to the currently enabled endpoint
using `switch_route`. This keeps route registration stable while still allowing the
integration to support multiple incoming push formats.
"""
def __init__(self) -> None:
"""Initialize routes."""
self.routes = {}
"""Initialize dispatcher storage."""
self.routes: dict[str, RouteInfo] = {}
self._ingress_observer: IngressObserver | None = None
def switch_route(self, coordinator: Callable, url_path: str):
"""Switch route."""
def _resolve_route(self, request: Request) -> RouteInfo | None:
"""Find the matching RouteInfo for a request.
Two step lookup:
1) Find exact match using method:path (for fix routes)
2) Fallback to aiohttp resource canonical URL
works for routes with path parameter - as {webhook_id}
"""
key = f"{request.method}:{request.path}"
if key in self.routes:
return self.routes[key]
# 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:
return self.routes[canonical_key]
return None
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
"""Enable or disable the Ecowitt sticky route.
switch_route() does not involve sticky routes, so we need another
method for Ecowitt state at reload.
"""
for route in self.routes.values():
if route.url_path == url_path:
_LOGGER.info("New coordinator to route: %s", route.url_path)
if route.url_path == url_path and route.sticky:
route.enabled = enabled
route.handler = handler if enabled else unregistered
_LOGGER.info(
"Ecowitt route %s %s",
route.url_path,
"enabled" if enabled else "disabled",
)
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
async def dispatch(self, request: Request) -> Response:
"""Dispatch incoming request to either the enabled handler or a fallback."""
info = self._resolve_route(request)
if not info:
_LOGGER.debug("Route (%s):%s is not registered!", request.method, request.path)
if self._ingress_observer is not None:
self._ingress_observer(request, False, "route_not_registered")
return await unregistered(request)
if self._ingress_observer is not None:
self._ingress_observer(
request,
info.enabled,
None if info.enabled else "route_disabled",
)
handler = info.handler if info.enabled else info.fallback
return await handler(request)
def switch_route(self, handler: Handler, url_path: str | None, *, enabled: bool = True) -> None:
"""Enable routes based on URL, disable all others. Leave sticky routes enabled.
When `enabled` is False (or url_path is None), all non-sticky (legacy) routes are disabled.
- used when only Ecowitt is active.
Sticky routes (health, ecowitt) are left untouched.
The aiohttp router stays untouched; we only flip which internal handler is active.
"""
for route in self.routes.values():
if route.sticky:
continue
if enabled and route.url_path == url_path:
_LOGGER.info(
"New coordinator to route: (%s):%s",
route.route.method,
route.url_path,
)
route.enabled = True
route.handler = coordinator
route.route._handler = coordinator # noqa: SLF001
route.handler = handler
else:
route.enabled = False
route.handler = unregistred
route.route._handler = unregistred # noqa: SLF001
route.handler = unregistered
def add_route(
self,
url_path: str,
route: AbstractRoute,
handler: Callable,
handler: Handler,
*,
enabled: bool = False,
):
"""Add route."""
sticky: bool = False,
) -> None:
"""Register a route in the dispatcher.
This does not register anything in aiohttp. It only stores routing metadata that
`dispatch` uses after aiohttp has routed the request by path.
"""
key = f"{route.method}:{url_path}"
self.routes[key] = Route(url_path, route, handler, enabled)
self.routes[key] = RouteInfo(url_path, route=route, handler=handler, enabled=enabled, sticky=sticky)
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
def get_route(self, url_path: str) -> Route | None:
"""Get route."""
for route in self.routes.values():
if route.url_path == url_path:
return route
return None
def show_enabled(self) -> str:
"""Return a human-readable description of the currently enabled route."""
def get_enabled(self) -> str:
"""Get enabled routes."""
enabled_routes = {route.url_path for route in self.routes.values() if route.enabled}
return ", ".join(sorted(enabled_routes)) if enabled_routes else "None"
enabled_routes = {
f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
for route in self.routes.values()
if route.enabled
}
if not enabled_routes:
return "No routes are enabled."
return ", ".join(sorted(enabled_routes))
def __str__(self):
"""Return string representation."""
return "\n".join([str(route) for route in self.routes.values()])
def path_enabled(self, url_path: str) -> bool:
"""Return whether any route registered for `url_path` is enabled."""
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
def snapshot(self) -> dict[str, Any]:
"""Return a compact routing snapshot for diagnostics."""
return {
key: {
"path": route.url_path,
"method": route.route.method,
"enabled": route.enabled,
"sticky": route.sticky,
}
for key, route in self.routes.items()
}
async def unregistred(*args, **kwargs):
"""Unregister path to handle incoming data."""
async def unregistered(request: Request) -> Response:
"""Fallback response for unknown/disabled routes.
_LOGGER.error("Recieved data to unregistred webhook. Check your settings")
return Response(body=f"{'Unregistred webhook.'}", status=404)
This should normally never happen for correctly configured stations, but it provides
a clear error message when the station pushes to the wrong endpoint.
"""
_ = request
_LOGGER.debug("Received data to unregistered or disabled webhook.")
return Response(text="Unregistered webhook. Check your settings.", status=400)

View File

@ -1,20 +1,37 @@
"""Sensors definition for SWS12500."""
"""Sensor platform for SWS12500.
This module creates sensor entities based on the config entry options.
The integration is push-based (webhook), so we avoid reloading the entry for
auto-discovered sensors. Instead, we dynamically add new entities at runtime
using the `async_add_entities` callback stored in `entry.runtime_data`.
Why not reload on auto-discovery?
Reloading a config entry unloads platforms temporarily, which removes coordinator
listeners. With frequent webhook pushes, this can create a window where nothing is
subscribed and the frontend appears "frozen" until another full reload/restart.
Per-entry runtime state lives on `entry.runtime_data` (see data.SWSRuntimeData)
"""
from __future__ import annotations
from functools import cached_property
import logging
from typing import TYPE_CHECKING, Any
from py_typecheck import checked_or
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo, generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import WeatherDataUpdateCoordinator
from . import health_sensor
from .const import (
BATTERY_LIST,
CHILL_INDEX,
DOMAIN,
DEV_DBG,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
@ -23,139 +40,188 @@ from .const import (
WIND_DIR,
WIND_SPEED,
WSLINK,
UnitOfBat,
)
from .data import SWSConfigEntry, build_device_info
from .sensors_common import WeatherSensorEntityDescription
from .sensors_weather import SENSOR_TYPES_WEATHER_API
from .sensors_wslink import SENSOR_TYPES_WSLINK
from .utils import battery_level_to_icon, battery_level_to_text, chill_index, heat_index
if TYPE_CHECKING:
from .coordinator import WeatherDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
def _auto_enable_derived_sensors(requested: set[str]) -> set[str]:
"""Auto-enable derived sensors when their source fields are present.
This does NOT model strict dependencies ("if you want X, we force-add inputs").
Instead, it opportunistically enables derived outputs when the station already
provides the raw fields needed to compute them.
"""
expanded = set(requested)
# Wind azimut depends on wind dir
if WIND_DIR in expanded:
expanded.add(WIND_AZIMUT)
# Heat index depends on temp + humidity
if OUTSIDE_TEMP in expanded and OUTSIDE_HUMIDITY in expanded:
expanded.add(HEAT_INDEX)
# Chill index depends on temp + wind speed
if OUTSIDE_TEMP in expanded and WIND_SPEED in expanded:
expanded.add(CHILL_INDEX)
return expanded
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SWSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Weather Station sensors."""
"""Set up Weather Station sensors.
coordinator: WeatherDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
Stores `async_add_entities` and the sensor-description map on `entry.runtime_data`
so the webhook handler can add newly discovered entities dynamically without reloading the config entry.
"""
sensors_to_load: list = []
sensors: list = []
_wslink = config_entry.options.get(WSLINK)
runtime = config_entry.runtime_data
coordinator = runtime.coordinator
SENSOR_TYPES = SENSOR_TYPES_WSLINK if _wslink else SENSOR_TYPES_WEATHER_API
# Wire up integration health diagnostic sensor.
await health_sensor.async_setup_entry(hass, config_entry, async_add_entities)
# Check if we have some sensors to load.
if sensors_to_load := config_entry.options.get(SENSORS_TO_LOAD, []):
if WIND_DIR in sensors_to_load:
sensors_to_load.append(WIND_AZIMUT)
if (OUTSIDE_HUMIDITY in sensors_to_load) and (OUTSIDE_TEMP in sensors_to_load):
sensors_to_load.append(HEAT_INDEX)
wslink_enabled = checked_or(config_entry.options.get(WSLINK), bool, False)
sensor_types = SENSOR_TYPES_WSLINK if wslink_enabled else SENSOR_TYPES_WEATHER_API
if (WIND_SPEED in sensors_to_load) and (OUTSIDE_TEMP in sensors_to_load):
sensors_to_load.append(CHILL_INDEX)
sensors = [
WeatherSensor(hass, description, coordinator)
for description in SENSOR_TYPES
if description.key in sensors_to_load
]
async_add_entities(sensors)
# Persist platform callback + description map for dynamic entity creation.
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
requested = _auto_enable_derived_sensors(set(sensors_to_load))
entities: list[WeatherSensor] = [
WeatherSensor(description, coordinator) for description in sensor_types if description.key in requested
]
async_add_entities(entities)
def add_new_sensors(hass: HomeAssistant, config_entry: SWSConfigEntry, keys: list[str]) -> None:
"""Dynamically add newly discovered sensors without reloading the entry.
Called by the webhook handler when the station starts sending new fields.
Design notes:
- This function is intentionally a safe no-op if the sensor platform hasn't
finished setting up yet (e.g. callback/description map missing).
- Unknown payload keys are ignored (only keys with an entity description are added).
"""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = getattr(config_entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_sensor_entities
if add_entities is None:
return
descriptions = runtime.sensor_descriptions
coordinator = runtime.coordinator
new_entities: list[SensorEntity] = [
WeatherSensor(desc, coordinator) for key in keys if (desc := descriptions.get(key)) is not None
]
if new_entities:
add_entities(new_entities)
class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity[WeatherDataUpdateCoordinator], SensorEntity
CoordinatorEntity, SensorEntity
): # pyright: ignore[reportIncompatibleVariableOverride]
"""Implementation of Weather Sensor entity."""
"""Implementation of Weather Sensor entity.
We intentionally keep the coordinator type unparameterized here to avoid
propagating HA's generic `DataUpdateCoordinator[T]` typing into this module.
"""
entity_description: WeatherSensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride]
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
description: WeatherSensorEntityDescription,
coordinator: WeatherDataUpdateCoordinator,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator)
self.hass = hass
self.coordinator = coordinator
self.entity_description = description
self._attr_unique_id = description.key
self._data = None
async def async_added_to_hass(self) -> None:
"""Handle listeners to reloaded sensors."""
await super().async_added_to_hass()
self.coordinator.async_add_listener(self._handle_coordinator_update)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._data = self.coordinator.data.get(self.entity_description.key)
super()._handle_coordinator_update()
self.async_write_ha_state()
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
self._dev_log = checked_or(coordinator.config.options.get(DEV_DBG), bool, False)
@property
def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride]
"""Return value of entity."""
"""Return the current sensor state.
_wslink = self.coordinator.config.options.get(WSLINK)
Resolution order:
1) If `value_from_data_fn` is provided, it receives the full payload dict and can compute
derived values (e.g. battery enum mapping, azimut text, heat/chill indices).
2) Otherwise we read the raw value for this key from the payload and pass it through `value_fn`.
if self.coordinator.data and (WIND_AZIMUT in self.entity_description.key):
return self.entity_description.value_fn(self.coordinator.data.get(WIND_DIR)) # pyright: ignore[ reportAttributeAccessIssue]
Payload normalization:
- The station sometimes sends empty strings for missing fields; we treat "" as no value (None).
"""
data: dict[str, Any] = checked_or(self.coordinator.data, dict[str, Any], {})
key = self.entity_description.key
if (
self.coordinator.data
and (HEAT_INDEX in self.entity_description.key)
and not _wslink
):
return self.entity_description.value_fn(heat_index(self.coordinator.data)) # pyright: ignore[ reportAttributeAccessIssue]
if self.entity_description.value_from_data_fn is not None:
try:
value = self.entity_description.value_from_data_fn(data)
except Exception: # noqa: BLE001
_LOGGER.exception("native_value compute failed via value_from_data_fn for key=%s", key)
return None
if (
self.coordinator.data
and (CHILL_INDEX in self.entity_description.key)
and not _wslink
):
return self.entity_description.value_fn(chill_index(self.coordinator.data)) # pyright: ignore[ reportAttributeAccessIssue]
return value
return (
None if self._data == "" else self.entity_description.value_fn(self._data) # pyright: ignore[ reportAttributeAccessIssue]
)
raw = data.get(key)
if raw is None or raw == "":
if self._dev_log:
_LOGGER.debug("native_value missing raw: key=%s raw=%s", key, raw)
return None
if self.entity_description.value_fn is None:
if self._dev_log:
_LOGGER.debug("native_value has no value_fn: key=%s raw=%s", key, raw)
return None
try:
value = self.entity_description.value_fn(raw)
except Exception: # noqa: BLE001
_LOGGER.exception("native_value compute failed via value_fn for key=%s raw=%s", key, raw)
return None
return value
@property
def suggested_entity_id(self) -> str:
"""Return name."""
return generate_entity_id("sensor.{}", self.entity_description.key)
@property
def icon(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the dynamic icon for battery representation."""
if self.entity_description.key in BATTERY_LIST:
if self.native_value:
battery_level = battery_level_to_text(self.native_value)
return battery_level_to_icon(battery_level)
return battery_level_to_icon(UnitOfBat.UNKNOWN)
return self.entity_description.icon
@property
def device_info(self) -> DeviceInfo: # pyright: ignore[reportIncompatibleVariableOverride]
"""Device info."""
return DeviceInfo(
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -1,14 +1,23 @@
"""Common classes for sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntityDescription
from .const import VOCLevel
@dataclass(frozen=True, kw_only=True)
class WeatherSensorEntityDescription(SensorEntityDescription):
"""Describe Weather Sensor entities."""
value_fn: Callable[[Any], int | float | str | None]
value_fn: Callable[[Any], int | float | str | VOCLevel | None] | None = None
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | VOCLevel | None] | None = None
deprecated: bool = False
replacement_entity_domain: str | None = None
replacement_entity_key: str | None = None

View File

@ -1,6 +1,6 @@
"""Sensor entities for the SWS12500 integration for old endpoint."""
from typing import cast
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
@ -41,7 +41,7 @@ from .const import (
UnitOfDir,
)
from .sensors_common import WeatherSensorEntityDescription
from .utils import wind_dir_to_text
from .utils import chill_index, heat_index, to_float, to_int, wind_dir_to_text
SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription(
@ -51,7 +51,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=INDOOR_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=INDOOR_HUMIDITY,
@ -60,7 +60,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=INDOOR_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=OUTSIDE_TEMP,
@ -69,7 +69,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=OUTSIDE_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=OUTSIDE_HUMIDITY,
@ -78,7 +78,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=OUTSIDE_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=DEW_POINT,
@ -87,7 +87,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer-lines",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=DEW_POINT,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=BARO_PRESSURE,
@ -97,7 +97,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
suggested_unit_of_measurement=UnitOfPressure.HPA,
translation_key=BARO_PRESSURE,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_SPEED,
@ -107,7 +107,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:weather-windy",
translation_key=WIND_SPEED,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_GUST,
@ -117,7 +117,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:windsock",
translation_key=WIND_GUST,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_DIR,
@ -127,14 +127,14 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=None,
icon="mdi:sign-direction",
translation_key=WIND_DIR,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=WIND_AZIMUT,
icon="mdi:sign-direction",
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
device_class=SensorDeviceClass.ENUM,
options=list(UnitOfDir),
options=[e.value for e in UnitOfDir],
translation_key=WIND_AZIMUT,
),
WeatherSensorEntityDescription(
@ -146,7 +146,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=DAILY_RAIN,
@ -157,7 +157,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=DAILY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=SOLAR_RADIATION,
@ -166,7 +166,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.IRRADIANCE,
icon="mdi:weather-sunny",
translation_key=SOLAR_RADIATION,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=UV,
@ -175,7 +175,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
native_unit_of_measurement=UV_INDEX,
icon="mdi:sunglasses",
translation_key=UV,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_TEMP,
@ -185,7 +185,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH2_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_HUMIDITY,
@ -194,7 +194,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH2_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_TEMP,
@ -204,7 +204,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH3_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH3_HUMIDITY,
@ -213,7 +213,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH3_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH4_TEMP,
@ -223,7 +223,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH4_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH4_HUMIDITY,
@ -232,7 +232,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH4_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=HEAT_INDEX,
@ -243,7 +243,8 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=HEAT_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
value_from_data_fn=heat_index,
),
WeatherSensorEntityDescription(
key=CHILL_INDEX,
@ -254,6 +255,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=CHILL_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
value_from_data_fn=chill_index,
),
)

View File

@ -1,6 +1,6 @@
"""Sensor entities for the SWS12500 integration for old endpoint."""
from typing import cast
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
@ -16,13 +16,30 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from .battery_sensors_def import BATTERY_LEVEL_SENSORS
from .const import (
BARO_PRESSURE,
CH2_BATTERY,
CH2_HUMIDITY,
CH2_TEMP,
CH3_BATTERY,
CH3_HUMIDITY,
CH3_TEMP,
CH4_BATTERY,
CH4_HUMIDITY,
CH4_TEMP,
CH5_BATTERY,
CH5_HUMIDITY,
CH5_TEMP,
CH6_BATTERY,
CH6_HUMIDITY,
CH6_TEMP,
CH7_BATTERY,
CH7_HUMIDITY,
CH7_TEMP,
CH8_BATTERY,
CH8_HUMIDITY,
CH8_TEMP,
CHILL_INDEX,
DAILY_RAIN,
DEW_POINT,
@ -38,7 +55,6 @@ from .const import (
OUTSIDE_TEMP,
RAIN,
SOLAR_RADIATION,
T9_BATTERY,
UV,
VOC,
WBGT_TEMP,
@ -48,11 +64,20 @@ from .const import (
WIND_GUST,
WIND_SPEED,
YEARLY_RAIN,
UnitOfBat,
UnitOfDir,
VOCLevel,
)
from .sensors_common import WeatherSensorEntityDescription
from .utils import battery_5step_to_pct, voc_level_to_text, wind_dir_to_text
from .utils import (
battery_level,
to_float,
to_int,
voc_level_to_text,
wind_dir_to_text,
wslink_chill_index,
wslink_heat_index,
)
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription(
@ -62,7 +87,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=INDOOR_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=INDOOR_HUMIDITY,
@ -71,7 +96,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=INDOOR_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=OUTSIDE_TEMP,
@ -80,7 +105,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=OUTSIDE_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=OUTSIDE_HUMIDITY,
@ -89,7 +114,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=OUTSIDE_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=DEW_POINT,
@ -98,7 +123,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer-lines",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=DEW_POINT,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=BARO_PRESSURE,
@ -108,7 +133,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
suggested_unit_of_measurement=UnitOfPressure.HPA,
translation_key=BARO_PRESSURE,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_SPEED,
@ -118,7 +143,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:weather-windy",
translation_key=WIND_SPEED,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_GUST,
@ -128,7 +153,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:windsock",
translation_key=WIND_GUST,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_DIR,
@ -138,14 +163,14 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=None,
icon="mdi:sign-direction",
translation_key=WIND_DIR,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=WIND_AZIMUT,
icon="mdi:sign-direction",
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
device_class=SensorDeviceClass.ENUM,
options=list(UnitOfDir),
options=[e.value for e in UnitOfDir],
translation_key=WIND_AZIMUT,
),
WeatherSensorEntityDescription(
@ -157,7 +182,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=DAILY_RAIN,
@ -168,7 +193,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=DAILY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=HOURLY_RAIN,
@ -179,7 +204,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=HOURLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WEEKLY_RAIN,
@ -190,7 +215,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=WEEKLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=MONTHLY_RAIN,
@ -201,7 +226,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=MONTHLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=YEARLY_RAIN,
@ -212,7 +237,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=YEARLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=SOLAR_RADIATION,
@ -221,7 +246,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.IRRADIANCE,
icon="mdi:weather-sunny",
translation_key=SOLAR_RADIATION,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=UV,
@ -230,7 +255,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
native_unit_of_measurement=UV_INDEX,
icon="mdi:sunglasses",
translation_key=UV,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_TEMP,
@ -240,7 +265,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH2_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_HUMIDITY,
@ -249,7 +274,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH2_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_TEMP,
@ -259,7 +284,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH3_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH3_HUMIDITY,
@ -268,27 +293,181 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH3_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
# WeatherSensorEntityDescription(
# key=CH4_TEMP,
# native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
# state_class=SensorStateClass.MEASUREMENT,
# device_class=SensorDeviceClass.TEMPERATURE,
# suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
# icon="mdi:weather-sunny",
# translation_key=CH4_TEMP,
# value_fn=lambda data: cast(float, data),
# ),
# WeatherSensorEntityDescription(
# key=CH4_HUMIDITY,
# native_unit_of_measurement=PERCENTAGE,
# state_class=SensorStateClass.MEASUREMENT,
# device_class=SensorDeviceClass.HUMIDITY,
# icon="mdi:weather-sunny",
# translation_key=CH4_HUMIDITY,
# value_fn=lambda data: cast(int, data),
# ),
WeatherSensorEntityDescription(
key=CH4_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH4_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH4_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH4_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH5_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH5_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH5_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH5_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH6_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH6_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH6_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH6_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH7_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH7_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH7_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH7_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH8_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH8_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH8_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH8_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_BATTERY,
translation_key=CH3_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH3_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH3_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH4_BATTERY,
translation_key=CH4_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH4_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH4_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH5_BATTERY,
translation_key=CH5_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH5_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH5_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH6_BATTERY,
translation_key=CH6_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH6_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH6_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH7_BATTERY,
translation_key=CH7_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH7_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH7_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH8_BATTERY,
translation_key=CH8_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH8_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH8_BATTERY,
entity_registry_enabled_default=False,
),
# `value_from_data_fn` takes precedence in `WeatherSensor.native_value`, so these
# use the station's own t1heat/t1chill when present and compute them otherwise -
# stations that do not report them used to leave both entities Unavailable forever.
WeatherSensorEntityDescription(
key=HEAT_INDEX,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
@ -298,7 +477,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=HEAT_INDEX,
value_fn=lambda data: cast("int", data),
value_from_data_fn=wslink_heat_index,
),
WeatherSensorEntityDescription(
key=CHILL_INDEX,
@ -309,28 +488,43 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=CHILL_INDEX,
value_fn=lambda data: cast("int", data),
value_from_data_fn=wslink_chill_index,
),
WeatherSensorEntityDescription(
key=OUTSIDE_BATTERY,
translation_key=OUTSIDE_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
options=[e.value for e in UnitOfBat],
value_fn=None,
value_from_data_fn=lambda data: battery_level(data.get(OUTSIDE_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=OUTSIDE_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH2_BATTERY,
translation_key=CH2_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
options=[e.value for e in UnitOfBat],
value_fn=None,
value_from_data_fn=lambda data: battery_level(data.get(CH2_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH2_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=INDOOR_BATTERY,
translation_key=INDOOR_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(INDOOR_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=INDOOR_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=WBGT_TEMP,
@ -340,7 +534,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=HCHO,
@ -349,7 +543,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:molecule",
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=VOC,
@ -357,15 +551,9 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=list(VOCLevel),
icon="mdi:air-filter",
value_fn=lambda data: cast("str", voc_level_to_text(data)),
),
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,
value_fn=voc_level_to_text,
),
# 0-5 level batteries are generated from BATTERY_NON_BINARY so the classification
# lives in exactly one place (see battery_sensors_def).
*BATTERY_LEVEL_SENSORS,
)

View File

@ -0,0 +1,76 @@
"""Stale sensor detection.
If a sensor key is in SENSORS_TO_LOAD but its station never reported it (or stopped reporting it
for an extended period), the corresponding HA entity becomes Unavailable indefinitely.
This module raises a Repairs issue listing such stale keys so users can decide whether to remove them.
Warmup avoids false-positives at integration startup before any payload arrives.
The check is in-memory only restart/reload resets last_seen, but the 1h warmup covers re-population
from incoming webhooks.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.util import dt as dt_util
from .const import DOMAIN, SENSORS_TO_LOAD
from .data import SWSConfigEntry
STALE_THRESHOLD: Final = timedelta(hours=24)
WARMUP_PERIOD: Final = timedelta(hours=1)
def _stale_sensor_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id for this config entry."""
return f"stale_sensors_{entry.entry_id}"
@callback
def _find_stale_keys(entry: SWSConfigEntry) -> list[str]:
"""Return keys in SENSORS_TO_LOAD that haven't been seen recently."""
runtime = entry.runtime_data
now = dt_util.utcnow()
if (now - runtime.started_at) < WARMUP_PERIOD:
return []
loaded = entry.options.get(SENSORS_TO_LOAD, [])
last_seen = runtime.last_seen
stale: list[str] = []
for key in loaded:
seen_at = last_seen.get(key)
if seen_at is None or (now - seen_at) > STALE_THRESHOLD:
stale.append(key)
return stale
@callback
def update_stale_sensors_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for stale sensor keys."""
issue_id = _stale_sensor_issue_id(entry)
stale = _find_stale_keys(entry)
if stale:
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="stale_sensors_detected",
translation_placeholders={
"sensors": ", ".join(sorted(stale)),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -3,23 +3,44 @@
"error": {
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same."
"valid_credentials_match": "API ID and API KEY should not be the same.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
"user": {
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
"title": "Configure access for Weather Station",
"title": "Choose your station type",
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "PWS/WSLink credentials.",
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink Protocol",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
"API_ID": "API ID is the Station ID you set in the Weather Station.",
"API_KEY": "API KEY is the password you set in the Weather Station.",
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
"wslink": "Enable WSLink Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
}
},
"ecowitt": {
"title": "Ecowitt configuration.",
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
}
}
}
@ -29,7 +50,13 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_key_required": "Windy API key is required if you want to enable this function."
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",
"pocasi_id_required": "Pocasi Meteo API ID is required if you want to enable this function.",
"pocasi_key_required": "Pocasi Meteo API key is required if you want to enable this function.",
"pocasi_send_minimum": "The resend interval must be at least 12 seconds.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
"init": {
@ -37,7 +64,10 @@
"description": "Choose what do you want to configure. If basic access or resending data for Windy site",
"menu_options": {
"basic": "Basic - configure credentials for Weather Station",
"windy": "Windy configuration"
"wslink_port_setup": "WSLink add-on port",
"ecowitt": "Ecowitt configuration",
"windy": "Windy configuration",
"pocasi": "Pocasi Meteo CZ configuration"
}
},
"basic": {
@ -46,26 +76,30 @@
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink API",
"legacy_enabled": "Enable PWS/WSLink endpoint",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
"API_ID": "API ID is the Station ID you set in the Weather Station.",
"API_KEY": "API KEY is the password you set in the Weather Station.",
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
"wslink": "Enable WSLink API if the station is set to send data via WSLink.",
"legacy_enabled": "Enable the PWS/WSLink endpoint. Turn off for an Ecowitt-only setup."
}
},
"windy": {
"description": "Resend weather data to your Windy stations.",
"title": "Configure Windy",
"data": {
"WINDY_API_KEY": "API KEY provided by Windy",
"WINDY_STATION_ID": "Station ID obtained form Windy",
"WINDY_STATION_PWD": "Station password obtained from Windy",
"windy_enabled_checkbox": "Enable resending data to Windy",
"windy_logger_checkbox": "Log Windy data and responses"
},
"data_description": {
"WINDY_API_KEY": "Windy API KEY obtained from https://https://api.windy.com/keys",
"WINDY_STATION_ID": "Windy station ID obtained from https://stations.windy.com/stations",
"WINDY_STATION_PWD": "Windy station password obtained from https://stations.windy.com/stations",
"windy_logger_checkbox": "Enable only if you want to send debuging data to the developer."
}
},
@ -75,34 +109,216 @@
"data": {
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds",
"POCASI_SEND_INTERVAL": "Resend interval in seconds",
"pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo",
"pocasi_logger_checkbox": "Log data and responses"
},
"data_description": {
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App",
"POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
"pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo",
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
}
},
"migration": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
"ecowitt": {
"description": "Ecowitt configuration.",
"title": "Ecowitt station configuration",
"data": {
"sensor_to_migrate": "Sensor to migrate",
"trigger_action": "Trigger migration"
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
"trigger_action": "Trigger the sensor statistics migration after checking."
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
}
},
"wslink_port_setup": {
"title": "WSLink add-on port",
"description": "Set the external port of the WSLink proxy add-on so the integration can reach its health endpoint.",
"data": {
"WSLINK_ADDON_PORT": "WSLink add-on port"
},
"data_description": {
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
}
}
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Outside battery"
},
"indoor_battery": {
"name": "Console battery"
},
"ch2_battery": {
"name": "Channel 2 battery"
},
"ch3_battery": {
"name": "Channel 3 battery"
},
"ch4_battery": {
"name": "Channel 4 battery"
},
"ch5_battery": {
"name": "Channel 5 battery"
},
"ch6_battery": {
"name": "Channel 6 battery"
},
"ch7_battery": {
"name": "Channel 7 battery"
},
"ch8_battery": {
"name": "Channel 8 battery"
}
},
"sensor": {
"ecowitt_absolute_pressure": {
"name": "Absolute pressure"
},
"ecowitt_rain_rate": {
"name": "Rain rate"
},
"ecowitt_event_rain": {
"name": "Event rain"
},
"ecowitt_hourly_rain": {
"name": "Hourly rain"
},
"ecowitt_weekly_rain": {
"name": "Weekly rain"
},
"ecowitt_monthly_rain": {
"name": "Monthly rain"
},
"ecowitt_yearly_rain": {
"name": "Yearly rain"
},
"ecowitt_total_rain": {
"name": "Total rain"
},
"ecowitt_24h_rain": {
"name": "24h rain"
},
"ecowitt_feels_like": {
"name": "Feels like"
},
"ecowitt_indoor_dewpoint": {
"name": "Indoor dew point"
},
"ecowitt_console_co2": {
"name": "Console CO₂"
},
"ecowitt_console_co2_24h": {
"name": "Console CO₂ (24h avg)"
},
"ecowitt_co2": {
"name": "CO₂"
},
"ecowitt_co2_24h": {
"name": "CO₂ (24h avg)"
},
"integration_health": {
"name": "Integration status",
"state": {
"online_wu": "Online PWS/WU",
"online_wslink": "Online WSLink",
"online_ecowitt": "Online Ecowitt",
"online_idle": "Waiting for data",
"degraded": "Degraded",
"error": "Error"
}
},
"active_protocol": {
"name": "Active protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "WSLink Addon Status",
"state": {
"online": "Running",
"offline": "Offline"
}
},
"wslink_addon_name": {
"name": "WSLink Addon Name"
},
"wslink_addon_version": {
"name": "WSLink Addon Version"
},
"wslink_addon_listen_port": {
"name": "WSLink Addon Listen Port"
},
"wslink_upstream_ha_port": {
"name": "WSLink Addon Upstream HA Port"
},
"route_wu_enabled": {
"name": "PWS/WU Protocol"
},
"route_wslink_enabled": {
"name": "WSLink Protocol"
},
"last_ingress_time": {
"name": "Last access time"
},
"last_ingress_protocol": {
"name": "Last access protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Last ingress route enabled"
},
"last_ingress_accepted": {
"name": "Last access",
"state": {
"accepted": "Accepted",
"rejected": "Rejected"
}
},
"last_ingress_authorized": {
"name": "Last access authorization",
"state": {
"authorized": "Authorized",
"unauthorized": "Unauthorized",
"unknown": "Unknown"
}
},
"last_ingress_reason": {
"name": "Last access reason"
},
"forward_windy_enabled": {
"name": "Forwarding to Windy"
},
"forward_windy_status": {
"name": "Forwarding status to Windy",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Forwarding to Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Forwarding status to Počasí Meteo",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Indoor temperature"
},
@ -160,12 +376,67 @@
"ch4_humidity": {
"name": "Channel 4 humidity"
},
"ch5_temp": {
"name": "Channel 5 temperature"
},
"ch5_humidity": {
"name": "Channel 5 humidity"
},
"ch6_temp": {
"name": "Channel 6 temperature"
},
"ch6_humidity": {
"name": "Channel 6 humidity"
},
"ch7_temp": {
"name": "Channel 7 temperature"
},
"ch7_humidity": {
"name": "Channel 7 humidity"
},
"ch8_temp": {
"name": "Channel 8 temperature"
},
"ch8_humidity": {
"name": "Channel 8 humidity"
},
"heat_index": {
"name": "Apparent temperature"
},
"chill_index": {
"name": "Wind chill"
},
"hourly_rain": {
"name": "Hourly precipitation"
},
"weekly_rain": {
"name": "Weekly precipitation"
},
"monthly_rain": {
"name": "Monthly precipitation"
},
"yearly_rain": {
"name": "Yearly precipitation"
},
"wbgt_temp": {
"name": "WBGT index"
},
"hcho": {
"name": "Formaldehyde (HCHO)"
},
"voc": {
"name": "VOC level",
"state": {
"unhealthy": "Unhealthy",
"poor": "Poor",
"moderate": "Moderate",
"good": "Good",
"excellent": "Excellent"
}
},
"t9_battery": {
"name": "HCHO/VOC sensor battery"
},
"wind_azimut": {
"name": "Bearing",
"state": {
@ -185,18 +456,96 @@
"wnw": "WNW",
"nw": "NW",
"nnw": "NNW"
},
"outside_battery": {
"name": "Outside battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
}
},
"outside_battery": {
"name": "Outside battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch2_battery": {
"name": "Channel 2 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch3_battery": {
"name": "Channel 3 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch4_battery": {
"name": "Channel 4 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch5_battery": {
"name": "Channel 5 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch6_battery": {
"name": "Channel 6 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch7_battery": {
"name": "Channel 7 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch8_battery": {
"name": "Channel 8 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"indoor_battery": {
"name": "Console battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
}
}
},
"issues": {
"legacy_battery_sensor_deprecated": {
"title": "Legacy battery sensor detected",
"description": "Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings -> Devices & Services -> SWS 12500."
},
"stale_sensors_detected": {
"title": "Stale sensors detected",
"description": "These sensors have not reported data for an extended period and may be misconfigured or out of range: {sensors}. If you no longer use them, remove them from the integration options."
},
"protocol_conflict": {
"title": "Two incompatible protocols enabled",
"description": "The legacy (PWS/WSLink) and Ecowitt endpoints are both enabled. They map onto the same sensor entities, so running both mixes up measurement units and makes readings disappear between updates. The legacy endpoint is being used and Ecowitt data is ignored until you choose one: go to Settings -> Devices & Services -> SWS 12500 -> Configure and disable either the legacy endpoint or Ecowitt."
}
},
"notify": {
"added": {
"title": "New sensors for SWS 12500 found.",

View File

@ -0,0 +1 @@
../dev/custom_components/sws12500

View File

@ -3,23 +3,44 @@
"error": {
"valid_credentials_api": "Vyplňte platné API ID.",
"valid_credentials_key": "Vyplňte platný API KEY.",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!"
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"protocols_mutually_exclusive": "Starý endpoint (PWS/WSLink) a Ecowitt nelze zapnout současně plní stejné entity senzorů, což by pomíchalo jednotky a mazalo naměřené hodnoty. Nejdřív jeden z nich vypni."
},
"step": {
"user": {
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
"title": "Nastavení přihlášení",
"title": "Vyberte typ stanice",
"description": "Zadejte typ stanice, kterou používáte. Pokude nepoužíváte Ecowitt, vyberte PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, jiné - Weather Underground kompatibilní)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "Přihlašovací údaje PWS/WSLink.",
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem.",
"data": {
"API_ID": "API ID / ID Stanice",
"API_KEY": "API KEY / Heslo",
"wslink": "WSLink API",
"wslink": "WSLink Protorkol",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
"wslink": "Zapněte tuto volbu, pokud stanice používá WSLink protokol. Pokud si nejstě jistí, použijte https://test-station.schizza.cz/",
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
}
},
"ecowitt": {
"title": "Nastavení pro Ecowitt stanice",
"description": "Zadejte unikátní webhook ID pro příjem dat ze stanic Ecowitt. Pokud nepoužíváte stanice Ecowitt, tento krok přeskočte.",
"data": {
"ecowitt_webhook_id": "Unikátní webhook ID pro Ecowitt stanice",
"ecowitt_enabled": "Povolit data ze stanic Ecowitt"
},
"data_description": {
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
}
}
}
@ -31,9 +52,11 @@
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
"windy_key_required": "Pro aktivaci je vyžadováno Windy ID i heslo stanice.",
"pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ",
"pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.",
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund.",
"protocols_mutually_exclusive": "Starý endpoint (PWS/WSLink) a Ecowitt nelze zapnout současně plní stejné entity senzorů, což by pomíchalo jednotky a mazalo naměřené hodnoty. Nejdřív jeden z nich vypni."
},
"step": {
"init": {
@ -43,23 +66,26 @@
"basic": "Základní - přístupové údaje (přihlášení)",
"windy": "Nastavení pro přeposílání dat na Windy",
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
"migration": "Migrace statistiky senzoru"
"ecowitt": "Nastavení pro stanice Ecowitt",
"wslink_port_setup": "Nastavení portu WSLink Addonu"
}
},
"basic": {
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
"title": "Nastavení přihlášení",
"description": "Nastavení endpointu PWS/WSLink. Pro stanici jen s Ecowwittem vypněte 'Povolit endpoint PWS/WSLink'. API ID/KEY nejsou potřeba",
"title": "Nastavení PWS/WSLink",
"data": {
"API_ID": "API ID / ID Stanice",
"API_KEY": "API KEY / Heslo",
"wslink": "WSLink API",
"dev_debug_checkbox": "Developer log"
"wslink": "WSLink protokol",
"dev_debug_checkbox": "Developer log",
"legacy_enabled": "Povolit endpoint PWS/WSLink"
},
"data_description": {
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink.",
"legacy_enabled": "Vyplněte, pokud vaše stanice používá pouze Ecowitt."
}
},
"windy": {
@ -83,34 +109,216 @@
"data": {
"POCASI_CZ_API_ID": "ID účtu na Počasí Meteo",
"POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo",
"POCASI_CZ_SEND_INTERVAL": "Interval v sekundách",
"pocasi_enabled_chcekbox": "Povolit přeposílání dat na server Počasí Meteo",
"POCASI_SEND_INTERVAL": "Interval v sekundách",
"pocasi_enabled_checkbox": "Povolit přeposílání dat na server Počasí Meteo",
"pocasi_logger_checkbox": "Logovat data a odpovědi z Počasí Meteo"
},
"data_description": {
"POCASI_API_ID": "ID získáte ve své aplikaci Počasí Meteo",
"POCASI_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo",
"POCASI_CZ_API_ID": "ID získáte ve své aplikaci Počasí Meteo",
"POCASI_CZ_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo",
"POCASI_SEND_INTERVAL": "Interval v jakém se mají data na server přeposílat (minimum 12s, defaultně 30s)",
"pocasi_enabled_checkbox": "Zapne přeposílání data na server Počasí Meteo",
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
}
},
"migration": {
"title": "Migrace statistiky senzoru.",
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
"ecowitt": {
"description": "Nastavení pro Ecowitt",
"title": "Konfigurace pro stanice Ecowitt",
"data": {
"sensor_to_migrate": "Senzor pro migraci",
"trigger_action": "Spustit migraci"
"ecowitt_webhook_id": "Unikátní webhook ID",
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
},
"data_description": {
"sensor_to_migrate": "Vyberte správný senzor pri migraci statistiky. \n Hodnoty senzoru budou zachovány, nepřepočítají se, pouze se změní jednotka v dlouhodobé statistice. ",
"trigger_action": "Po zaškrtnutí se spustí migrace statistiky senzoru."
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
}
},
"wslink_port_setup": {
"description": "Nastavení portu, kde naslouchá WSLink Addon. Slouží pro příjem diagnostik.",
"title": "Port WSLink Addonu",
"data": {
"WSLINK_ADDON_PORT": "Naslouchající port WSLink Addonu"
},
"data_description": {
"WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu."
}
}
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Venkovní baterie"
},
"indoor_battery": {
"name": "Baterie kozole"
},
"ch2_battery": {
"name": "Baterie senzoru 2"
},
"ch3_battery": {
"name": "Baterie senzoru 3"
},
"ch4_battery": {
"name": "Baterie senzoru 4"
},
"ch5_battery": {
"name": "Baterie senzoru 5"
},
"ch6_battery": {
"name": "Baterie senzoru 6"
},
"ch7_battery": {
"name": "Baterie senzoru 7"
},
"ch8_battery": {
"name": "Baterie senzoru 8"
}
},
"sensor": {
"ecowitt_absolute_pressure": {
"name": "Absolutní tlak"
},
"ecowitt_rain_rate": {
"name": "Intenzita srážek"
},
"ecowitt_event_rain": {
"name": "Srážky (událost)"
},
"ecowitt_hourly_rain": {
"name": "Hodinové srážky"
},
"ecowitt_weekly_rain": {
"name": "Týdenní srážky"
},
"ecowitt_monthly_rain": {
"name": "Měsíční srážky"
},
"ecowitt_yearly_rain": {
"name": "Roční srážky"
},
"ecowitt_total_rain": {
"name": "Celkové srážky"
},
"ecowitt_24h_rain": {
"name": "Srážky za 24 h"
},
"ecowitt_feels_like": {
"name": "Pocitová teplota"
},
"ecowitt_indoor_dewpoint": {
"name": "Vnitřní rosný bod"
},
"ecowitt_console_co2": {
"name": "CO₂ konzole"
},
"ecowitt_console_co2_24h": {
"name": "CO₂ konzole (průměr 24 h)"
},
"ecowitt_co2": {
"name": "CO₂"
},
"ecowitt_co2_24h": {
"name": "CO₂ (průměr 24 h)"
},
"integration_health": {
"name": "Stav integrace",
"state": {
"online_wu": "Online PWS/WU",
"online_wslink": "Online WSLink",
"online_ecowitt": "Online Ecowitt",
"online_idle": "Čekám na data",
"degraded": "Degradovaný",
"error": "Nefunkční"
}
},
"active_protocol": {
"name": "Aktivní protokol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "Stav WSLink Addonu",
"state": {
"online": "Běží",
"offline": "Vypnutý"
}
},
"wslink_addon_name": {
"name": "Název WSLink Addonu"
},
"wslink_addon_version": {
"name": "Verze WSLink Addonu"
},
"wslink_addon_listen_port": {
"name": "Port WSLink Addonu"
},
"wslink_upstream_ha_port": {
"name": "Port upstream HA WSLink Addonu"
},
"route_wu_enabled": {
"name": "Protokol PWS/WU"
},
"route_wslink_enabled": {
"name": "Protokol WSLink"
},
"last_ingress_time": {
"name": "Poslední přístup"
},
"last_ingress_protocol": {
"name": "Protokol posledního přístupu",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Trasa posledního přístupu povolena"
},
"last_ingress_accepted": {
"name": "Poslední přístup",
"state": {
"accepted": "Prijat",
"rejected": "Odmítnut"
}
},
"last_ingress_authorized": {
"name": "Autorizace posledního přístupu",
"state": {
"authorized": "Autorizován",
"unauthorized": "Neautorizován",
"unknown": "Neznámý"
}
},
"last_ingress_reason": {
"name": "Zpráva přístupu"
},
"forward_windy_enabled": {
"name": "Přeposílání na Windy"
},
"forward_windy_status": {
"name": "Stav přeposílání na Windy",
"state": {
"disabled": "Vypnuto",
"idle": "Čekám na odeslání",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Přeposílání na Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Stav přeposílání na Počasí Meteo",
"state": {
"disabled": "Vypnuto",
"idle": "Čekám na odeslání",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Vnitřní teplota"
},
@ -168,6 +376,30 @@
"ch4_humidity": {
"name": "Vlhkost sensoru 4"
},
"ch5_temp": {
"name": "Teplota senzoru 5"
},
"ch5_humidity": {
"name": "Vlhkost sensoru 5"
},
"ch6_temp": {
"name": "Teplota senzoru 6"
},
"ch6_humidity": {
"name": "Vlhkost sensoru 6"
},
"ch7_temp": {
"name": "Teplota senzoru 7"
},
"ch7_humidity": {
"name": "Vlhkost sensoru 7"
},
"ch8_temp": {
"name": "Teplota senzoru 8"
},
"ch8_humidity": {
"name": "Vlhkost sensoru 8"
},
"heat_index": {
"name": "Tepelný index"
},
@ -231,7 +463,7 @@
"state": {
"low": "Nízká",
"normal": "Normální",
"unknown": "Neznámá / zcela vybitá"
"drained": "Neznámá / zcela vybitá"
}
},
"indoor_battery": {
@ -239,7 +471,7 @@
"state": {
"low": "Nízká",
"normal": "Normální",
"unknown": "Neznámá / zcela vybitá"
"drained": "Neznámá / zcela vybitá"
}
},
"ch2_battery": {
@ -247,11 +479,73 @@
"state": {
"low": "Nízká",
"normal": "Normální",
"unknown": "Neznámá / zcela vybitá"
"drained": "Neznámá / zcela vybitá"
}
},
"ch3_battery": {
"name": "Stav nabití baterie kanálu 3",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"ch4_battery": {
"name": "Stav nabití baterie kanálu 4",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"ch5_battery": {
"name": "Stav nabití baterie kanálu 5",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"ch6_battery": {
"name": "Stav nabití baterie kanálu 6",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"ch7_battery": {
"name": "Stav nabití baterie kanálu 7",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"ch8_battery": {
"name": "Stav nabití baterie kanálu 8",
"state": {
"low": "Nízká",
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
}
}
},
"issues": {
"legacy_battery_sensor_deprecated": {
"title": "Detekovány zastaralé senzory baterií.",
"description": "V registru entit byly nalezeny staré senzory baterií ({entities}). Byly nahrazeny binárními senzory baterií a budou odstraněny ve verzi {remove_version}. Smažte prosím staré entity ručně přes Nastavení -> Zařízení a služby -> SWS-12500."
},
"stale_sensors_detected": {
"title": "Detekovány nečinné sensory",
"description": "Tyto senzory jsou nastavené, ale nereportují data: {sensors}. V HA budou viditelné jako Nedostupné. Pokud už nejsou přítomné, můžeš je odstranit přes Nastavení > Zařízení a Služby > SWS 12500."
},
"protocol_conflict": {
"title": "Zapnuté dva neslučitelné protokoly",
"description": "Současně je zapnutý starý endpoint (PWS/WSLink) i Ecowitt. Oba plní stejné entity senzorů, takže jejich souběh míchá měrné jednotky a hodnoty mezi aktualizacemi mizí. Používá se starý endpoint a data z Ecowittu se ignorují, dokud si nevybereš jeden: jdi do Nastavení -> Zařízení a služby -> SWS 12500 -> Konfigurovat a vypni buď starý endpoint, nebo Ecowitt."
}
},
"notify": {
"added": {
"title": "Nalezeny nové senzory pro SWS 12500.",

View File

@ -3,23 +3,44 @@
"error": {
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same."
"valid_credentials_match": "API ID and API KEY should not be the same.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
"user": {
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
"title": "Configure access for Weather Station",
"title": "Choose your station type",
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "PWS/WSLink credentials.",
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink Protocol",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
"API_ID": "API ID is the Station ID you set in the Weather Station.",
"API_KEY": "API KEY is the password you set in the Weather Station.",
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
"wslink": "Enable WSLink Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
}
},
"ecowitt": {
"title": "Ecowitt configuration.",
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
}
}
}
@ -29,8 +50,13 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_id_required": "Windy API key is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function."
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",
"pocasi_id_required": "Pocasi Meteo API ID is required if you want to enable this function.",
"pocasi_key_required": "Pocasi Meteo API key is required if you want to enable this function.",
"pocasi_send_minimum": "The resend interval must be at least 12 seconds.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
"init": {
@ -38,7 +64,10 @@
"description": "Choose what do you want to configure. If basic access or resending data for Windy site",
"menu_options": {
"basic": "Basic - configure credentials for Weather Station",
"windy": "Windy configuration"
"wslink_port_setup": "WSLink add-on port",
"ecowitt": "Ecowitt configuration",
"windy": "Windy configuration",
"pocasi": "Pocasi Meteo CZ configuration"
}
},
"basic": {
@ -47,14 +76,16 @@
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink API",
"legacy_enabled": "Enable PWS/WSLink endpoint",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
"API_ID": "API ID is the Station ID you set in the Weather Station.",
"API_KEY": "API KEY is the password you set in the Weather Station.",
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
"wslink": "Enable WSLink API if the station is set to send data via WSLink.",
"legacy_enabled": "Enable the PWS/WSLink endpoint. Turn off for an Ecowitt-only setup."
}
},
"windy": {
@ -78,34 +109,216 @@
"data": {
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds",
"POCASI_SEND_INTERVAL": "Resend interval in seconds",
"pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo",
"pocasi_logger_checkbox": "Log data and responses"
},
"data_description": {
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App",
"POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
"pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo",
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
}
},
"migration": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
"ecowitt": {
"description": "Ecowitt configuration.",
"title": "Ecowitt station configuration",
"data": {
"sensor_to_migrate": "Sensor to migrate",
"trigger_action": "Trigger migration"
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
"trigger_action": "Trigger the sensor statistics migration after checking."
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
}
},
"wslink_port_setup": {
"title": "WSLink add-on port",
"description": "Set the external port of the WSLink proxy add-on so the integration can reach its health endpoint.",
"data": {
"WSLINK_ADDON_PORT": "WSLink add-on port"
},
"data_description": {
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
}
}
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Outside battery"
},
"indoor_battery": {
"name": "Console battery"
},
"ch2_battery": {
"name": "Channel 2 battery"
},
"ch3_battery": {
"name": "Channel 3 battery"
},
"ch4_battery": {
"name": "Channel 4 battery"
},
"ch5_battery": {
"name": "Channel 5 battery"
},
"ch6_battery": {
"name": "Channel 6 battery"
},
"ch7_battery": {
"name": "Channel 7 battery"
},
"ch8_battery": {
"name": "Channel 8 battery"
}
},
"sensor": {
"ecowitt_absolute_pressure": {
"name": "Absolute pressure"
},
"ecowitt_rain_rate": {
"name": "Rain rate"
},
"ecowitt_event_rain": {
"name": "Event rain"
},
"ecowitt_hourly_rain": {
"name": "Hourly rain"
},
"ecowitt_weekly_rain": {
"name": "Weekly rain"
},
"ecowitt_monthly_rain": {
"name": "Monthly rain"
},
"ecowitt_yearly_rain": {
"name": "Yearly rain"
},
"ecowitt_total_rain": {
"name": "Total rain"
},
"ecowitt_24h_rain": {
"name": "24h rain"
},
"ecowitt_feels_like": {
"name": "Feels like"
},
"ecowitt_indoor_dewpoint": {
"name": "Indoor dew point"
},
"ecowitt_console_co2": {
"name": "Console CO₂"
},
"ecowitt_console_co2_24h": {
"name": "Console CO₂ (24h avg)"
},
"ecowitt_co2": {
"name": "CO₂"
},
"ecowitt_co2_24h": {
"name": "CO₂ (24h avg)"
},
"integration_health": {
"name": "Integration status",
"state": {
"online_wu": "Online PWS/WU",
"online_wslink": "Online WSLink",
"online_ecowitt": "Online Ecowitt",
"online_idle": "Waiting for data",
"degraded": "Degraded",
"error": "Error"
}
},
"active_protocol": {
"name": "Active protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "WSLink Addon Status",
"state": {
"online": "Running",
"offline": "Offline"
}
},
"wslink_addon_name": {
"name": "WSLink Addon Name"
},
"wslink_addon_version": {
"name": "WSLink Addon Version"
},
"wslink_addon_listen_port": {
"name": "WSLink Addon Listen Port"
},
"wslink_upstream_ha_port": {
"name": "WSLink Addon Upstream HA Port"
},
"route_wu_enabled": {
"name": "PWS/WU Protocol"
},
"route_wslink_enabled": {
"name": "WSLink Protocol"
},
"last_ingress_time": {
"name": "Last access time"
},
"last_ingress_protocol": {
"name": "Last access protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Last ingress route enabled"
},
"last_ingress_accepted": {
"name": "Last access",
"state": {
"accepted": "Accepted",
"rejected": "Rejected"
}
},
"last_ingress_authorized": {
"name": "Last access authorization",
"state": {
"authorized": "Authorized",
"unauthorized": "Unauthorized",
"unknown": "Unknown"
}
},
"last_ingress_reason": {
"name": "Last access reason"
},
"forward_windy_enabled": {
"name": "Forwarding to Windy"
},
"forward_windy_status": {
"name": "Forwarding status to Windy",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Forwarding to Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Forwarding status to Počasí Meteo",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Indoor temperature"
},
@ -163,6 +376,30 @@
"ch4_humidity": {
"name": "Channel 4 humidity"
},
"ch5_temp": {
"name": "Channel 5 temperature"
},
"ch5_humidity": {
"name": "Channel 5 humidity"
},
"ch6_temp": {
"name": "Channel 6 temperature"
},
"ch6_humidity": {
"name": "Channel 6 humidity"
},
"ch7_temp": {
"name": "Channel 7 temperature"
},
"ch7_humidity": {
"name": "Channel 7 humidity"
},
"ch8_temp": {
"name": "Channel 8 temperature"
},
"ch8_humidity": {
"name": "Channel 8 humidity"
},
"heat_index": {
"name": "Apparent temperature"
},
@ -181,7 +418,7 @@
"yearly_rain": {
"name": "Yearly precipitation"
},
"wbgt_index": {
"wbgt_temp": {
"name": "WBGT index"
},
"hcho": {
@ -226,7 +463,7 @@
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
"drained": "Unknown / drained out"
}
},
"ch2_battery": {
@ -234,7 +471,55 @@
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
"drained": "Unknown / drained out"
}
},
"ch3_battery": {
"name": "Channel 3 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch4_battery": {
"name": "Channel 4 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch5_battery": {
"name": "Channel 5 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch6_battery": {
"name": "Channel 6 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch7_battery": {
"name": "Channel 7 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"ch8_battery": {
"name": "Channel 8 battery level",
"state": {
"normal": "OK",
"low": "Low",
"drained": "Unknown / drained out"
}
},
"indoor_battery": {
@ -242,11 +527,25 @@
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
"drained": "Unknown / drained out"
}
}
}
},
"issues": {
"legacy_battery_sensor_deprecated": {
"title": "Legacy battery sensor detected",
"description": "Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings -> Devices & Services -> SWS 12500."
},
"stale_sensors_detected": {
"title": "Stale sensors detected",
"description": "These sensors have not reported data for an extended period and may be misconfigured or out of range: {sensors}. If you no longer use them, remove them from the integration options."
},
"protocol_conflict": {
"title": "Two incompatible protocols enabled",
"description": "The legacy (PWS/WSLink) and Ecowitt endpoints are both enabled. They map onto the same sensor entities, so running both mixes up measurement units and makes readings disappear between updates. The legacy endpoint is being used and Ecowitt data is ignored until you choose one: go to Settings -> Devices & Services -> SWS 12500 -> Configure and disable either the legacy endpoint or Ecowitt."
}
},
"notify": {
"added": {
"title": "New sensors for SWS 12500 found.",

View File

@ -1,12 +1,23 @@
"""Utils for SWS12500."""
"""Utils for SWS12500.
This module contains small helpers used across the integration.
Notable responsibilities:
- Payload remapping: convert raw station/webhook field names into stable internal keys.
- Auto-discovery helpers: detect new payload fields that are not enabled yet and persist them
to config entry options so sensors can be created dynamically.
- Formatting/conversion helpers (wind direction text, battery mapping, temperature conversions).
Keeping these concerns in one place avoids duplicating logic in the webhook handler and entity code.
"""
from __future__ import annotations
import logging
import math
from pathlib import Path
import sqlite3
from typing import Any
from typing import Any, Final
import numpy as np
from py_typecheck.core import checked_or
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
@ -15,9 +26,10 @@ from homeassistant.helpers.translation import async_get_translations
from .const import (
AZIMUT,
CHILL_INDEX,
CONNECTION_GATED_SENSORS,
DATABASE_PATH,
DEV_DBG,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
REMAP_ITEMS,
@ -40,17 +52,17 @@ async def translations(
*,
key: str = "message",
category: str = "notify",
) -> str:
) -> str | None:
"""Get translated keys for domain."""
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
language = hass.config.language
language: str = hass.config.language
_translations = await async_get_translations(hass, language, category, [translation_domain])
if localize_key in _translations:
return _translations[localize_key]
return ""
return None
async def translated_notification(
@ -69,7 +81,7 @@ async def translated_notification(
localize_title = f"component.{translation_domain}.{category}.{translation_key}.title"
language = hass.config.language
language: str = hass.config.language
_translations = await async_get_translations(hass, language, category, [translation_domain])
if localize_key in _translations:
@ -85,7 +97,7 @@ async def translated_notification(
persistent_notification.async_create(hass, message, _translations[localize_title], notification_id)
async def update_options(hass: HomeAssistant, entry: ConfigEntry, update_key, update_value) -> bool:
async def update_options(hass: HomeAssistant, entry: ConfigEntry, update_key: str, update_value: Any) -> bool:
"""Update config.options entry."""
conf = {**entry.options}
conf[update_key] = update_value
@ -93,33 +105,35 @@ async def update_options(hass: HomeAssistant, entry: ConfigEntry, update_key, up
return hass.config_entries.async_update_entry(entry, options=conf)
def anonymize(data):
"""Anoynimize recieved data."""
def anonymize(
data: dict[str, str | int | float | bool],
) -> dict[str, str | int | float | bool]:
"""Anonymize received data for safe logging.
anonym = {}
for k in data:
if k not in {"ID", "PASSWORD", "wsid", "wspw"}:
anonym[k] = data[k]
- Keep all keys, but mask sensitive values.
- Do not raise on unexpected/missing keys.
"""
secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"}
return anonym
return {k: ("***" if k in secrets else v) for k, v in data.items()}
def remap_items(entities):
"""Remap items in query."""
items = {}
for item in entities:
if item in REMAP_ITEMS:
items[REMAP_ITEMS[item]] = entities[item]
def remap_items(entities: dict[str, str]) -> dict[str, str]:
"""Remap legacy (WU-style) payload field names into internal sensor keys.
return items
The station sends short/legacy field names (e.g. "tempf", "humidity"). Internally we use
stable keys from `const.py` (e.g. "outside_temp", "outside_humidity"). This function produces
a normalized dict that the rest of the integration can work with.
"""
return {REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS}
def remap_wslink_items(entities):
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
"""Remap items in query for WSLink API."""
items = {}
for item in entities:
items: dict[str, str] = {}
for item, value in entities.items():
if item in REMAP_WSLINK_ITEMS:
items[REMAP_WSLINK_ITEMS[item]] = entities[item]
items[REMAP_WSLINK_ITEMS[item]] = value
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
if str(entities.get(conn_key, "0")) != "1":
@ -129,24 +143,37 @@ def remap_wslink_items(entities):
return items
def loaded_sensors(config_entry: ConfigEntry) -> list | None:
"""Get loaded sensors."""
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
"""Return sensor keys currently enabled for this config entry.
Auto-discovery persists new keys into `config_entry.options[SENSORS_TO_LOAD]`. The sensor
platform uses this list to decide which entities to create.
"""
return config_entry.options.get(SENSORS_TO_LOAD) or []
def check_disabled(hass: HomeAssistant, items, config_entry: ConfigEntry) -> list | None:
"""Check if we have data for unloaded sensors.
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
"""Detect payload fields that are not enabled yet (auto-discovery).
If so, then add sensor to load queue.
The integration supports "auto-discovery" of sensors: when the station starts sending a new
field, we can automatically enable and create the corresponding entity.
This helper compares the normalized payload keys (`items`) with the currently enabled sensor
keys stored in options (`SENSORS_TO_LOAD`) and returns the missing keys.
Returns:
- list[str] of newly discovered sensor keys (to be added/enabled), or
- None if no new keys were found.
Notes:
- Logging is controlled via `DEV_DBG` because payloads can arrive frequently.
Returns list of found sensors or None
"""
log: bool = config_entry.options.get(DEV_DBG, False)
entityFound: bool = False
_loaded_sensors = loaded_sensors(config_entry)
missing_sensors: list = []
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
_loaded_sensors: list[str] = loaded_sensors(config_entry)
missing_sensors: list[str] = []
for item in items:
if log:
@ -154,27 +181,36 @@ def check_disabled(hass: HomeAssistant, items, config_entry: ConfigEntry) -> lis
if item not in _loaded_sensors:
missing_sensors.append(item)
entityFound = True
if log:
_LOGGER.info("Add sensor (%s) to loading queue", item)
return missing_sensors if entityFound else None
return missing_sensors or 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
"""
if deg:
return AZIMUT[int(abs((float(deg) - 11.25) % 360) / 22.5)]
_deg = to_float(deg)
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_to_text(battery: int) -> UnitOfBat:
"""Return battery level in text representation.
def battery_level(battery: int | str | None) -> UnitOfBat:
"""Return battery level.
WSLink payload values often arrive as strings (e.g. "0"/"1"), so we accept
both ints and strings and coerce to int before mapping.
Returns UnitOfBat
"""
@ -184,24 +220,21 @@ def battery_level_to_text(battery: int) -> UnitOfBat:
1: UnitOfBat.NORMAL,
}
if battery is None:
if (battery is None) or (battery == ""):
return UnitOfBat.UNKNOWN
return level_map.get(int(battery), UnitOfBat.UNKNOWN)
vi: int
if isinstance(battery, int):
vi = battery
else:
try:
vi = int(battery)
except ValueError:
return UnitOfBat.UNKNOWN
return level_map.get(vi, UnitOfBat.UNKNOWN)
def battery_level_to_icon(battery: UnitOfBat) -> str:
"""Return battery level in icon representation.
Returns str
"""
icons = {
UnitOfBat.LOW: "mdi:battery-low",
UnitOfBat.NORMAL: "mdi:battery",
}
return icons.get(battery, "mdi:battery-unknown")
def fahrenheit_to_celsius(fahrenheit: float) -> float:
@ -214,21 +247,63 @@ def celsius_to_fahrenheit(celsius: float) -> float:
return celsius * 9.0 / 5.0 + 32
def heat_index(data: Any, convert: bool = False) -> float | None:
def to_int(val: Any) -> int | None:
"""Convert int or string (including decimal-formatted, e.g. "180.0") to int."""
if val is None:
return None
if isinstance(val, str) and val.strip() == "":
return None
try:
return int(val)
except (TypeError, ValueError):
pass
# The station sometimes sends integer fields as decimals ("180.0"); accept those.
try:
return int(float(val))
except (TypeError, ValueError):
return None
def to_float(val: Any) -> float | None:
"""Convert int or string to float."""
if val is None:
return None
if isinstance(val, str) and val.strip() == "":
return None
try:
v = float(val)
except (TypeError, ValueError):
return None
else:
return v
def heat_index(data: dict[str, int | float | str], convert: bool = False) -> float | None:
"""Calculate heat index from temperature.
data: dict with temperature and humidity
convert: bool, convert recieved data from Celsius to Fahrenheit
convert: bool, convert received data from Celsius to Fahrenheit
"""
temp = data.get(OUTSIDE_TEMP, None)
rh = data.get(OUTSIDE_HUMIDITY, None)
if not temp or not rh:
if (temp := to_float(data.get(OUTSIDE_TEMP))) is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
temp,
)
return None
temp = float(temp)
rh = float(rh)
if (rh := to_float(data.get(OUTSIDE_HUMIDITY))) is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE HUMIDITY (%s), cannot calculate wind chill index.",
rh,
)
return None
adjustment = None
@ -248,10 +323,10 @@ def heat_index(data: Any, convert: bool = False) -> float | None:
+ 0.00085282 * temp * rh * rh
- 0.00000199 * temp * temp * rh * rh
)
if rh < 13 and (temp in np.arange(80, 112, 0.1)):
if rh < 13 and (80 <= temp <= 112):
adjustment = ((13 - rh) / 4) * math.sqrt((17 - abs(temp - 95)) / 17)
if rh > 80 and (temp in np.arange(80, 87, 0.1)):
if rh > 80 and (80 <= temp <= 87):
adjustment = ((rh - 85) / 10) * ((87 - temp) / 5)
return round((full_index + adjustment if adjustment else full_index), 2)
@ -259,21 +334,28 @@ def heat_index(data: Any, convert: bool = False) -> float | None:
return simple
def chill_index(data: Any, convert: bool = False) -> float | None:
def chill_index(data: dict[str, str | float | int], convert: bool = False) -> float | None:
"""Calculate wind chill index from temperature and wind speed.
data: dict with temperature and wind speed
convert: bool, convert recieved data from Celsius to Fahrenheit
convert: bool, convert received data from Celsius to Fahrenheit
"""
temp = to_float(data.get(OUTSIDE_TEMP))
wind = to_float(data.get(WIND_SPEED))
temp = data.get(OUTSIDE_TEMP, None)
wind = data.get(WIND_SPEED, None)
if not temp or not wind:
if temp is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
temp,
)
return None
temp = float(temp)
wind = float(wind)
if wind is None:
_LOGGER.error(
"We are missing/invalid WIND SPEED (%s), cannot calculate wind chill index.",
wind,
)
return None
if convert:
temp = celsius_to_fahrenheit(temp)
@ -288,121 +370,80 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
)
def voc_level_to_text(value: str) -> VOCLevel | None:
"""Map 1-5 VOC level to text state."""
if value in (None, ""):
def voc_level_to_text(value: Any) -> VOCLevel | None:
"""Map the 1-5 VOC level to a text state.
Goes through `to_int` like every other value_fn: a bare `int()` raises on a garbage
payload value, which `WeatherSensor.native_value` then logs with a full traceback on
every push.
"""
level = to_int(value)
if level is None:
return None
return VOC_LEVEL_MAP.get(int(value))
return VOC_LEVEL_MAP.get(level)
def battery_5step_to_pct(value: str) -> int | None:
"""Convert 0-5 battery steps to percentage."""
def battery_5step_to_pct(value: Any) -> int | None:
"""Convert the 0-5 battery step to a percentage.
if value in (None, ""):
Out-of-range steps are clamped so the reading stays valid for a battery
device class (see `voc_level_to_text` for why `to_int` is used).
"""
step = to_int(value)
if step is None:
return None
return round(int(value) / 5 * 100)
return round(min(max(step, 0), 5) / 5 * 100)
def long_term_units_in_statistics_meta():
"""Get units in long term statitstics."""
sensor_units = []
if not Path(DATABASE_PATH).exists():
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
return False
conn = sqlite3.connect(DATABASE_PATH)
db = conn.cursor()
try:
db.execute(
"""
SELECT statistic_id, unit_of_measurement from statistics_meta
WHERE statistic_id LIKE 'sensor.weather_station_sws%'
"""
)
rows = db.fetchall()
sensor_units = {statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows}
except sqlite3.Error as e:
_LOGGER.error("Error during data migration: %s", e)
finally:
conn.close()
return sensor_units
# The NWS wind-chill formula is defined for mph; WSLink reports wind in m/s.
_MS_TO_MPH: Final = 2.236936
async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
"""Migrate data from mm/d to mm."""
def wslink_heat_index(data: dict[str, Any]) -> float | None:
"""Heat index for a WSLink payload, in Celsius.
_LOGGER.debug("Sensor %s is required for data migration", sensor_id)
updated_rows = 0
Prefers the station's own `t1heat` reading. Not every WSLink station sends one,
and without a fallback the entity - which `_auto_enable_derived_sensors` creates
as soon as temperature and humidity arrive - stays Unavailable forever.
if not Path(DATABASE_PATH).exists():
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
return False
`heat_index` takes Celsius via `convert=True` but always returns Fahrenheit, so
the result is converted back to match this entity's native Celsius unit.
"""
if (reported := to_float(data.get(HEAT_INDEX))) is not None:
return reported
conn = sqlite3.connect(DATABASE_PATH)
db = conn.cursor()
# Guard here rather than letting heat_index log an error per push: a payload
# without these fields is normal, not a fault.
if to_float(data.get(OUTSIDE_TEMP)) is None or to_float(data.get(OUTSIDE_HUMIDITY)) is None:
return None
try:
_LOGGER.info(sensor_id)
db.execute(
"""
UPDATE statistics_meta
SET unit_of_measurement = 'mm'
WHERE statistic_id = ?
AND unit_of_measurement = 'mm/d';
""",
(sensor_id,),
)
updated_rows = db.rowcount
conn.commit()
_LOGGER.info(
"Data migration completed successfully. Updated rows: %s for %s",
updated_rows,
sensor_id,
)
except sqlite3.Error as e:
_LOGGER.error("Error during data migration: %s", e)
finally:
conn.close()
return updated_rows
value_f = heat_index(data, convert=True)
return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2)
def migrate_data_old(sensor_id: str | None = None):
"""Migrate data from mm/d to mm."""
updated_rows = 0
def wslink_chill_index(data: dict[str, Any]) -> float | None:
"""Wind chill for a WSLink payload, in Celsius.
if not Path(DATABASE_PATH).exists():
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
return False
Prefers the station's own `t1chill` reading; see `wslink_heat_index` for why a
fallback is needed.
conn = sqlite3.connect(DATABASE_PATH)
db = conn.cursor()
`chill_index` converts the temperature but *not* the wind speed, and its formula
expects mph, so the m/s reading is converted here before the Fahrenheit result is
turned back into Celsius.
"""
if (reported := to_float(data.get(CHILL_INDEX))) is not None:
return reported
try:
_LOGGER.info(sensor_id)
db.execute(
"""
UPDATE statistics_meta
SET unit_of_measurement = 'mm'
WHERE statistic_id = ?
AND unit_of_measurement = 'mm/d';
""",
(sensor_id,),
)
updated_rows = db.rowcount
conn.commit()
_LOGGER.info(
"Data migration completed successfully. Updated rows: %s for %s",
updated_rows,
sensor_id,
)
temp_c = to_float(data.get(OUTSIDE_TEMP))
wind_ms = to_float(data.get(WIND_SPEED))
if temp_c is None or wind_ms is None:
return None
except sqlite3.Error as e:
_LOGGER.error("Error during data migration: %s", e)
finally:
conn.close()
return updated_rows
value_f = chill_index(
{
OUTSIDE_TEMP: celsius_to_fahrenheit(temp_c),
WIND_SPEED: wind_ms * _MS_TO_MPH,
}
)
return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2)

View File

@ -1,20 +1,26 @@
"""Windy functions."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from aiohttp.client import ClientResponse
from aiohttp.client_exceptions import ClientError
from py_typecheck import checked_or
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
PURGE_DATA,
WINDY_ENABLED,
WINDY_INVALID_KEY,
WINDY_LOGGER_ENABLED,
WINDY_MAX_RETRIES,
WINDY_NOT_INSERTED,
WINDY_STATION_ID,
WINDY_STATION_PW,
@ -26,19 +32,38 @@ from .utils import update_options
_LOGGER = logging.getLogger(__name__)
RESPONSE_FOR_TEST = False
class WindyNotInserted(Exception):
"""NotInserted state."""
"""NotInserted state.
Possible variants are:
- station password is invalid
- station password does not match the station
- payload failed validation
"""
class WindySuccess(Exception):
"""WindySucces state."""
class WindyApiKeyError(Exception):
"""Windy API Key error."""
class WindyPasswordMissing(Exception):
"""Windy password is missing in query or Authorization header.
This should not happend, while we are checking if we have password set and do exits early.
"""
class WindyDuplicatePayloadDetected(Exception):
"""Duplicate payload detected."""
class WindyRateLimitExceeded(Exception):
"""Rate limit exceeded. Minimum interval is 5 minutes.
This should not happend in runnig integration.
Might be seen, if restart of HomeAssistant occured and we are not aware of previous update.
"""
def timed(minutes: int):
@ -56,40 +81,100 @@ class WindyPush:
"""Init."""
self.hass = hass
self.config = config
self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None
self.last_attempt_at: str | None = None
""" lets wait for 1 minute to get initial data from station
and then try to push first data to Windy
"""
self.last_update = datetime.now()
self.next_update = datetime.now() + timed(minutes=1)
self.last_update: datetime = dt_util.utcnow()
self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
self.log = self.config.options.get(WINDY_LOGGER_ENABLED)
self.invalid_response_count = 0
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
def verify_windy_response( # pylint: disable=useless-return
self,
response: str,
) -> WindyNotInserted | WindySuccess | WindyApiKeyError | None:
# Lets check if Windy server is responding right.
# Otherwise, try WINDY_MAX_RETRIES times and then disable resending.
self.invalid_response_count: int = 0
@property
def enabled(self) -> bool:
"""Whether forwarding is currently on, read live from the options.
Toggling this option does not reload the entry (see `update_listener`), so a
cached copy would leave the diagnostics sensor reporting a stale value until
the next push - or forever, since a disabled forwarder is never called again.
"""
return checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
# Refactored responses verification.
#
# We now comply to API at https://stations.windy.com/api-reference
def verify_windy_response(self, response: ClientResponse):
"""Verify answer form Windy."""
if self.log:
_LOGGER.info("Windy response raw response: %s", response)
if self.log and response:
# 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 "NOTICE" in response:
raise WindyNotInserted
if "SUCCESS" in response:
if response.status == 200:
raise WindySuccess
if "Invalid API key" in response:
raise WindyApiKeyError
if response.status == 400:
raise WindyNotInserted
if "Unauthorized" in response:
raise WindyApiKeyError
if response.status == 401:
raise WindyPasswordMissing
return None
if response.status == 409:
raise WindyDuplicatePayloadDetected
async def push_data_to_windy(self, data, wslink: bool = False):
if response.status == 429:
raise WindyRateLimitExceeded
def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]:
"""Convert WSLink API data to Windy API data protocol."""
if "t1ws" in indata:
indata["wind"] = indata.pop("t1ws")
if "t1wgust" in indata:
indata["gust"] = indata.pop("t1wgust")
if "t1wdir" in indata:
indata["winddir"] = indata.pop("t1wdir")
if "t1hum" in indata:
indata["humidity"] = indata.pop("t1hum")
if "t1dew" in indata:
indata["dewpoint"] = indata.pop("t1dew")
if "t1tem" in indata:
indata["temp"] = indata.pop("t1tem")
if "rbar" in indata:
indata["mbar"] = indata.pop("rbar")
if "t1rainhr" in indata:
indata["precip"] = indata.pop("t1rainhr")
if "t1uvi" in indata:
indata["uv"] = indata.pop("t1uvi")
if "t1solrad" in indata:
indata["solarradiation"] = indata.pop("t1solrad")
return indata
async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending.
`enabled` reads the option back, so persisting it here is what actually turns
forwarding off.
`last_status` is deliberately left to the caller so the diagnostics sensor keeps
the specific reason (`config_error`, `auth_error`, ...) instead of a generic one.
"""
self.last_error = reason
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to false.")
persistent_notification.async_create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
"""Pushes weather data do Windy stations.
Interval is 5 minutes, otherwise Windy would not accepts data.
@ -98,6 +183,28 @@ class WindyPush:
from station. But we need to do some clean up.
"""
# First check if we have valid credentials, before any data manipulation.
self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None
# An empty string is still a `str`, so `checked` alone would let unconfigured
# credentials through and send a request that can only ever be rejected.
if not (windy_station_id := checked_or(self.config.options.get(WINDY_STATION_ID), str, "")):
_LOGGER.error("Windy station ID is not provided! Check your configuration.")
self.last_status = "config_error"
await self._disable_windy(
"Windy station ID is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if not (windy_station_pw := checked_or(self.config.options.get(WINDY_STATION_PW), str, "")):
_LOGGER.error("Windy station password is missing! Check your configuration.")
self.last_status = "config_error"
await self._disable_windy(
"Windy password is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if self.log:
_LOGGER.info(
"Windy last update = %s, next update at: %s",
@ -105,97 +212,128 @@ class WindyPush:
str(self.next_update),
)
if self.next_update > datetime.now():
if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local"
return False
# Reserve the next send window now (before the await below) so a concurrent
# webhook does not also pass the rate-limit check and double-send.
self.next_update = dt_util.utcnow() + timed(minutes=5)
purged_data = data.copy()
for purge in PURGE_DATA:
if purge in purged_data:
purged_data.pop(purge)
_ = purged_data.pop(purge)
if wslink:
# WSLink -> Windy params
if "t1ws" in purged_data:
purged_data["wind"] = purged_data.pop("t1ws")
if "t1wgust" in purged_data:
purged_data["gust"] = purged_data.pop("t1wgust")
if "t1wdir" in purged_data:
purged_data["winddir"] = purged_data.pop("t1wdir")
if "t1hum" in purged_data:
purged_data["humidity"] = purged_data.pop("t1hum")
if "t1dew" in purged_data:
purged_data["dewpoint"] = purged_data.pop("t1dew")
if "t1tem" in purged_data:
purged_data["temp"] = purged_data.pop("t1tem")
if "rbar" in purged_data:
purged_data["mbar"] = purged_data.pop("rbar")
if "t1rainhr" in purged_data:
purged_data["precip"] = purged_data.pop("t1rainhr")
if "t1uvi" in purged_data:
purged_data["uv"] = purged_data.pop("t1uvi")
if "t1solrad" in purged_data:
purged_data["solarradiation"] = purged_data.pop("t1solrad")
windy_station_id = (self.config.options.get(WINDY_STATION_ID) or "").strip()
windy_station_pw = (self.config.options.get(WINDY_STATION_PW) or "").strip()
# Both values are required. Options can sometimes be None, so normalize to
# empty string and strip whitespace before validating.
if not windy_station_id or not windy_station_pw:
_LOGGER.error(
"Windy ID or PASSWORD is not set correctly. Please reconfigure your WINDY resend credentials. Disabling WINDY resend for now!"
)
persistent_notification.async_create(
self.hass,
"Your Windy credentials are not set correctly. Disabling Windy resending for now. Update Windy options and enable reseding.",
"Windy resending disabled.",
)
await update_options(self.hass, self.config, WINDY_ENABLED, False)
return False
purged_data = self._covert_wslink_to_pws(purged_data)
request_url = f"{WINDY_URL}"
purged_data["id"] = windy_station_id
purged_data["time"] = "now"
headers = {"Authorization": f"Bearer {windy_station_pw}"}
if self.log:
_LOGGER.info("Dataset for windy: %s", purged_data)
session = async_get_clientsession(self.hass, verify_ssl=False)
# Mask the station id (a credential) before logging the dataset.
_LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"})
session = async_get_clientsession(self.hass)
try:
async with session.get(request_url, params=purged_data, headers=headers) as resp:
status = await resp.text()
try:
self.verify_windy_response(status)
self.verify_windy_response(response=resp)
except WindyNotInserted:
# log despite of settings
_LOGGER.error(WINDY_NOT_INSERTED)
self.last_status = "not_inserted"
self.last_error = WINDY_NOT_INSERTED
self.invalid_response_count += 1
except WindyApiKeyError:
# log despite of settings
_LOGGER.error(
"%s Max retries before disable resend function: %s",
WINDY_NOT_INSERTED,
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
except WindyPasswordMissing:
# log despite of settings
self.last_status = "auth_error"
self.last_error = WINDY_INVALID_KEY
_LOGGER.critical(WINDY_INVALID_KEY)
await update_options(self.hass, self.config, WINDY_ENABLED, False)
await self._disable_windy(
reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings."
)
except WindyDuplicatePayloadDetected:
self.last_status = "duplicate"
self.last_error = "Duplicate payload detected by Windy server."
_LOGGER.critical(
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1
except WindyRateLimitExceeded:
# log despite of settings
self.last_status = "rate_limited_remote"
self.last_error = "Windy rate limit exceeded."
_LOGGER.critical(
"Windy responded with WindyRateLimitExceeded, this should happend only on restarting Home Assistant when we lost track of last send time. Pause resend for next 5 minutes."
)
self.next_update = dt_util.utcnow() + timedelta(minutes=5)
except WindySuccess:
# reset invalid_response_count
self.invalid_response_count = 0
self.last_status = "ok"
self.last_error = None
if self.log:
_LOGGER.info(WINDY_SUCCESS)
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:
_LOGGER.debug(
"Unexpected response from Windy. Max retries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
finally:
if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(
"Invalid response from Windy %s times. Disabling resend option.",
WINDY_MAX_RETRIES,
)
await self._disable_windy(
reason=f"Unable to send data to Windy ({WINDY_MAX_RETRIES} times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
)
except ClientError as ex:
_LOGGER.critical("Invalid response from Windy: %s", str(ex))
# TimeoutError is not a ClientError: an `async_timeout`/`asyncio` timeout would
# otherwise escape into the webhook handler and answer the station with HTTP 500,
# even though the measured data was already stored.
except (ClientError, TimeoutError) as ex:
self.last_status = "client_error"
# Store only the exception class - last_error is surfaced via entity
# attributes; str(ex) could embed the request URL.
self.last_error = type(ex).__name__
_LOGGER.critical(
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s",
str(ex),
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1
if self.invalid_response_count > 3:
if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED)
await update_options(self.hass, self.config, WINDY_ENABLED, False)
self.last_update = datetime.now()
await self._disable_windy(
reason=f"Invalid response from Windy {WINDY_MAX_RETRIES} times. Disabling resending option."
)
self.last_update = dt_util.utcnow()
self.next_update = self.last_update + timed(minutes=5)
if self.log:
_LOGGER.info("Next update: %s", str(self.next_update))
return None
return True

View File

@ -0,0 +1,122 @@
"""Every battery key must be classified exactly once.
The station reports batteries in two incompatible ways:
- 0/1 (low/normal) -> ``BATTERY_LIST`` -> binary sensors
- 0-5 level (5 = full) -> ``BATTERY_NON_BINARY`` -> percentage sensors
Both entity description sets are generated from those tuples, so a key appearing in
both would produce a binary sensor *and* a percentage sensor for the same battery -
and the binary one would collapse levels 1-5 into a single "not low", silently
discarding the reading. A key appearing in neither simply never gets an entity.
The WSLink API has more of both kinds still to implement (`t5lsbat`, `t6c1-7bat` are
0/1; `t8bat`, `t10bat`, `t11bat` are 0-5), so these tests exist to fail the moment a
new `*_BATTERY` constant is added without deciding which kind it is.
"""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import PERCENTAGE
from custom_components.sws12500 import const
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS, BATTERY_LEVEL_SENSORS
from custom_components.sws12500.const import BATTERY_LIST, BATTERY_NON_BINARY
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
def _all_battery_constants() -> set[str]:
"""Every `*_BATTERY` value defined in const.py."""
return {value for name, value in vars(const).items() if name.endswith("_BATTERY") and isinstance(value, str)}
def test_classifications_are_disjoint() -> None:
"""A key in both tuples would get two conflicting entities."""
overlap = set(BATTERY_LIST) & set(BATTERY_NON_BINARY)
assert not overlap, f"battery keys classified as both binary and 0-5: {sorted(overlap)}"
def test_every_battery_constant_is_classified() -> None:
"""Adding a `*_BATTERY` constant must force a decision about its kind."""
classified = set(BATTERY_LIST) | set(BATTERY_NON_BINARY)
unclassified = _all_battery_constants() - classified
assert not unclassified, (
f"unclassified battery keys: {sorted(unclassified)} - add each to BATTERY_LIST "
"(reported as 0/1) or BATTERY_NON_BINARY (reported as a 0-5 level) in const.py"
)
def test_classification_covers_only_real_constants() -> None:
"""Neither tuple may list a key that is not an actual battery constant."""
stray = (set(BATTERY_LIST) | set(BATTERY_NON_BINARY)) - _all_battery_constants()
assert not stray, f"not `*_BATTERY` constants: {sorted(stray)}"
# ---------------------------------------------------------------------------
# The description sets are generated from the tuples, not hand-maintained
# ---------------------------------------------------------------------------
def test_binary_descriptions_generated_from_battery_list() -> None:
keys = [desc.key for desc in BATTERY_BINARY_SENSORS]
assert keys == list(BATTERY_LIST)
assert all(desc.device_class is BinarySensorDeviceClass.BATTERY for desc in BATTERY_BINARY_SENSORS)
def test_level_descriptions_generated_from_battery_non_binary() -> None:
keys = [desc.key for desc in BATTERY_LEVEL_SENSORS]
assert keys == list(BATTERY_NON_BINARY)
for desc in BATTERY_LEVEL_SENSORS:
assert desc.device_class is SensorDeviceClass.BATTERY
assert desc.native_unit_of_measurement == PERCENTAGE
assert desc.value_fn is not None
# 5 is full; the raw level must be rendered as a percentage.
assert desc.value_fn("5") == 100
assert desc.value_fn("0") == 0
def test_level_batteries_are_not_also_plain_sensors() -> None:
"""A 0-5 battery must not be hand-defined on top of its generated description.
`BATTERY_LEVEL_SENSORS` is spliced into `SENSOR_TYPES_WSLINK`, so one occurrence
there is the generated one and is expected; `SENSOR_TYPES_WEATHER_API` does not
splice them in, so zero is expected there. "At most once" is therefore the
invariant that holds for both - a second entry is a duplicate entity.
Complete *absence* of a description is caught by
`test_level_descriptions_generated_from_battery_non_binary`, which pins the
generated set to `BATTERY_NON_BINARY` exactly.
"""
for sensor_types in (SENSOR_TYPES_WSLINK, SENSOR_TYPES_WEATHER_API):
keys = [desc.key for desc in sensor_types]
for key in BATTERY_NON_BINARY:
assert keys.count(key) <= 1, f"{key} defined more than once"
def test_level_batteries_reach_the_sensor_platform() -> None:
"""Every 0-5 battery must actually be spliced into the WSLink platform set.
The generated tuple existing is not enough - if the `*BATTERY_LEVEL_SENSORS`
splice were dropped, the classification tests would still pass while the
percentage entities silently disappeared.
"""
keys = [desc.key for desc in SENSOR_TYPES_WSLINK]
for key in BATTERY_NON_BINARY:
assert key in keys, f"{key} is classified as a 0-5 battery but has no sensor description"
def test_binary_batteries_are_not_plain_sensors_too() -> None:
"""0/1 batteries belong to the binary platform only.
The legacy plain-sensor variants are deprecated (see `legacy.py`) but still
present; they must at least not be duplicated.
"""
for sensor_types in (SENSOR_TYPES_WSLINK, SENSOR_TYPES_WEATHER_API):
keys = [desc.key for desc in sensor_types]
for key in BATTERY_LIST:
assert keys.count(key) <= 1, f"{key} defined more than once"

View File

@ -0,0 +1,225 @@
"""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 {}
# device_info reads coordinator.config for the shared device model.
self.config = SimpleNamespace(options={})
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"] == "PWS" # no ecowitt/wslink in stub options -> PWS
assert info["identifiers"] == {(DOMAIN,)}
def test_add_new_binary_sensors_noop_when_runtime_data_missing():
"""add_new_binary_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_binary_sensors(None, entry, keys=["outside_battery"]) # must not raise

652
tests/test_config_flow.py Normal file
View File

@ -0,0 +1,652 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
INVALID_CREDENTIALS,
LEGACY_ENABLED,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WSLINK,
WSLINK_ADDON_PORT,
)
from homeassistant import config_entries
from homeassistant.helpers.network import NoURLAvailableError
@pytest.mark.asyncio
async def test_config_flow_user_form_then_create_entry(
hass, enable_custom_integrations
) -> None:
"""Online HA: config flow shows form then creates entry and options."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
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",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "create_entry"
assert result2["title"] == DOMAIN
# 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
async def test_config_flow_user_invalid_credentials_api_id(
hass, enable_custom_integrations
) -> None:
"""API_ID in INVALID_CREDENTIALS -> error on API_ID."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
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],
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"][API_ID] == "valid_credentials_api"
@pytest.mark.asyncio
async def test_config_flow_user_invalid_credentials_api_key(
hass, enable_custom_integrations
) -> None:
"""API_KEY in INVALID_CREDENTIALS -> error on API_KEY."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
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",
API_KEY: INVALID_CREDENTIALS[0],
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"][API_KEY] == "valid_credentials_key"
@pytest.mark.parametrize("blank", ["", " "])
@pytest.mark.parametrize("field", [API_ID, API_KEY])
@pytest.mark.asyncio
async def test_config_flow_user_rejects_blank_credentials(
hass, enable_custom_integrations, field, blank
) -> None:
"""Blank PWS credentials must not create an entry.
`INVALID_CREDENTIALS` holds placeholder strings only, so an empty (or
whitespace-only) value used to pass validation. The entry was created but
`_validate_credentials` then rejected every incoming packet, leaving the
integration permanently without data.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
assert form["step_id"] == "pws"
user_input = {
API_ID: "ok_id",
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
}
user_input[field] = blank
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
expected = "valid_credentials_api" if field is API_ID else "valid_credentials_key"
assert result2["errors"][field] == expected
@pytest.mark.parametrize("blank", ["", " "])
@pytest.mark.parametrize("field", [API_ID, API_KEY])
@pytest.mark.asyncio
async def test_options_flow_basic_rejects_blank_credentials(
hass, enable_custom_integrations, field, blank
) -> None:
"""Same blank-credential rule applies when the legacy endpoint is re-enabled."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: False, ECOWITT_ENABLED: False},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "basic"}
)
assert form["step_id"] == "basic"
user_input = {
API_ID: "ok_id",
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
LEGACY_ENABLED: True,
}
user_input[field] = blank
result = await hass.config_entries.options.async_configure(
init["flow_id"], user_input=user_input
)
assert result["type"] == "form"
expected = "valid_credentials_api" if field is API_ID else "valid_credentials_key"
assert result["errors"][field] == expected
@pytest.mark.asyncio
async def test_config_flow_user_invalid_credentials_match(
hass, enable_custom_integrations
) -> None:
"""API_KEY == API_ID -> base error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
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",
API_KEY: "same",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"]["base"] == "valid_credentials_match"
@pytest.mark.asyncio
async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
"""Options flow shows menu with expected steps."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
entry.add_to_hass(hass)
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", "wslink_port_setup", "ecowitt", "windy", "pocasi"}
@pytest.mark.asyncio
async def test_options_flow_basic_validation_and_create_entry(
hass, enable_custom_integrations
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
API_ID: "old",
API_KEY: "oldkey",
WSLINK: False,
DEV_DBG: False,
},
)
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": "basic"}
)
assert form["type"] == "form"
assert form["step_id"] == "basic"
# Cover invalid API_ID branch in options flow basic step.
bad_api_id = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: INVALID_CREDENTIALS[0],
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
},
)
assert bad_api_id["type"] == "form"
assert bad_api_id["step_id"] == "basic"
assert bad_api_id["errors"][API_ID] == "valid_credentials_api"
# Cover invalid API_KEY branch in options flow basic step.
bad_api_key = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "ok_id",
API_KEY: INVALID_CREDENTIALS[0],
WSLINK: False,
DEV_DBG: False,
},
)
assert bad_api_key["type"] == "form"
assert bad_api_key["step_id"] == "basic"
assert bad_api_key["errors"][API_KEY] == "valid_credentials_key"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={API_ID: "same", API_KEY: "same", WSLINK: False, DEV_DBG: False},
)
assert bad["type"] == "form"
assert bad["step_id"] == "basic"
assert bad["errors"]["base"] == "valid_credentials_match"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={API_ID: "new", API_KEY: "newkey", WSLINK: True, DEV_DBG: True},
)
assert good["type"] == "create_entry"
assert good["title"] == DOMAIN
assert good["data"][API_ID] == "new"
assert good["data"][API_KEY] == "newkey"
assert good["data"][WSLINK] is True
assert good["data"][DEV_DBG] is True
@pytest.mark.asyncio
async def test_options_flow_windy_requires_keys_when_enabled(
hass, enable_custom_integrations
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
WINDY_ENABLED: False,
WINDY_LOGGER_ENABLED: False,
WINDY_STATION_ID: "",
WINDY_STATION_PW: "",
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "windy"}
)
assert form["type"] == "form"
assert form["step_id"] == "windy"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
WINDY_ENABLED: True,
WINDY_LOGGER_ENABLED: False,
WINDY_STATION_ID: "",
WINDY_STATION_PW: "",
},
)
assert bad["type"] == "form"
assert bad["step_id"] == "windy"
assert bad["errors"][WINDY_STATION_ID] == "windy_key_required"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
WINDY_ENABLED: True,
WINDY_LOGGER_ENABLED: True,
WINDY_STATION_ID: "sid",
WINDY_STATION_PW: "spw",
},
)
assert good["type"] == "create_entry"
assert good["data"][WINDY_ENABLED] is True
@pytest.mark.asyncio
async def test_options_flow_pocasi_validation_minimum_interval_and_required_keys(
hass,
enable_custom_integrations,
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
POCASI_CZ_API_ID: "",
POCASI_CZ_API_KEY: "",
POCASI_CZ_ENABLED: False,
POCASI_CZ_LOGGER_ENABLED: False,
POCASI_CZ_SEND_INTERVAL: 30,
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "pocasi"}
)
assert form["type"] == "form"
assert form["step_id"] == "pocasi"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
POCASI_CZ_API_ID: "",
POCASI_CZ_API_KEY: "",
POCASI_CZ_ENABLED: True,
POCASI_CZ_LOGGER_ENABLED: False,
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM - 1,
},
)
assert bad["type"] == "form"
assert bad["step_id"] == "pocasi"
assert bad["errors"][POCASI_CZ_SEND_INTERVAL] == "pocasi_send_minimum"
assert bad["errors"][POCASI_CZ_API_ID] == "pocasi_id_required"
assert bad["errors"][POCASI_CZ_API_KEY] == "pocasi_key_required"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
POCASI_CZ_API_ID: "pid",
POCASI_CZ_API_KEY: "pkey",
POCASI_CZ_ENABLED: True,
POCASI_CZ_LOGGER_ENABLED: True,
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM,
},
)
assert good["type"] == "create_entry"
assert good["data"][POCASI_CZ_ENABLED] is True
@pytest.mark.asyncio
async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_default(
hass,
enable_custom_integrations,
) -> None:
"""Online HA: ecowitt step uses get_url() placeholders and secrets token when webhook id missing."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
ECOWITT_WEBHOOK_ID: "",
ECOWITT_ENABLED: False,
# Ecowitt can only be turned on when the legacy endpoint is off.
LEGACY_ENABLED: False,
},
)
entry.add_to_hass(hass)
# A URL without a host degrades to the "UNKNOWN" placeholder rather than raising.
# (`yarl.URL.host` is a read-only cached property, so the fallback has to build a
# new value instead of assigning to it.) This runs in its own flow because showing
# the form advances past the menu.
hostless_init = await hass.config_entries.options.async_init(entry.entry_id)
assert hostless_init["type"] == "menu"
with patch(
"custom_components.sws12500.config_flow.get_url",
return_value="http://",
):
hostless = await hass.config_entries.options.async_configure(
hostless_init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
assert hostless["type"] == "form"
assert (hostless.get("description_placeholders") or {})["url"] == "UNKNOWN"
# A normal URL fills real placeholders and completes the flow.
init = await hass.config_entries.options.async_init(entry.entry_id)
assert init["type"] == "menu"
with patch(
"custom_components.sws12500.config_flow.get_url",
return_value="http://example.local:8123",
):
form = await hass.config_entries.options.async_configure(
init["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["port"] == "8123"
assert placeholders["webhook_id"] # generated
done = await hass.config_entries.options.async_configure(
init["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
@pytest.mark.asyncio
async def test_options_flow_ecowitt_survives_no_url_available(
hass, enable_custom_integrations
) -> None:
"""`get_url` raising must not abort the Ecowitt options step.
The host/port are shown as setup instructions only. HA can fail to resolve any
of its own URLs (no internal/external URL configured), and letting that escape
made the Ecowitt setup unreachable.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={ECOWITT_WEBHOOK_ID: "", ECOWITT_ENABLED: False, LEGACY_ENABLED: False},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
with patch(
"custom_components.sws12500.config_flow.get_url",
side_effect=NoURLAvailableError,
):
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
assert form["type"] == "form"
placeholders = form.get("description_placeholders") or {}
assert placeholders["url"] == "UNKNOWN"
assert placeholders["port"] == "UNKNOWN"
assert placeholders["webhook_id"]
@pytest.mark.asyncio
async def test_config_flow_ecowitt_survives_no_url_available(
hass, enable_custom_integrations
) -> None:
"""The initial Ecowitt step stays usable when HA cannot resolve its own URL."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"custom_components.sws12500.config_flow.get_url",
side_effect=NoURLAvailableError,
):
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "ecowitt"}
)
assert form["type"] == "form"
placeholders = form.get("description_placeholders") or {}
assert placeholders["url"] == "UNKNOWN"
assert placeholders["port"] == "UNKNOWN"
# The step is still completable without a resolvable URL.
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
@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
@pytest.mark.asyncio
async def test_options_flow_does_not_roll_back_concurrent_autodiscovery(
hass,
enable_custom_integrations,
) -> None:
"""Auto-discovery that lands while the dialog is open must survive the submit.
The options flow snapshots the entry when a step opens, but the webhook handler
appends to SENSORS_TO_LOAD independently. Writing back the snapshot would silently
drop any sensor discovered in between.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
API_ID: "station",
API_KEY: "secret",
LEGACY_ENABLED: True,
SENSORS_TO_LOAD: ["outside_temp"],
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
# The station starts reporting a new field while the form is on screen.
hass.config_entries.async_update_entry(
entry,
options={**entry.options, SENSORS_TO_LOAD: ["outside_temp", "wind_gust"]},
)
done = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "station",
API_KEY: "secret",
WSLINK: False,
LEGACY_ENABLED: True,
},
)
assert done["type"] == "create_entry"
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]

234
tests/test_conflicts.py Normal file
View File

@ -0,0 +1,234 @@
"""Legacy (PWS/WSLink) and Ecowitt must never run at the same time.
Both endpoints remap onto the same internal sensor keys and push through the same
coordinator, so running both mixes measurement units, blanks the keys the other protocol
does not report, and feeds one entity from two sources.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.conflicts import (
ERROR_MUTUALLY_EXCLUSIVE,
effective_protocols,
protocols_conflict,
update_protocol_conflict_issue,
)
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
LEGACY_ENABLED,
WSLINK,
)
from homeassistant.helpers import issue_registry as ir
def _entry(**options: Any) -> Any:
return SimpleNamespace(options=options, entry_id="entry123")
# ---------------------------------------------------------------------------
# effective_protocols / protocols_conflict
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("options", "expected"),
[
# LEGACY_ENABLED defaults to True, ECOWITT_ENABLED to False.
({}, (True, False)),
({LEGACY_ENABLED: True, ECOWITT_ENABLED: False}, (True, False)),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}, (False, True)),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: False}, (False, False)),
# Conflict: legacy wins, Ecowitt is dropped rather than corrupting entities.
({LEGACY_ENABLED: True, ECOWITT_ENABLED: True}, (True, False)),
# A bare ECOWITT_ENABLED still conflicts, because legacy defaults to on.
({ECOWITT_ENABLED: True}, (True, False)),
],
)
def test_effective_protocols(options, expected) -> None:
assert effective_protocols(_entry(**options)) == expected
@pytest.mark.parametrize(
("options", "conflict"),
[
({}, False),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}, False),
({LEGACY_ENABLED: True, ECOWITT_ENABLED: True}, True),
({ECOWITT_ENABLED: True}, True),
],
)
def test_protocols_conflict(options, conflict) -> None:
assert protocols_conflict(_entry(**options)) is conflict
# ---------------------------------------------------------------------------
# Repairs issue
# ---------------------------------------------------------------------------
async def test_conflict_issue_created_and_cleared(hass) -> None:
"""The issue appears for a conflicting entry and disappears once resolved."""
registry = ir.async_get(hass)
entry = _entry(**{LEGACY_ENABLED: True, ECOWITT_ENABLED: True})
update_protocol_conflict_issue(hass, entry)
issue = registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}")
assert issue is not None
assert issue.severity == ir.IssueSeverity.ERROR
assert issue.translation_key == "protocol_conflict"
entry.options = {LEGACY_ENABLED: True, ECOWITT_ENABLED: False}
update_protocol_conflict_issue(hass, entry)
assert registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is None
async def test_no_issue_for_clean_entry(hass) -> None:
"""A single-protocol entry raises nothing."""
registry = ir.async_get(hass)
entry = _entry(**{LEGACY_ENABLED: False, ECOWITT_ENABLED: True})
update_protocol_conflict_issue(hass, entry)
assert registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is None
# ---------------------------------------------------------------------------
# Options flow guards
# ---------------------------------------------------------------------------
async def test_options_basic_rejects_enabling_legacy_while_ecowitt_on(hass, enable_custom_integrations) -> None:
"""Turning the legacy endpoint on while Ecowitt is active is refused."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: False, ECOWITT_ENABLED: True, ECOWITT_WEBHOOK_ID: "abc"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "station",
API_KEY: "secret",
WSLINK: False,
LEGACY_ENABLED: True,
},
)
assert result["type"] == "form"
assert result["errors"] == {"base": ERROR_MUTUALLY_EXCLUSIVE}
# Nothing was written.
assert entry.options[ECOWITT_ENABLED] is True
assert entry.options[LEGACY_ENABLED] is False
async def test_options_ecowitt_rejects_enabling_while_legacy_on(hass, enable_custom_integrations) -> None:
"""Turning Ecowitt on while the legacy endpoint is active is refused."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: True, ECOWITT_ENABLED: False, API_ID: "a", API_KEY: "b"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
webhook = (form.get("description_placeholders") or {})["webhook_id"]
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={ECOWITT_WEBHOOK_ID: webhook, ECOWITT_ENABLED: True},
)
assert result["type"] == "form"
assert result["step_id"] == "ecowitt"
assert result["errors"] == {"base": ERROR_MUTUALLY_EXCLUSIVE}
assert entry.options[ECOWITT_ENABLED] is False
async def test_options_ecowitt_allows_saving_while_disabled(hass, enable_custom_integrations) -> None:
"""The guard only blocks *enabling* it - editing the webhook id stays possible."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: True, ECOWITT_ENABLED: False, API_ID: "a", API_KEY: "b"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "ecowitt"})
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={ECOWITT_WEBHOOK_ID: "deadbeef", ECOWITT_ENABLED: False},
)
assert result["type"] == "create_entry"
assert result["data"][ECOWITT_WEBHOOK_ID] == "deadbeef"
# ---------------------------------------------------------------------------
# Route wiring
# ---------------------------------------------------------------------------
async def test_conflicting_entry_does_not_wire_ecowitt_route(hass, enable_custom_integrations) -> None:
"""A pre-existing both-enabled config must not ingest Ecowitt payloads.
Without this the Ecowitt handler keeps writing imperial values into the same
coordinator as the legacy endpoint, mislabelling units and blanking the keys the
other protocol reports.
"""
class _Router:
def add_get(self, path: str, handler: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="POST")
hass.http = SimpleNamespace(app=SimpleNamespace(router=_Router()))
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
LEGACY_ENABLED: True,
ECOWITT_ENABLED: True,
ECOWITT_WEBHOOK_ID: "abc",
API_ID: "a",
API_KEY: "b",
},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
routes = hass.data[DOMAIN]["routes"]
snapshot = routes.snapshot()
ecowitt = [r for r in snapshot.values() if r["path"].startswith("/weatherhub/")]
assert ecowitt, "ecowitt route should be registered"
assert all(not r["enabled"] for r in ecowitt), "ecowitt must be inactive while legacy is on"
# The legacy (WU) route is the one that stays live.
assert routes.path_enabled("/weatherstation/updateweatherstation.php")
# ... and the user is told about it.
assert ir.async_get(hass).async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is not None

23
tests/test_const.py Normal file
View File

@ -0,0 +1,23 @@
from custom_components.sws12500.const import (
DEFAULT_URL,
DOMAIN,
POCASI_CZ_MAX_RETRIES,
POCASI_CZ_UNEXPECTED,
WINDY_MAX_RETRIES,
WINDY_UNEXPECTED,
WINDY_URL,
WSLINK_URL,
)
def test_const_values():
assert DOMAIN == "sws12500"
assert DEFAULT_URL == "/weatherstation/updateweatherstation.php"
assert WSLINK_URL == "/data/upload.php"
assert WINDY_URL == "https://stations.windy.com/api/v2/observation/update"
def test_retry_counts_in_user_facing_messages():
"""The retry count in the notification texts must follow the constants."""
assert f"{WINDY_MAX_RETRIES} times" in WINDY_UNEXPECTED
assert f"{POCASI_CZ_MAX_RETRIES} times" in POCASI_CZ_UNEXPECTED

118
tests/test_data.py Normal file
View File

@ -0,0 +1,118 @@
"""Tests for the typed per-entry runtime data container."""
from __future__ import annotations
from types import SimpleNamespace
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, LEGACY_ENABLED, WSLINK
from custom_components.sws12500.data import (
SWSRuntimeData,
_station_model,
build_device_info,
)
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 == {}
# --------------------------------------------------------------------------- #
# _station_model / build_device_info (single shared device)
# --------------------------------------------------------------------------- #
def test_station_model_pws_when_no_flags():
"""No ecowitt / wslink flags -> the legacy PWS push station."""
entry = SimpleNamespace(options={})
assert _station_model(entry) == "PWS" # type: ignore[arg-type]
def test_station_model_wslink_when_flag_set():
"""The WSLINK flag (without ecowitt) yields the WSLink model."""
entry = SimpleNamespace(options={WSLINK: True})
assert _station_model(entry) == "WSLink" # type: ignore[arg-type]
def test_station_model_ecowitt_without_learned_model():
"""Ecowitt enabled but no model learned yet -> bare "Ecowitt".
Covers both the missing-runtime_data and the model-is-None paths.
"""
no_runtime = SimpleNamespace(options={ECOWITT_ENABLED: True, LEGACY_ENABLED: False})
assert _station_model(no_runtime) == "Ecowitt" # type: ignore[arg-type]
runtime_no_model = SimpleNamespace(
options={ECOWITT_ENABLED: True, LEGACY_ENABLED: False},
runtime_data=SimpleNamespace(ecowitt_model=None),
)
assert _station_model(runtime_no_model) == "Ecowitt" # type: ignore[arg-type]
def test_station_model_ecowitt_with_learned_model():
"""Ecowitt enabled with a learned model -> "Ecowitt <model>"."""
entry = SimpleNamespace(
options={ECOWITT_ENABLED: True, LEGACY_ENABLED: False},
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
)
assert _station_model(entry) == "Ecowitt GW1000" # type: ignore[arg-type]
def test_station_model_legacy_wins_over_ecowitt_on_conflict():
"""A stale both-enabled config reports the endpoint that is actually wired up.
`effective_protocols` resolves the conflict in favour of legacy, so the device model
must not advertise an Ecowitt station whose data is being ignored.
"""
entry = SimpleNamespace(
options={ECOWITT_ENABLED: True, LEGACY_ENABLED: True, WSLINK: True},
runtime_data=SimpleNamespace(ecowitt_model="WS3900"),
)
assert _station_model(entry) == "WSLink" # type: ignore[arg-type]
def test_build_device_info_shared_identity():
"""All entities share one device under the legacy ``{(DOMAIN,)}`` identifier."""
entry = SimpleNamespace(options={})
info = build_device_info(entry) # type: ignore[arg-type]
assert info["identifiers"] == {(DOMAIN,)}
assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza"
assert info["model"] == "PWS"

155
tests/test_diagnostics.py Normal file
View File

@ -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"] == {}

View File

@ -0,0 +1,451 @@
"""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, ECOWITT_ENABLED, LEGACY_ENABLED, REMAP_ECOWITT_COMPAT
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
# Default config stub for native entities: the integration shares a single device,
# so EcoWittNativeSensor only needs the entry to resolve the device model. A bare
# PWS config (no ecowitt/wslink flags) is enough for the non-device assertions.
_PWS_CONFIG = SimpleNamespace(options={})
# An ecowitt config that yields model "Ecowitt GW1000" for device-info assertions.
# An Ecowitt-only setup: the legacy endpoint must be off, otherwise the two protocols
# would feed the same entities (see conflicts.effective_protocols).
_ECOWITT_CONFIG = SimpleNamespace(
options={ECOWITT_ENABLED: True, LEGACY_ENABLED: False},
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
)
def _native(sensor: Any, config: Any = _PWS_CONFIG) -> EcoWittNativeSensor:
"""Build a native sensor with a default (PWS) config stub."""
return EcoWittNativeSensor(sensor, config)
# 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 = _native(sensor, _ECOWITT_CONFIG)
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
# Native sensors join the single shared integration device; the running station
# type is reflected in the model, not in a separate Ecowitt device.
info = entity._attr_device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["model"] == "Ecowitt GW1000"
assert info["identifiers"] == {(DOMAIN,)}
assert info["manufacturer"] == "Schizza"
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 = _native(sensor, _ECOWITT_CONFIG)
# 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 and points at the shared device.
info = entity._attr_device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["identifiers"] == {(DOMAIN,)}
def test_native_sensor_device_model_follows_config_not_station() -> None:
"""Device model comes from the entry config, independent of the sensor station.
A native sensor whose ``station`` is missing still lands on the shared device,
and the model reflects the configured station type (here a bare PWS config).
"""
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25)
sensor.station = None # type: ignore[assignment]
entity = _native(sensor) # default PWS config
info = entity._attr_device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["model"] == "PWS"
assert info["identifiers"] == {(DOMAIN,)}
def test_native_value_returns_value() -> None:
"""native_value returns the underlying sensor value."""
sensor = _make_sensor(value=42.0)
entity = _native(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 = _native(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 = _native(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 = _native(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 = _native(sensor)
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
entity._handle_update()
entity.async_write_ha_state.assert_called_once_with()
@pytest.mark.asyncio
async def test_set_add_entities_flushes_pending_unmapped_sensors() -> None:
"""Unmapped sensors parsed before the callback was set are flushed on set_add_entities."""
bridge = _make_bridge()
# Payload arrives before the platform is ready (no callback): native sensors are
# parsed by aioecowitt but skipped by _on_new_sensor.
await bridge.process_payload(dict(_PAYLOAD))
assert bridge.unmapped_sensor # e.g. pm25_ch1 / co2 parsed but not yet created
created: list[Any] = []
bridge.set_add_entities(lambda entities: created.extend(entities))
# The previously-skipped unmapped sensors are created now.
assert created
assert all(isinstance(e, EcoWittNativeSensor) for e in created)
def test_on_new_sensor_respects_cap() -> None:
"""Native entity creation is capped to bound entity-registry growth."""
from custom_components.sws12500.ecowitt import MAX_NATIVE_ECOWITT_SENSORS
bridge = _make_bridge()
cb = MagicMock()
bridge.set_add_entities(cb)
# Pretend we already created the maximum number of native sensors.
bridge._know_native_keys = {f"k{i}" for i in range(MAX_NATIVE_ECOWITT_SENSORS)}
bridge._on_new_sensor(_make_sensor(key="pm25_ch1"))
cb.assert_not_called()
def test_on_new_sensor_skips_unit_twin_of_mapped() -> None:
"""The metric twin of a mapped (imperial) sensor is not created as a native entity."""
bridge = _make_bridge()
cb = MagicMock()
bridge.set_add_entities(cb)
# tempc is the °C twin of tempf, which IS mapped into the SWS pipeline.
bridge._on_new_sensor(
_make_sensor(key="tempc", name="Outdoor Temperature", stype=EcoWittSensorTypes.TEMPERATURE_C)
)
cb.assert_not_called()
assert "tempc" not in bridge._know_native_keys
def test_on_new_sensor_skips_already_created_twin() -> None:
"""For an unmapped metric/imperial pair only the first-seen unit is created."""
bridge = _make_bridge()
created: list[Any] = []
bridge.set_add_entities(lambda ents: created.extend(ents))
bridge._on_new_sensor(_make_sensor(key="rainratein", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_INCHES))
bridge._on_new_sensor(_make_sensor(key="rainratemm", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_MM))
keys = [e._ecowitt_sensor.key for e in created]
assert keys == ["rainratein"] # the twin rainratemm is deduplicated
def test_native_sensor_translation_key_for_curated() -> None:
ent = _native(
_make_sensor(key="baromabsin", name="Absolute Pressure", stype=EcoWittSensorTypes.PRESSURE_INHG)
)
assert ent._attr_translation_key == "ecowitt_absolute_pressure"
def test_native_sensor_name_fallback_for_unknown() -> None:
ent = _native(
_make_sensor(key="air_ch1", name="Air Gap 1", stype=EcoWittSensorTypes.INTERNAL)
)
assert ent._attr_name == "Air Gap 1"
assert getattr(ent, "_attr_translation_key", None) is None

897
tests/test_health.py Normal file
View File

@ -0,0 +1,897 @@
"""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 (
API_ID,
API_KEY,
DEFAULT_URL,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_URL_PREFIX,
HEALTH_URL,
LEGACY_ENABLED,
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
from homeassistant.config_entries import ConfigEntryState
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
class _ProbeRouterStub:
"""Minimal aiohttp router stub for the end-to-end setup test."""
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
return SimpleNamespace(method="POST")
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_configured_protocol() -> None:
# Legacy enabled (default) -> wu / wslink based on the WSLINK flag.
assert hc._configured_protocol(_make_entry()) == "wu"
assert hc._configured_protocol(_make_entry({WSLINK: True})) == "wslink"
# Ecowitt-only (legacy off, ecowitt on) -> ecowitt.
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True})) == "ecowitt"
# Nothing configured -> wu fallback.
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: 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"
def test_default_health_data_ecowitt() -> None:
data = hc._default_health_data(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}))
assert data["configured_protocol"] == "ecowitt"
assert data["active_protocol"] == "ecowitt"
assert data["integration_status"] == "online_ecowitt"
# ---------------------------------------------------------------------------
# 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"
def test_refresh_summary_online_ecowitt(hass, entry) -> None:
# Ecowitt ingress is a valid protocol: active_protocol must track it (not fall back
# to the legacy "wu"), and it must not be flagged as a WU/WSLink mismatch.
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wu"
data["last_ingress"] = {"protocol": "ecowitt", "accepted": True, "reason": "accepted"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "online_ecowitt"
assert data["active_protocol"] == "ecowitt"
# ---------------------------------------------------------------------------
# _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.
device_info reads coordinator.config for the shared device model.
"""
return SimpleNamespace(data=data, config=SimpleNamespace(options={}))
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_strips_internal_fields() -> None:
data = {
"integration_status": "online_wu",
"addon": {
"online": True,
"name": "wslink_proxy",
"home_assistant_source_ip": "1.2.3.4",
"health_url": "https://1.2.3.4:443/healthz",
"info_url": "https://1.2.3.4:443/status/internal",
"home_assistant_url": "http://ha:8123",
"raw_status": {"secret": "x"},
},
}
coordinator = _stub_coordinator(data)
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
attrs = sensor.extra_state_attributes
assert attrs is not None
# Non-sensitive fields pass through.
assert attrs["integration_status"] == "online_wu"
assert attrs["addon"]["online"] is True
assert attrs["addon"]["name"] == "wslink_proxy"
# Internal network details are stripped from the (any-user-readable) attributes.
for field in ("home_assistant_source_ip", "health_url", "info_url", "home_assistant_url", "raw_status"):
assert field not in attrs["addon"]
# The source snapshot is not mutated (deepcopy).
assert "raw_status" in data["addon"]
def test_sensor_extra_state_attributes_none_data() -> None:
coordinator = _stub_coordinator(None)
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
assert sensor.extra_state_attributes is None
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_public_health_snapshot_handles_missing_or_nondict_addon() -> None:
# addon missing -> returned as-is (copy).
assert hc.public_health_snapshot({"integration_status": "x"}) == {"integration_status": "x"}
# addon not a dict -> left untouched.
assert hc.public_health_snapshot({"addon": "nope"}) == {"addon": "nope"}
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"] == "PWS" # no ecowitt/wslink in stub options -> PWS
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
# ---------------------------------------------------------------------------
# Regression: the optional add-on probe must never fail the config entry
#
# `async_config_entry_first_refresh` converts *any* exception escaping
# `_async_update_data` into `ConfigEntryNotReady`. Since the WSLink proxy add-on is
# optional, an unreachable/misbehaving add-on used to take the whole integration -
# including the station webhook - down with it.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"failure",
[
pytest.param(TimeoutError(), id="asyncio-timeout"),
pytest.param(ClientConnectionError("refused"), id="connection-refused"),
pytest.param(aiohttp.ClientResponseError(None, (), status=500), id="client-response-error"),
pytest.param(aiohttp.ClientError("generic"), id="generic-client-error"),
pytest.param(OSError("raw socket"), id="raw-oserror"),
],
)
async def test_probe_failure_never_raises(hass, monkeypatch, failure) -> None:
"""Any expected probe failure is recorded as offline, not raised."""
entry = _make_entry({WSLINK_ADDON_PORT: 8443})
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
_patch_network(monkeypatch, _FakeSession({"/healthz": failure}))
data = await coordinator._async_update_data()
assert data["addon"]["online"] is False
assert data["addon"]["raw_status"] is None
# Metadata resolved before the failing request is still reported.
assert data["addon"]["health_url"] == "https://1.2.3.4:8443/healthz"
assert data["addon"]["home_assistant_source_ip"] == "1.2.3.4"
async def test_probe_unexpected_error_never_raises(hass, monkeypatch, caplog) -> None:
"""Even an unforeseen error is contained (and logged) rather than propagated."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
_patch_network(monkeypatch, _FakeSession({"/healthz": RuntimeError("boom")}))
data = await coordinator._async_update_data()
assert data["addon"]["online"] is False
assert "Unexpected error while probing" in caplog.text
async def test_probe_survives_missing_ha_url(hass, monkeypatch) -> None:
"""`get_url` raising NoURLAvailableError must not skip the reachability probe."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
session = _FakeSession({"/healthz": _FakeResponse(200), "/status/internal": _FakeResponse(404)})
_patch_network(monkeypatch, session)
def _no_url(_hass):
raise hc.NoURLAvailableError
monkeypatch.setattr(hc, "get_url", _no_url)
data = await coordinator._async_update_data()
# The add-on was still probed successfully despite HA not knowing its own URL.
assert data["addon"]["online"] is True
async def test_probe_non_dict_status_body(hass, monkeypatch) -> None:
"""A non-dict /status/internal body must not blow up metadata parsing."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
session = _FakeSession(
{
"/healthz": _FakeResponse(200),
"/status/internal": _FakeResponse(200, json_data=["not", "a", "dict"]),
}
)
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
assert data["addon"]["online"] is True
assert data["addon"]["raw_status"] is None
assert data["addon"]["name"] is None
async def test_setup_succeeds_when_addon_probe_fails(hass, enable_custom_integrations, monkeypatch) -> None:
"""End to end: a dead add-on must not put the config entry into SETUP_RETRY."""
hass.http = SimpleNamespace(app=SimpleNamespace(router=_ProbeRouterStub()))
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass)
async def _timeout(_self, _addon):
raise TimeoutError
monkeypatch.setattr(HealthCoordinator, "_probe_addon", _timeout)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.runtime_data.health_coordinator.data["addon"]["online"] is False
async def test_probe_healthz_non_200_skips_info_call(hass, monkeypatch) -> None:
"""Add-on reachable but unhealthy: report offline and skip the info endpoint."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
session = _FakeSession(
{
"/healthz": _FakeResponse(503),
"/status/internal": _FakeResponse(200, json_data={"addon": "should-not-be-read"}),
}
)
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
assert data["addon"]["online"] is False
assert data["addon"]["raw_status"] is None
assert data["addon"]["name"] is None

242
tests/test_init.py Normal file
View File

@ -0,0 +1,242 @@
"""Integration init tests using Home Assistant pytest fixtures.
These tests rely on `pytest-homeassistant-custom-component` to provide:
- `hass` fixture (running Home Assistant instance)
- `MockConfigEntry` helper for config entries
They validate that the integration can set up a config entry and that the
coordinator is created and stored in `hass.data`.
Note:
This integration registers aiohttp routes via `hass.http.app.router`. In this
test environment, `hass.http` may not be set up, so we patch route registration
to keep these tests focused on setup logic.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import (
CONFIG_ENTRY_VERSION,
WeatherDataUpdateCoordinator,
async_migrate_entry,
async_setup_entry,
)
from custom_components.sws12500.config_flow import ConfigFlowHandler
from custom_components.sws12500.const import (
DOMAIN,
POCASI_CZ_ENABLED,
POCASI_CZ_ENABLED_LEGACY,
WINDY_ENABLED,
)
from custom_components.sws12500.data import SWSRuntimeData
from homeassistant.util import dt as dt_util
@pytest.fixture
def config_entry() -> MockConfigEntry:
"""Create a minimal config entry for the integration."""
return MockConfigEntry(domain=DOMAIN, data={}, options={})
async def test_async_setup_entry_creates_runtime_state(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""Setting up a config entry should succeed and populate hass.data."""
config_entry.add_to_hass(hass)
# `async_setup_entry` calls `register_path`, which needs `hass.http`.
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
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(
hass.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
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 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(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""The integration should forward entry setups to the sensor platform."""
config_entry.add_to_hass(hass)
# `async_setup_entry` calls `register_path`, which needs `hass.http`.
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
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)
result = await async_setup_entry(hass, config_entry)
assert result is True
hass.config_entries.async_forward_entry_setups.assert_awaited()
forwarded_entry, forwarded_platforms = (
hass.config_entries.async_forward_entry_setups.await_args.args
)
assert forwarded_entry.entry_id == config_entry.entry_id
assert "sensor" in list(forwarded_platforms)
async def test_weather_data_update_coordinator_can_be_constructed(
hass, config_entry: MockConfigEntry
):
"""Coordinator should be constructible with a real hass fixture."""
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)
def test_config_flow_version_matches_migration_target() -> None:
"""The flow version and the migration target must not drift apart."""
assert ConfigFlowHandler.VERSION == CONFIG_ENTRY_VERSION
async def test_migrate_moves_legacy_pocasi_key(hass) -> None:
"""A v1 entry carrying the misspelled key migrates to v2 keeping the value."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={POCASI_CZ_ENABLED_LEGACY: True, WINDY_ENABLED: True},
version=1,
)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 2
assert entry.options[POCASI_CZ_ENABLED] is True
assert POCASI_CZ_ENABLED_LEGACY not in entry.options
# Unrelated options survive untouched.
assert entry.options[WINDY_ENABLED] is True
async def test_migrate_without_pocasi_option(hass) -> None:
"""An entry that never had the Pocasi option migrates cleanly."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={WINDY_ENABLED: False}, version=1)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 2
assert POCASI_CZ_ENABLED not in entry.options
assert entry.options[WINDY_ENABLED] is False
async def test_migrate_does_not_clobber_correct_key(hass) -> None:
"""When both keys are present, the already-correct one wins."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={POCASI_CZ_ENABLED_LEGACY: False, POCASI_CZ_ENABLED: True},
version=1,
)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is True
assert entry.options[POCASI_CZ_ENABLED] is True
assert POCASI_CZ_ENABLED_LEGACY not in entry.options
async def test_migrate_is_idempotent(hass) -> None:
"""Running the migration twice leaves the entry unchanged."""
entry = MockConfigEntry(
domain=DOMAIN, data={}, options={POCASI_CZ_ENABLED_LEGACY: True}, version=1
)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is True
first = dict(entry.options)
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 2
assert dict(entry.options) == first
assert entry.options[POCASI_CZ_ENABLED] is True
async def test_migrate_refuses_future_version(hass) -> None:
"""An entry written by a newer version is not downgraded."""
entry = MockConfigEntry(
domain=DOMAIN, data={}, options={}, version=CONFIG_ENTRY_VERSION + 1
)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is False
assert entry.version == CONFIG_ENTRY_VERSION + 1

View File

@ -0,0 +1,455 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
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.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)
class _RequestStub:
"""Minimal aiohttp Request stub used by `received_data`."""
query: dict[str, Any]
async def post(self) -> dict[str, Any]:
return {}
class _RouterStub:
"""Router stub that records route registrations."""
def __init__(self) -> None:
self.add_get_calls: list[tuple[str, Any]] = []
self.add_post_calls: list[tuple[str, Any]] = []
self.raise_on_add: Exception | None = None
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_get_calls.append((path, handler))
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_post_calls.append((path, handler))
return SimpleNamespace(method="POST")
@pytest.fixture
def hass_with_http(hass):
"""Provide a real HA hass fixture augmented with a stub http router."""
router = _RouterStub()
hass.http = SimpleNamespace(app=SimpleNamespace(router=router))
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},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
assert ok is True
# 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, ECOWITT_PATH]
# Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN].
assert DOMAIN in hass_with_http.data
routes = hass_with_http.data[DOMAIN].get("routes")
assert routes is not None
assert isinstance(routes.show_enabled(), str)
@pytest.mark.asyncio
async def test_register_path_raises_config_entry_not_ready_on_router_runtime_error(
hass_with_http,
):
from homeassistant.exceptions import ConfigEntryNotReady
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)
router: _RouterStub = hass_with_http.http.app.router
router.raise_on_add = RuntimeError("router broken")
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, coordinator_health, entry)
@pytest.mark.asyncio
async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_not_ready(
hass_with_http,
):
"""Cover register_path branch where `checked(hass.data[DOMAIN], dict)` returns None."""
from homeassistant.exceptions import ConfigEntryNotReady
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)
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_runtime_data_and_forwards_platforms(
hass_with_http,
monkeypatch,
):
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# 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)
# Shared dispatcher registered under hass.data[DOMAIN].
assert "routes" in hass_with_http.data[DOMAIN]
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
@pytest.mark.asyncio
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 -> ConfigEntryNotReady."""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# No pre-registered routes -> async_setup_entry calls register_path.
hass_with_http.data.setdefault(DOMAIN, {})
hass_with_http.data[DOMAIN].pop("routes", None)
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: False,
)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
with pytest.raises(ConfigEntryNotReady):
await async_setup_entry(hass_with_http, entry)
@pytest.mark.asyncio
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={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# 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
# Switch to WSLink and run setup again.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# 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 = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
)
hass_with_http.config_entries.async_reload = AsyncMock()
# Only SENSORS_TO_LOAD changes.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_not_awaited()
# The snapshot on runtime_data is refreshed.
assert entry.runtime_data.last_options == dict(entry.options)
@pytest.mark.asyncio
async def test_update_listener_triggers_reload_when_other_option_changes(
hass_with_http,
monkeypatch,
):
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
)
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500._LOGGER.info", info)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
info.assert_called()
@pytest.mark.asyncio
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)
# 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)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
# --- async_unload_entry ----------------------------------------------------
@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
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
@pytest.mark.asyncio
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.config_entries.async_unload_platforms = AsyncMock(return_value=False)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is False
# --- coordinator auth (lifecycle-adjacent) ---------------------------------
@pytest.mark.asyncio
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
"""Cover coordinator auth behavior reachable from the webhook entrypoint."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Missing security params -> unauthorized
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"x": "y"})) # type: ignore[arg-type]
# Wrong credentials -> unauthorized
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "no"})
) # type: ignore[arg-type]
# Missing API_ID in options -> IncorrectDataError
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
entry2.add_to_hass(hass)
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
with pytest.raises(IncorrectDataError):
await coordinator2.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_returns_503_when_runtime_data_missing(hass):
"""A payload arriving while the entry is unloaded is rejected with 503, not 500."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", WSLINK: False})
entry.add_to_hass(hass)
# No runtime_data assigned -> simulates the unloaded/reloading window.
coordinator = WeatherDataUpdateCoordinator(hass, entry)
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 503
@pytest.mark.asyncio
async def test_register_path_idempotent_when_routes_exist(hass_with_http):
"""A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""
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

474
tests/test_pocasi_push.py Normal file
View File

@ -0,0 +1,474 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any, Literal
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientError
import pytest
from custom_components.sws12500.const import (
DEFAULT_URL,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_MAX_RETRIES,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_UNEXPECTED,
POCASI_CZ_URL,
POCASI_INVALID_KEY,
WSLINK_URL,
)
from custom_components.sws12500.pocasti_cz import PocasiPush
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _FakeResponse:
text_value: str
status: int = 200
async def text(self) -> str:
return self.text_value
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 = None, exc: Exception | None = None
):
self._response = response
self._exc = exc
self.calls: list[dict[str, Any]] = []
def get(self, url: str, *, params: dict[str, Any] | None = None):
self.calls.append({"url": url, "params": dict(params or {})})
if self._exc is not None:
raise self._exc
assert self._response is not None
return self._response
def _make_entry(
*,
api_id: str | None = "id",
api_key: str | None = "key",
interval: int = 30,
logger: bool = False,
) -> Any:
options: dict[str, Any] = {
POCASI_CZ_SEND_INTERVAL: interval,
POCASI_CZ_LOGGER_ENABLED: logger,
POCASI_CZ_ENABLED: True,
}
if api_id is not None:
options[POCASI_CZ_API_ID] = api_id
if api_key is not None:
options[POCASI_CZ_API_KEY] = api_key
entry = SimpleNamespace()
entry.options = options
entry.entry_id = "test_entry_id"
return entry
def _write_through_update_options(entry: Any) -> AsyncMock:
"""Mock `update_options` that really mutates the entry, like the real helper.
`PocasiPush.enabled` reads the option back, so a mock that only records the call
would leave `enabled` reporting the pre-disable value.
"""
async def _apply(_hass, _entry, key, value):
entry.options[key] = value
return True
return AsyncMock(side_effect=_apply)
@pytest.fixture
def hass():
# Minimal hass-like object; we patch client session retrieval.
return SimpleNamespace()
@pytest.mark.asyncio
async def test_push_data_to_server_missing_api_id_returns_early(monkeypatch, hass):
entry = _make_entry(api_id=None, api_key="key")
pp = PocasiPush(hass, entry)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_server_missing_api_key_returns_early(monkeypatch, hass):
entry = _make_entry(api_id="id", api_key=None)
pp = PocasiPush(hass, entry)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
entry = _make_entry(interval=30, logger=True)
pp = PocasiPush(hass, entry)
# Ensure "next_update > now" so it returns early before doing HTTP.
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode,expected_path", [("WU", DEFAULT_URL), ("WSLINK", WSLINK_URL)]
)
async def test_push_data_to_server_injects_auth_and_chooses_url(
monkeypatch, hass, mode: Literal["WU", "WSLINK"], expected_path: str
):
entry = _make_entry(api_id="id", api_key="key")
pp = PocasiPush(hass, entry)
# Force send now.
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
# Avoid depending on anonymize output; just make it deterministic.
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
await pp.push_data_to_server({"temp": 1}, mode)
assert len(session.calls) == 1
call = session.calls[0]
assert call["url"] == f"{POCASI_CZ_URL}{expected_path}"
params = call["params"]
if mode == "WU":
assert params["ID"] == "id"
assert params["PASSWORD"] == "key"
else:
assert params["wsid"] == "id"
assert params["wspw"] == "key"
@pytest.mark.asyncio
async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
verify = MagicMock(return_value="ok")
monkeypatch.setattr(pp, "verify_response", verify)
await pp.push_data_to_server({"x": 1}, "WU")
verify.assert_called_once_with(200, "OK")
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [401, 403])
async def test_push_data_to_server_auth_error_disables_feature(monkeypatch, hass, status):
"""A 401/403 disables resending immediately - credentials will not self-heal."""
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("", status=status))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
update_options = _write_through_update_options(entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", crit)
await pp.push_data_to_server({"x": 1}, "WU")
crit.assert_called()
# Should log invalid key message and disable feature.
assert any(
POCASI_INVALID_KEY in str(c.args[0]) for c in crit.call_args_list if c.args
)
update_options.assert_awaited_once_with(hass, entry, POCASI_CZ_ENABLED, False)
assert pp.enabled is False
assert pp.last_status == "auth_error"
@pytest.mark.asyncio
async def test_push_data_to_server_success_logs_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
# A previous failure must be cleared by a successful send.
pp.invalid_response_count = 2
session = _FakeSession(response=_FakeResponse("OK", status=200))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.info", info)
await pp.push_data_to_server({"x": 1}, "WU")
info.assert_called()
assert pp.last_status == "ok"
assert pp.last_error is None
assert pp.invalid_response_count == 0
@pytest.mark.asyncio
async def test_push_data_to_server_server_error_disables_after_max_retries(monkeypatch, hass):
"""HTTP 500 is no longer silently reported as success; it counts toward the limit."""
entry = _make_entry()
pp = PocasiPush(hass, entry)
session = _FakeSession(response=_FakeResponse("", status=500))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
update_options = _write_through_update_options(entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
for _ in range(POCASI_CZ_MAX_RETRIES - 1):
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.last_status == "unexpected_response"
assert pp.enabled is True
update_options.assert_not_awaited()
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.enabled is False
update_options.assert_awaited_once_with(hass, entry, POCASI_CZ_ENABLED, False)
@pytest.mark.asyncio
async def test_push_data_to_server_client_error_increments_and_disables_after_three(
monkeypatch, hass
):
entry = _make_entry()
pp = PocasiPush(hass, entry)
update_options = _write_through_update_options(entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", crit)
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
# Force request attempts and exceed invalid count threshold.
for _i in range(4):
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 4
# Should disable after >3
update_options.assert_awaited()
args = update_options.await_args.args
assert args[2] == POCASI_CZ_ENABLED
assert args[3] is False
# Should log unexpected at least once
assert any(
POCASI_CZ_UNEXPECTED in str(c.args[0]) for c in crit.call_args_list if c.args
)
@pytest.mark.asyncio
async def test_push_data_to_server_timeout_is_handled_like_client_error(
monkeypatch, hass
):
"""A network timeout must not escape into the webhook handler.
`TimeoutError` is not a subclass of `ClientError`, so it used to propagate out
of `push_data_to_server`, through the awaiting coordinator, and answer the
station with HTTP 500 - even though the measured data was already stored.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options",
_write_through_update_options(entry),
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", MagicMock())
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.last_status == "client_error"
assert pp.last_error == "TimeoutError"
assert pp.invalid_response_count == 1
assert pp.enabled is True
def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.debug", dbg)
assert pp.verify_response(200, "anything") == "ok"
dbg.assert_called()
@pytest.mark.parametrize(
("status", "expected"),
[
(200, "ok"),
(204, "ok"),
(299, "ok"),
(401, "auth_error"),
(403, "auth_error"),
(400, "unexpected_response"),
(404, "unexpected_response"),
(500, "unexpected_response"),
(503, "unexpected_response"),
],
)
def test_verify_response_status_mapping(hass, status, expected):
"""Every send outcome is derived from the HTTP status, not from the (empty) body."""
pp = PocasiPush(hass, _make_entry())
assert pp.verify_response(status, "") == expected
@pytest.mark.asyncio
async def test_disable_pocasi_logs_when_option_write_fails(monkeypatch, hass):
"""A failed option write is logged but still leaves resending off in memory."""
entry = _make_entry()
pp = PocasiPush(hass, entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options",
AsyncMock(return_value=False),
)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.debug", dbg)
await pp._disable_pocasi("because")
# `enabled` mirrors the persisted option: if the write failed, forwarding is still
# on as far as the config is concerned, and the failure is logged instead.
assert pp.enabled is True
assert pp.last_error == "because"
dbg.assert_called()
# ---------------------------------------------------------------------------
# Live `enabled` and empty-credential rejection
# ---------------------------------------------------------------------------
def test_enabled_reads_options_live(hass):
"""Toggling the option is visible immediately - no reload, no cached copy.
`update_listener` deliberately skips the reload when only this flag changes, so a
value cached in __init__ would leave the diagnostics sensor permanently stale.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
assert pp.enabled is True
entry.options[POCASI_CZ_ENABLED] = False
assert pp.enabled is False
entry.options[POCASI_CZ_ENABLED] = True
assert pp.enabled is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("api_id", "api_key"),
[("", "key"), ("id", ""), ("", "")],
ids=["empty-id", "empty-key", "both-empty"],
)
async def test_empty_credentials_never_reach_the_network(monkeypatch, hass, api_id, api_key):
"""An empty string is still a `str`, so it must be rejected explicitly.
Otherwise a blank configuration sends a request that can only ever be refused.
"""
entry = _make_entry(api_id=api_id, api_key=api_key)
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
assert pp.last_status == "config_error"

533
tests/test_received_data.py Normal file
View File

@ -0,0 +1,533 @@
from __future__ import annotations
from dataclasses import dataclass
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,
DEFAULT_URL,
DOMAIN,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
WSLINK_URL,
)
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _RequestStub:
"""Minimal aiohttp Request stub.
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,
) -> 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,
"dev_debug_checkbox": dev_debug,
}
if api_id is not None:
options[API_ID] = api_id
if api_key is not None:
options[API_KEY] = api_key
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
@pytest.mark.asyncio
async def test_received_data_wu_missing_security_params_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=False)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# No ID/PASSWORD -> unauthorized
request = _RequestStub(query={"foo": "bar"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_wslink_missing_security_params_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# No wsid/wspw -> unauthorized
request = _RequestStub(query={"foo": "bar"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_missing_api_id_in_options_raises_incorrect_data_error(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id=None, api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
with pytest.raises(IncorrectDataError):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_missing_api_key_in_options_raises_incorrect_data_error(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key=None)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
with pytest.raises(IncorrectDataError):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_wrong_credentials_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "wrong"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_success_remaps_and_updates_coordinator_data(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Patch remapping so this test doesn't depend on mapping tables.
remapped = {"outside_temp": "10"}
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_items",
lambda _data: remapped,
)
# Ensure no autodiscovery triggers
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
# Capture updates
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "tempf": "50"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch):
entry = _make_entry(wslink=True, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"ws_temp": "1"}
monkeypatch.setattr(
"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.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"wsid": "id", "wspw": "key", "t": "1"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch):
entry = _make_entry(wslink=False, api_id="id", api_key="key", windy_enabled=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.windy.push_data_to_windy = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_items",
lambda _data: {"k": "v"},
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.windy.push_data_to_windy.assert_awaited_once()
args, _kwargs = coordinator.windy.push_data_to_windy.await_args
assert isinstance(args[0], dict) # raw data dict
assert args[1] is False # wslink flag
@pytest.mark.asyncio
async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch):
entry = _make_entry(wslink=True, api_id="id", api_key="key", pocasi_enabled=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.pocasi.push_data_to_server = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_wslink_items",
lambda _data: {"k": "v"},
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"wsid": "id", "wspw": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.pocasi.push_data_to_server.assert_awaited_once()
args, _kwargs = coordinator.pocasi.push_data_to_server.await_args
assert isinstance(args[0], dict) # raw data dict
assert args[1] == "WSLINK"
@pytest.mark.asyncio
async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sensors(
hass,
monkeypatch,
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Arrange: remapped payload contains keys that are disabled.
remapped = {"a": "1", "b": "2"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
# Autodiscovery finds two sensors to add
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a", "b"],
)
# No previously loaded sensors
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.coordinator.translations", _translations)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
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
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
# It should notify
translated_notification.assert_awaited()
# It should persist newly discovered sensors
update_options.assert_awaited()
args, _kwargs = update_options.await_args
assert args[2] == SENSORS_TO_LOAD
assert set(args[3]) >= {"a", "b"}
# It should add new sensors dynamically
add_new_sensors.assert_called_once()
_hass_arg, _entry_arg, keys = add_new_sensors.call_args.args
assert _hass_arg is hass
assert _entry_arg is entry
assert set(keys) == {"a", "b"}
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_autodiscovery_human_readable_empty_branch_via_checked_none(
hass,
monkeypatch,
):
"""Force `checked([...], list[str])` to return None so `human_readable = ""` branch is executed."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"a": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a"],
)
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.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):
if expected_type == list[str]:
return None
return value
monkeypatch.setattr("custom_components.sws12500.coordinator.checked", _checked_override)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
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
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
# Ensure it still notifies (with empty human readable list)
translated_notification.assert_awaited()
# And persists sensors
update_options.assert_awaited()
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch(
hass, monkeypatch
):
"""Cover `_loaded_sensors := loaded_sensors(self.config)` branch (extend existing)."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"new": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
# Autodiscovery finds one new sensor
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["new"],
)
# Pretend there are already loaded sensors in options
monkeypatch.setattr(
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
)
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()
)
update_options = AsyncMock()
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
)
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
# Ensure the persisted list includes both new and existing sensors
update_options.assert_awaited()
args, _kwargs = update_options.await_args
assert args[2] == SENSORS_TO_LOAD
assert set(args[3]) >= {"new", "existing"}
@pytest.mark.asyncio
async def test_received_data_autodiscovery_translations_all_none_still_notifies_and_updates(
hass, monkeypatch
):
"""Cover the branch where translated sensor names cannot be resolved (human_readable becomes empty)."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"a": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a"],
)
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.coordinator.translations", _translations)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
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
)
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
translated_notification.assert_awaited()
update_options.assert_awaited()
add_new_sensors.assert_called_once()
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypatch):
entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: {"k": "v"})
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
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 = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
anonymize.assert_called_once()
log_info.assert_called_once()
@pytest.mark.asyncio
async def test_register_path_switching_logic_is_exercised_via_routes(monkeypatch):
"""Sanity: constants exist and are distinct (helps guard tests relying on them)."""
assert DEFAULT_URL != WSLINK_URL
assert DOMAIN == "sws12500"
@pytest.mark.asyncio
async def test_received_data_empty_configured_credentials_raises_incorrect_data(hass, monkeypatch):
"""Empty configured API ID/KEY is treated as missing config (defense-in-depth)."""
entry = _make_entry(wslink=False, api_id="", api_key="key")
entry.runtime_data.health_coordinator = SimpleNamespace(update_ingress_result=MagicMock())
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(IncorrectDataError):
await coordinator.received_data(_RequestStub(query={"ID": "id", "PASSWORD": "key"})) # type: ignore[arg-type]
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()
@pytest.mark.asyncio
async def test_received_data_empty_incoming_credentials_raises_unauthorized(hass, monkeypatch):
"""Present-but-empty incoming credentials are rejected before the digest compare."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
entry.runtime_data.health_coordinator = SimpleNamespace(update_ingress_result=MagicMock())
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"ID": "", "PASSWORD": ""})) # type: ignore[arg-type]
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()

View File

@ -0,0 +1,573 @@
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
- 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,
)
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", "model": "GW2000A"}
)
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.ecowitt_bridge.process_payload.assert_awaited_once()
# Station model is learned from the payload and stored for the shared device model.
assert entry.runtime_data.ecowitt_model == "GW2000A"
# 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)
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: [],
)
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)
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()
)
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: [],
)
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={})
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()
# ---------------------------------------------------------------------------
# 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)
@pytest.mark.asyncio
async def test_received_ecowitt_returns_503_when_runtime_data_missing(hass):
"""An Ecowitt payload during the unload window is rejected with 503, not 500."""
entry = SimpleNamespace(
entry_id="x",
options={ECOWITT_ENABLED: True},
async_on_unload=lambda *_a, **_k: None,
) # no runtime_data attribute -> guard triggers
coordinator = WeatherDataUpdateCoordinator(hass, entry)
resp = await coordinator.received_ecowitt_data(
_EcowittRequestStub(match_info={"webhook_id": "x"})
) # type: ignore[arg-type]
assert resp.status == 503

105
tests/test_routes.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Awaitable, Callable
from aiohttp.web import Response
import pytest
from custom_components.sws12500.routes import Routes, unregistered
Handler = Callable[["_RequestStub"], Awaitable[Response]]
@dataclass(slots=True)
class _RequestStub:
"""Minimal request stub for unit-testing the dispatcher.
`Routes.dispatch` relies on `request.method` and `request.path`.
`unregistered` accepts a request object but does not use it.
"""
method: str
path: str
@dataclass(slots=True)
class _RouteStub:
"""Minimal route stub providing `method` expected by Routes.add_route`."""
method: str
@pytest.fixture
def routes() -> Routes:
return Routes()
async def test_dispatch_unknown_path_calls_unregistered(routes: Routes) -> None:
request = _RequestStub(method="GET", path="/unregistered")
response = await routes.dispatch(request) # type: ignore[arg-type]
assert response.status == 400
async def test_unregistered_handler_returns_400() -> None:
request = _RequestStub(method="GET", path="/invalid")
response = await unregistered(request) # type: ignore[arg-type]
assert response.status == 400
async def test_dispatch_registered_but_disabled_uses_fallback(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 400
async def test_dispatch_registered_and_enabled_uses_handler(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=201)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=True)
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 201
def test_switch_route_enables_exactly_one(routes: Routes) -> None:
async def handler_a(_request: _RequestStub) -> Response:
return Response(text="A", status=200)
async def handler_b(_request: _RequestStub) -> Response:
return Response(text="B", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler_a, enabled=True)
routes.add_route("/b", _RouteStub(method="GET"), handler_b, enabled=False)
routes.switch_route(handler_b, "/b")
assert routes.routes["GET:/a"].enabled is False
assert routes.routes["GET:/b"].enabled is True
def test_show_enabled_returns_message_when_none_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=False)
assert routes.show_enabled() == "No routes are enabled."
def test_show_enabled_includes_url_when_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=True)
msg = routes.show_enabled()
assert "Dispatcher enabled for (GET):/b" in msg
assert "handler" in msg

82
tests/test_routes_more.py Normal file
View File

@ -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

View File

@ -0,0 +1,220 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from custom_components.sws12500.const import (
CHILL_INDEX,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
SENSORS_TO_LOAD,
WIND_AZIMUT,
WIND_DIR,
WIND_SPEED,
WSLINK,
)
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.sensor import (
WeatherSensor,
_auto_enable_derived_sensors,
add_new_sensors,
async_setup_entry,
)
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
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, *, options: dict[str, Any] | None = None) -> None:
self.data = data if data is not None else {}
# 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():
# 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] = {}
return _Hass()
def _capture_add_entities():
captured: list[Any] = []
def _add_entities(entities: list[Any]) -> None:
captured.extend(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():
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():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY})
assert HEAT_INDEX in expanded
def test_auto_enable_derived_sensors_chill_index():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED})
assert CHILL_INDEX in expanded
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
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()
await async_setup_entry(hass, entry, add_entities)
# 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_setup_selects_weather_api_descriptions_when_wslink_disabled(hass):
entry, _coordinator, runtime = _make_entry(options={WSLINK: False})
_captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
@pytest.mark.asyncio
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()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
@pytest.mark.asyncio
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()
await async_setup_entry(hass, entry, add_entities)
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
# --- 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
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}
add_new_sensors(hass, entry, keys=[known_desc.key])
add_entities.assert_called_once()
(entities_arg,) = add_entities.call_args.args
assert isinstance(entities_arg, list)
assert len(entities_arg) == 1
assert isinstance(entities_arg[0], WeatherSensor)
assert entities_arg[0].entity_description.key == known_desc.key
def test_add_new_sensors_noop_when_runtime_data_missing():
"""add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_sensors(None, entry, keys=["anything"]) # must not raise

View File

@ -0,0 +1,6 @@
# Test file for sensors_common.py module
def test_sensors_common_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,6 @@
# Test file for sensors_weather.py module
def test_sensors_weather_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,10 @@
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
def test_sensor_types_wslink_structure():
assert isinstance(SENSOR_TYPES_WSLINK, tuple)
assert len(SENSOR_TYPES_WSLINK) > 0
for sensor in SENSOR_TYPES_WSLINK:
assert hasattr(sensor, "key")
assert hasattr(sensor, "native_unit_of_measurement")

View File

@ -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

83
tests/test_strings.py Normal file
View File

@ -0,0 +1,83 @@
"""Tests for the user-facing strings shipped with the integration.
`strings.json` is the source of truth; `translations/en.json` and `translations/cs.json`
are what the frontend actually loads. A key that exists in one file and not in another
(or a state key that no entity ever emits) is invisible in code review and only shows up
as an untranslated raw value in the UI.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
from homeassistant.components.sensor import SensorDeviceClass
COMPONENT_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500"
STRINGS_FILE = COMPONENT_DIR / "strings.json"
TRANSLATION_FILES = (
COMPONENT_DIR / "translations" / "en.json",
COMPONENT_DIR / "translations" / "cs.json",
)
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _flatten(data: dict[str, Any], prefix: str = "") -> set[str]:
"""Return every leaf path in a translation document."""
keys: set[str] = set()
for key, value in data.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
keys |= _flatten(value, path)
else:
keys.add(path)
return keys
@pytest.mark.parametrize("translation_file", TRANSLATION_FILES, ids=lambda p: p.name)
def test_translation_keys_match_strings_json(translation_file: Path) -> None:
"""Every shipped language must carry exactly the keys defined in strings.json."""
expected = _flatten(_load(STRINGS_FILE))
actual = _flatten(_load(translation_file))
assert not expected - actual, f"{translation_file.name} is missing keys"
assert not actual - expected, f"{translation_file.name} has stale keys"
def _enum_descriptions() -> list[Any]:
"""Sensor descriptions that report a fixed set of states to the frontend."""
seen: dict[str, Any] = {}
for description in (*SENSOR_TYPES_WEATHER_API, *SENSOR_TYPES_WSLINK):
if description.device_class is not SensorDeviceClass.ENUM or not description.options:
continue
seen.setdefault(description.translation_key or description.key, description)
return list(seen.values())
@pytest.mark.parametrize(
("path", "description"),
[(path, description) for path in (STRINGS_FILE, *TRANSLATION_FILES) for description in _enum_descriptions()],
ids=lambda value: value.name if isinstance(value, Path) else (value.translation_key or value.key),
)
def test_enum_states_are_translated(path: Path, description: Any) -> None:
"""Each enum state an entity can emit needs a translation - and no extra ones.
The battery sensors emit `UnitOfBat.UNKNOWN`, whose *value* is "drained"; a state
block keyed on the member name ("unknown") never matches, so the frontend falls back
to showing the raw state.
"""
translation_key = description.translation_key or description.key
states = _load(path)["entity"]["sensor"][translation_key]["state"]
assert set(states) == {str(option) for option in description.options}, (
f"{path.name}: state keys for '{translation_key}' do not match the emitted values"
)

View File

@ -4,7 +4,7 @@ Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
``t9cn`` parameters:
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``)
``VOCLevel`` / ``VOC_LEVEL_MAP``)
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
- the connection gating in ``utils.remap_wslink_items``
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
@ -20,7 +20,6 @@ import pytest
from custom_components.sws12500.const import (
BATTERY_LIST,
BATTERY_NON_BINARY,
CONNECTION_GATED_SENSORS,
HCHO,
OUTSIDE_TEMP,
@ -70,11 +69,12 @@ 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]
# the 0-5 / percentage battery must not be treated as a binary low/normal one
assert T9_BATTERY not in BATTERY_LIST
@ -84,7 +84,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 +109,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 +184,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:

View File

@ -0,0 +1,6 @@
# Test file for translations/cs.json module
def test_translations_cs_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,6 @@
# Test file for translations/en.json module
def test_translations_en_functionality():
# Add your test cases here
pass

8
tests/test_utils.py Normal file
View File

@ -0,0 +1,8 @@
from custom_components.sws12500.utils import celsius_to_fahrenheit, fahrenheit_to_celsius
def test_temperature_conversion():
assert celsius_to_fahrenheit(0) == 32
assert celsius_to_fahrenheit(100) == 212
assert fahrenheit_to_celsius(32) == 0
assert fahrenheit_to_celsius(212) == 100

54
tests/test_utils_conv.py Normal file
View File

@ -0,0 +1,54 @@
"""Coverage for to_int / to_float edge cases and anonymize() masking."""
from __future__ import annotations
import pytest
from custom_components.sws12500.utils import anonymize, to_float, to_int
def test_anonymize_masks_all_known_secrets() -> None:
raw = {
"ID": "id",
"PASSWORD": "pw",
"wsid": "ws",
"wspw": "wp",
"passkey": "ecowitt-secret",
"PASSKEY": "ecowitt-secret",
"tempf": "68",
}
out = anonymize(raw)
for key in ("ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"):
assert out[key] == "***"
# Non-secret values pass through unchanged.
assert out["tempf"] == "68"
@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

393
tests/test_utils_more.py Normal file
View File

@ -0,0 +1,393 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.sws12500.const import (
DEV_DBG,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
REMAP_ITEMS,
REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD,
WIND_SPEED,
UnitOfBat,
)
from custom_components.sws12500.utils import (
anonymize,
battery_5step_to_pct,
battery_level,
celsius_to_fahrenheit,
check_disabled,
chill_index,
fahrenheit_to_celsius,
heat_index,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
voc_level_to_text,
wind_dir_to_text,
)
@dataclass(slots=True)
class _EntryStub:
entry_id: str = "test_entry_id"
options: dict[str, Any] = None # type: ignore[assignment]
class _ConfigEntriesStub:
def __init__(self) -> None:
self.async_update_entry = MagicMock(return_value=True)
class _HassStub:
def __init__(self, language: str = "en") -> None:
self.config = SimpleNamespace(language=language)
self.config_entries = _ConfigEntriesStub()
@pytest.fixture
def hass() -> _HassStub:
return _HassStub(language="en")
@pytest.fixture
def entry() -> _EntryStub:
return _EntryStub(options={})
def test_anonymize_masks_secrets_and_keeps_other_values():
data = {
"ID": "abc",
"PASSWORD": "secret",
"wsid": "id2",
"wspw": "pw2",
"temp": 10,
"ok": True,
}
out = anonymize(data)
assert out["ID"] == "***"
assert out["PASSWORD"] == "***"
assert out["wsid"] == "***"
assert out["wspw"] == "***"
assert out["temp"] == 10
assert out["ok"] is True
def test_remap_items_filters_unknown_keys():
# Pick a known legacy key from the mapping
legacy_key = next(iter(REMAP_ITEMS.keys()))
internal_key = REMAP_ITEMS[legacy_key]
entities = {legacy_key: "1", "unknown": "2"}
out = remap_items(entities)
assert out == {internal_key: "1"}
def test_remap_wslink_items_filters_unknown_keys():
wslink_key = next(iter(REMAP_WSLINK_ITEMS.keys()))
internal_key = REMAP_WSLINK_ITEMS[wslink_key]
entities = {wslink_key: "x", "unknown": "y"}
out = remap_wslink_items(entities)
assert out == {internal_key: "x"}
def test_loaded_sensors_returns_list_or_empty(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a", "b"]
assert loaded_sensors(entry) == ["a", "b"]
entry.options[SENSORS_TO_LOAD] = []
assert loaded_sensors(entry) == []
entry.options.pop(SENSORS_TO_LOAD)
assert loaded_sensors(entry) == []
def test_check_disabled_returns_none_when_all_present(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a", "b"]
entry.options[DEV_DBG] = False
missing = check_disabled({"a": "1", "b": "2"}, entry)
assert missing is None
def test_check_disabled_returns_missing_keys(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a"]
entry.options[DEV_DBG] = False
missing = check_disabled({"a": "1", "b": "2", "c": "3"}, entry)
assert missing == ["b", "c"]
def test_check_disabled_logs_when_dev_dbg_enabled(entry: _EntryStub, monkeypatch):
# Just ensure logging branches are exercised without asserting exact messages.
entry.options[SENSORS_TO_LOAD] = []
entry.options[DEV_DBG] = True
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.info", lambda *a, **k: None
)
missing = check_disabled({"a": "1"}, entry)
assert missing == ["a"]
@pytest.mark.asyncio
async def test_update_options_calls_async_update_entry(
hass: _HassStub, entry: _EntryStub
):
entry.options = {"x": 1}
ok = await update_options(hass, entry, "y", True)
assert ok is True
hass.config_entries.async_update_entry.assert_called_once()
_called_entry = hass.config_entries.async_update_entry.call_args.args[0]
assert _called_entry is entry
called_options = hass.config_entries.async_update_entry.call_args.kwargs["options"]
assert called_options["x"] == 1
assert called_options["y"] is True
@pytest.mark.asyncio
async def test_translations_returns_value_when_key_present(
hass: _HassStub, monkeypatch
):
# Build the key that translations() will look for
localize_key = "component.sws12500.entity.sensor.test.name"
get_translations = AsyncMock(return_value={localize_key: "Translated"})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
out = await translations(
hass,
"sws12500",
"sensor.test",
key="name",
category="entity",
)
assert out == "Translated"
@pytest.mark.asyncio
async def test_translations_returns_none_when_key_missing(hass: _HassStub, monkeypatch):
get_translations = AsyncMock(return_value={})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
out = await translations(hass, "sws12500", "missing")
assert out is None
@pytest.mark.asyncio
async def test_translated_notification_creates_notification_without_placeholders(
hass: _HassStub, monkeypatch
):
base_key = "component.sws12500.notify.added.message"
title_key = "component.sws12500.notify.added.title"
get_translations = AsyncMock(return_value={base_key: "Msg", title_key: "Title"})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
create = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.utils.persistent_notification.async_create", create
)
await translated_notification(hass, "sws12500", "added")
create.assert_called_once()
args = create.call_args.args
assert args[0] is hass
assert args[1] == "Msg"
assert args[2] == "Title"
@pytest.mark.asyncio
async def test_translated_notification_formats_placeholders(
hass: _HassStub, monkeypatch
):
base_key = "component.sws12500.notify.added.message"
title_key = "component.sws12500.notify.added.title"
get_translations = AsyncMock(
return_value={base_key: "Hello {name}", title_key: "Title"}
)
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
create = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.utils.persistent_notification.async_create", create
)
await translated_notification(
hass, "sws12500", "added", translation_placeholders={"name": "World"}
)
create.assert_called_once()
assert create.call_args.args[1] == "Hello World"
def test_battery_level_handles_none_empty_invalid_and_known_values():
assert battery_level(None) == UnitOfBat.UNKNOWN
assert battery_level("") == UnitOfBat.UNKNOWN
assert battery_level("x") == UnitOfBat.UNKNOWN
assert battery_level(0) == UnitOfBat.LOW
assert battery_level("0") == UnitOfBat.LOW
assert battery_level(1) == UnitOfBat.NORMAL
assert battery_level("1") == UnitOfBat.NORMAL
# Unknown numeric values map to UNKNOWN
assert battery_level(2) == UnitOfBat.UNKNOWN
assert battery_level("2") == UnitOfBat.UNKNOWN
def test_temperature_conversions_round_trip():
# Use a value that is exactly representable in binary-ish floats
f = 32.0
c = fahrenheit_to_celsius(f)
assert c == 0.0
assert celsius_to_fahrenheit(c) == 32.0
# General check (approx)
f2 = 77.0
c2 = fahrenheit_to_celsius(f2)
assert c2 == pytest.approx(25.0)
assert celsius_to_fahrenheit(c2) == pytest.approx(77.0)
def test_wind_dir_to_text_returns_none_for_zero_and_valid_for_positive():
assert wind_dir_to_text(0.0) is None
assert wind_dir_to_text(0) is None
# For a non-zero degree it should return some enum value
out = wind_dir_to_text(10.0)
assert out is not None
def test_heat_index_returns_none_when_missing_temp_or_humidity(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.error", lambda *a, **k: None
)
assert heat_index({OUTSIDE_HUMIDITY: "50"}) is None
assert heat_index({OUTSIDE_TEMP: "80"}) is None
assert heat_index({OUTSIDE_TEMP: "x", OUTSIDE_HUMIDITY: "50"}) is None
assert heat_index({OUTSIDE_TEMP: "80", OUTSIDE_HUMIDITY: "x"}) is None
def test_heat_index_simple_path_and_full_index_path():
# Simple path: keep simple average under threshold.
# Using temp=70F, rh=40 keeps ((simple+temp)/2) under 80 typically.
simple = heat_index({OUTSIDE_TEMP: "70", OUTSIDE_HUMIDITY: "40"})
assert simple is not None
# Full index path: choose high temp/rh -> triggers full index.
full = heat_index({OUTSIDE_TEMP: "90", OUTSIDE_HUMIDITY: "85"})
assert full is not None
def test_heat_index_low_humidity_adjustment_branch():
# This targets:
# if rh < 13 and (80 <= temp <= 112): adjustment = ...
#
# Pick a temp/rh combo that:
# - triggers the full-index path: ((simple + temp) / 2) > 80
# - satisfies low humidity adjustment bounds
out = heat_index({OUTSIDE_TEMP: "95", OUTSIDE_HUMIDITY: "10"})
assert out is not None
def test_heat_index_convert_from_celsius_path():
# If convert=True, temp is interpreted as Celsius and converted to Fahrenheit internally.
# Use 30C (~86F) and high humidity to trigger full index path.
out = heat_index({OUTSIDE_TEMP: "30", OUTSIDE_HUMIDITY: "85"}, convert=True)
assert out is not None
def test_chill_index_returns_none_when_missing_temp_or_wind(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.error", lambda *a, **k: None
)
assert chill_index({WIND_SPEED: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "x", WIND_SPEED: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "10", WIND_SPEED: "x"}) is None
def test_chill_index_returns_calculated_when_cold_and_windy():
# temp in F, wind > 3 -> calculate when temp < 50
out = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "10"})
assert out is not None
assert isinstance(out, float)
def test_chill_index_returns_temp_when_not_cold_or_not_windy():
# Not cold -> hits the `else temp` branch
out1 = chill_index({OUTSIDE_TEMP: "60", WIND_SPEED: "10"})
assert out1 == 60.0
# Not windy -> hits the `else temp` branch
out2 = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "2"})
assert out2 == 40.0
# Boundary: exactly 50F should also hit the `else temp` branch (since condition is temp < 50)
out3 = chill_index({OUTSIDE_TEMP: "50", WIND_SPEED: "10"})
assert out3 == 50.0
# Boundary: exactly 3 mph should also hit the `else temp` branch (since condition is wind > 3)
out4 = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "3"})
assert out4 == 40.0
def test_chill_index_convert_from_celsius_path():
out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True)
assert out is not None
# ---------------------------------------------------------------------------
# Converters must degrade to None, not raise
#
# `WeatherSensor.native_value` catches value_fn exceptions and logs them with
# `_LOGGER.exception`, so a bare int() on a garbage payload value produced a full
# traceback on *every* push rather than a quiet `unknown` state.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", "1.5.2", [], {}])
def test_voc_level_to_text_rejects_garbage(bad):
assert voc_level_to_text(bad) is None
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", object()])
def test_battery_5step_to_pct_rejects_garbage(bad):
assert battery_5step_to_pct(bad) is None
@pytest.mark.parametrize(
("value", "expected"),
[(0, 0), ("0", 0), (1, 20), ("3", 60), (5, 100), ("5.0", 100)],
)
def test_battery_5step_to_pct_maps_the_scale(value, expected):
assert battery_5step_to_pct(value) == expected
@pytest.mark.parametrize(("value", "expected"), [(-3, 0), (9, 100)])
def test_battery_5step_to_pct_clamps_out_of_range(value, expected):
"""Out-of-range steps stay a valid battery percentage."""
assert battery_5step_to_pct(value) == expected

View File

@ -0,0 +1,254 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Callable
from unittest.mock import MagicMock
from custom_components.sws12500.const import DOMAIN
from custom_components.sws12500.sensor import WeatherSensor
@dataclass(slots=True)
class _DescriptionStub:
"""Minimal stand-in for WeatherSensorEntityDescription.
WeatherSensor only relies on:
- key
- value_fn
- value_from_data_fn
"""
key: str
value_fn: Callable[[Any], Any] | None = None
value_from_data_fn: Callable[[dict[str, Any]], Any] | None = None
class _CoordinatorStub:
"""Minimal coordinator stub used by WeatherSensor."""
def __init__(
self, data: dict[str, Any] | None = None, *, config: Any | None = None
):
self.data = data if data is not None else {}
# 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():
desc = _DescriptionStub(
key="derived",
value_from_data_fn=lambda data: f"v:{data.get('x')}",
value_fn=lambda raw: f"raw:{raw}", # should not be used
)
coordinator = _CoordinatorStub(data={"x": 123, "derived": "ignored"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == "v:123"
def test_native_value_value_from_data_fn_success_with_dev_logging_hits_computed_debug_branch(
monkeypatch,
):
"""Ensure value_from_data_fn works with dev logging enabled."""
desc = _DescriptionStub(
key="derived",
value_from_data_fn=lambda data: data["x"] + 1,
)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"x": 41}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_from_data_fn_exception_returns_none():
def boom(_data: dict[str, Any]) -> Any:
raise RuntimeError("nope")
desc = _DescriptionStub(key="derived", value_from_data_fn=boom)
coordinator = _CoordinatorStub(data={"derived": 1})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_missing_raw_returns_none():
desc = _DescriptionStub(key="missing", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_missing_raw_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="missing", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_raw_none_with_dev_logging_hits_debug_branch(monkeypatch):
# This targets the `raw is None` branch (not empty string) and ensures the debug line
# is actually executed (coverage sometimes won't attribute it when data is missing).
called = {"debug": 0}
def _debug(*_a, **_k):
called["debug"] += 1
monkeypatch.setattr("custom_components.sws12500.sensor._LOGGER.debug", _debug)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
# Ensure the key exists and explicitly maps to None so `data.get(key)` returns None
# in a deterministic way for coverage.
coordinator = _CoordinatorStub(data={"k": None}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
assert called["debug"] >= 1
def test_native_value_missing_raw_logs_specific_message(monkeypatch):
"""Target the exact debug log line for missing raw values.
This is meant to hit the specific `_LOGGER.debug("native_value missing raw: ...")`
statement to help achieve full `sensor.py` coverage.
"""
debug = MagicMock()
monkeypatch.setattr("custom_components.sws12500.sensor._LOGGER.debug", debug)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": None}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
debug.assert_any_call("native_value missing raw: key=%s raw=%s", "k", None)
def test_native_value_empty_string_raw_returns_none():
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"k": ""})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_empty_string_raw_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": ""}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_no_value_fn_returns_none():
desc = _DescriptionStub(key="k", value_fn=None)
coordinator = _CoordinatorStub(data={"k": 10})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_no_value_fn_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=None)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": 10}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_value_fn_success():
desc = _DescriptionStub(key="k", value_fn=lambda raw: int(raw) + 1)
coordinator = _CoordinatorStub(data={"k": "41"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_fn_success_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=lambda raw: int(raw) + 1)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": "41"}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_fn_exception_returns_none():
def boom(_raw: Any) -> Any:
raise ValueError("bad")
desc = _DescriptionStub(key="k", value_fn=boom)
coordinator = _CoordinatorStub(data={"k": "x"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_suggested_entity_id_uses_sensor_domain_and_key(monkeypatch):
# `homeassistant.helpers.entity.generate_entity_id` requires either `current_ids` or `hass`.
# Our entity isn't attached to hass in this unit test, so patch it to a deterministic result.
monkeypatch.setattr(
"custom_components.sws12500.sensor.generate_entity_id",
lambda _fmt, key: f"sensor.{key}",
)
desc = _DescriptionStub(key="outside_temp", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"outside_temp": 1})
entity = WeatherSensor(desc, coordinator)
suggested = entity.suggested_entity_id
assert suggested == "sensor.outside_temp"
def test_device_info_contains_expected_identifiers_and_domain():
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"k": 1})
entity = WeatherSensor(desc, coordinator)
info = entity.device_info
assert info is not None
# DeviceInfo is mapping-like; access defensively.
assert info.get("name") == "Weather Station SWS 12500"
assert info.get("manufacturer") == "Schizza"
assert info.get("model") == "PWS" # no ecowitt/wslink in stub options -> PWS
identifiers = info.get("identifiers")
assert isinstance(identifiers, set)
assert (DOMAIN,) in identifiers
def test_dev_log_flag_reads_from_config_entry_options():
# When coordinator has a config with options, WeatherSensor should read dev_debug_checkbox.
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": 1}, config=config)
entity = WeatherSensor(desc, coordinator)
# We don't assert logs; we just ensure native_value still works with dev logging enabled.
assert entity.native_value == 1

6
tests/test_windy_func.py Normal file
View File

@ -0,0 +1,6 @@
# Test file for windy_func.py module
def test_windy_func_functionality():
# Add your test cases here
pass

121
tests/test_windy_more.py Normal file
View File

@ -0,0 +1,121 @@
"""Additional Windy branch coverage: duplicate (409), rate limit (429), 3-strike disable."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import 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
from homeassistant.util import dt as dt_util
@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 = dt_util.utcnow() - 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 = dt_util.utcnow() - timedelta(seconds=1)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(_FakeResponse(status=429)),
)
before = dt_util.utcnow()
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 = dt_util.utcnow() - 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.async_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)

519
tests/test_windy_push.py Normal file
View File

@ -0,0 +1,519 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.client_exceptions import ClientError
import pytest
from custom_components.sws12500.const import (
PURGE_DATA,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WINDY_UNEXPECTED,
WINDY_URL,
)
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _FakeResponse:
status: int
text_value: str = ""
async def text(self) -> str:
return self.text_value
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 = None, exc: Exception | None = None
):
self._response = response
self._exc = exc
self.calls: list[dict[str, Any]] = []
def get(
self,
url: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
):
self.calls.append(
{"url": url, "params": dict(params or {}), "headers": dict(headers or {})}
)
if self._exc is not None:
raise self._exc
assert self._response is not None
return self._response
@pytest.fixture
def hass():
# Use HA provided fixture if available; otherwise a minimal stub works because we patch session getter.
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_windy_response_notice_raises_not_inserted(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyNotInserted):
wp.verify_windy_response(_FakeResponse(status=400, text_value="Bad Request"))
def test_verify_windy_response_success_raises_success(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindySuccess):
wp.verify_windy_response(_FakeResponse(status=200, text_value="OK"))
def test_verify_windy_response_password_missing_raises(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyPasswordMissing):
wp.verify_windy_response(_FakeResponse(status=401, text_value="Unauthorized"))
def test_covert_wslink_to_pws_maps_keys(hass):
wp = WindyPush(hass, _make_entry())
data = {
"t1ws": "1",
"t1wgust": "2",
"t1wdir": "3",
"t1hum": "4",
"t1dew": "5",
"t1tem": "6",
"rbar": "7",
"t1rainhr": "8",
"t1uvi": "9",
"t1solrad": "10",
"other": "keep",
}
out = wp._covert_wslink_to_pws(data)
assert out["wind"] == "1"
assert out["gust"] == "2"
assert out["winddir"] == "3"
assert out["humidity"] == "4"
assert out["dewpoint"] == "5"
assert out["temp"] == "6"
assert out["mbar"] == "7"
assert out["precip"] == "8"
assert out["uv"] == "9"
assert out["solarradiation"] == "10"
assert out["other"] == "keep"
for k in (
"t1ws",
"t1wgust",
"t1wdir",
"t1hum",
"t1dew",
"t1tem",
"rbar",
"t1rainhr",
"t1uvi",
"t1solrad",
):
assert k not in out
@pytest.mark.asyncio
async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
# Ensure "next_update > now" is true
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(response=_FakeResponse(status=200, text_value="OK")),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
@pytest.mark.asyncio
async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
# Force it to send now
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
data = {k: "x" for k in PURGE_DATA}
data.update({"keep": "1"})
ok = await wp.push_data_to_windy(data, wslink=False)
assert ok is True
assert len(session.calls) == 1
call = session.calls[0]
assert call["url"] == WINDY_URL
# Purged keys removed
for k in PURGE_DATA:
assert k not in call["params"]
# Added keys
assert call["params"]["id"] == entry.options[WINDY_STATION_ID]
assert call["params"]["time"] == "now"
assert (
call["headers"]["Authorization"] == f"Bearer {entry.options[WINDY_STATION_PW]}"
)
@pytest.mark.asyncio
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, wslink=True)
assert ok is True
params = session.calls[0]["params"]
assert "wind" in params and params["wind"] == "1"
assert "temp" in params and params["temp"] == "2"
assert "t1ws" not in params and "t1tem" not in params
@pytest.mark.asyncio
async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch, hass):
entry = _make_entry()
entry.options.pop(WINDY_STATION_ID)
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
# Disabling must not overwrite the specific reason the diagnostics sensor reports.
assert wp.last_status == "config_error"
@pytest.mark.asyncio
async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch, hass):
entry = _make_entry()
entry.options.pop(WINDY_STATION_PW)
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
assert wp.last_status == "config_error"
@pytest.mark.asyncio
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
# Response triggers WindyPasswordMissing (401)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
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.async_create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
assert wp.last_status == "auth_error"
@pytest.mark.asyncio
async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_debug(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=False)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
dbg.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_notice_logs_not_inserted(monkeypatch, hass):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
err = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.error", err)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
# It logs WINDY_NOT_INSERTED regardless of log setting
err.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
monkeypatch, hass
):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.info", info)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
# It should log WINDY_SUCCESS (or at least call info) when logging is enabled
info.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_logger_enabled(
monkeypatch, hass
):
"""Cover the `else:` branch when `verify_windy_response` does not raise.
This is a defensive branch in `push_data_to_windy`:
try: verify(...)
except ...:
else:
if self.log:
_LOGGER.debug(WINDY_NOT_INSERTED)
"""
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
debug = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", debug)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
debug.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_client_error_increments_and_disables_after_three(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
# Cause ClientError on session.get
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
# First 3 calls should not disable; 4th should
for i in range(4):
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.invalid_response_count == 4
# update_options awaited once when count > 3
update_options.assert_awaited()
args = update_options.await_args.args
assert args[2] == WINDY_ENABLED
assert args[3] is False
# It should log WINDY_UNEXPECTED at least once
assert any(
WINDY_UNEXPECTED in str(c.args[0]) for c in crit.call_args_list if c.args
)
@pytest.mark.asyncio
async def test_push_data_to_windy_timeout_is_handled_like_client_error(
monkeypatch, hass
):
"""A network timeout must not escape into the webhook handler.
`TimeoutError` is not a subclass of `ClientError`, so it used to propagate out
of `push_data_to_windy`, through the awaiting coordinator, and answer the
station with HTTP 500 - even though the measured data was already stored.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", AsyncMock(return_value=True)
)
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", MagicMock())
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.last_status == "client_error"
assert wp.last_error == "TimeoutError"
assert wp.invalid_response_count == 1
@pytest.mark.asyncio
async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.invalid_response_count = 3 # next error will push it over the threshold
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=False)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(),
)
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
dbg.assert_called()

View File

@ -0,0 +1,153 @@
"""Heat / wind-chill indices must work on WSLink too.
`_auto_enable_derived_sensors` creates both entities as soon as temperature and
humidity (resp. wind speed) arrive, but the WSLink descriptions only ever read the
station's own ``t1heat`` / ``t1chill``. A station that does not report those left both
entities Unavailable forever, while the WU/PWS platform computed them.
The payload is metric, the NWS formulas are Fahrenheit-based (and mph for wind chill),
and the WSLink entities are declared in Celsius - so the conversions matter as much as
the fallback itself.
"""
from __future__ import annotations
import pytest
from custom_components.sws12500.const import (
CHILL_INDEX,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
WIND_SPEED,
)
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
from custom_components.sws12500.utils import (
celsius_to_fahrenheit,
chill_index,
fahrenheit_to_celsius,
heat_index,
wslink_chill_index,
wslink_heat_index,
)
# The remapped form of a real payload from a station that sends neither t1heat nor
# t1chill (t1tem/t1hum/t1ws in metric units).
LIVE_PAYLOAD: dict[str, str] = {
OUTSIDE_TEMP: "6.2",
OUTSIDE_HUMIDITY: "40",
WIND_SPEED: "30.6",
}
def _description(key: str):
return next(desc for desc in SENSOR_TYPES_WSLINK if desc.key == key)
# ---------------------------------------------------------------------------
# Fallback
# ---------------------------------------------------------------------------
def test_indices_are_computed_when_the_station_omits_them() -> None:
"""The reported bug: both entities stayed empty for this exact payload."""
assert wslink_heat_index(LIVE_PAYLOAD) is not None
assert wslink_chill_index(LIVE_PAYLOAD) is not None
def test_computed_values_are_celsius_and_plausible() -> None:
"""6.2 C at 40% RH with strong wind: heat index near ambient, chill below it."""
heat = wslink_heat_index(LIVE_PAYLOAD)
chill = wslink_chill_index(LIVE_PAYLOAD)
assert heat is not None and chill is not None
# Sanity-check the magnitude - a Fahrenheit result leaking through would be ~39/28.
assert -10 < heat < 15, heat
assert -10 < chill < 15, chill
# Wind chill must be colder than the still-air temperature at 30.6 m/s.
assert chill < 6.2
def test_wind_is_converted_to_mph() -> None:
"""`chill_index` converts temperature but not wind, and its formula needs mph.
Feeding m/s straight in understates the wind and yields a warmer chill.
"""
correct = wslink_chill_index(LIVE_PAYLOAD)
naive_f = chill_index(
{OUTSIDE_TEMP: celsius_to_fahrenheit(6.2), WIND_SPEED: 30.6}, # m/s, unconverted
)
assert naive_f is not None
naive = round(fahrenheit_to_celsius(naive_f), 2)
assert correct is not None
assert correct < naive, f"unconverted wind gives {naive}, converted gives {correct}"
# ---------------------------------------------------------------------------
# The station's own reading still wins
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("fn", "key"),
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
ids=["heat", "chill"],
)
def test_station_reported_value_takes_precedence(fn, key) -> None:
"""t1heat / t1chill are already Celsius and must be passed through untouched."""
assert fn({**LIVE_PAYLOAD, key: "3.5"}) == 3.5
@pytest.mark.parametrize(
("fn", "key"),
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
ids=["heat", "chill"],
)
def test_empty_reported_value_falls_back_to_computing(fn, key) -> None:
"""The station sends "" for absent fields; that must not shadow the fallback."""
assert fn({**LIVE_PAYLOAD, key: ""}) is not None
# ---------------------------------------------------------------------------
# Missing inputs stay quiet
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, OUTSIDE_HUMIDITY])
def test_heat_index_needs_temp_and_humidity(missing) -> None:
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
assert wslink_heat_index(payload) is None
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, WIND_SPEED])
def test_chill_index_needs_temp_and_wind(missing) -> None:
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
assert wslink_chill_index(payload) is None
def test_no_inputs_at_all_is_none() -> None:
assert wslink_heat_index({}) is None
assert wslink_chill_index({}) is None
# ---------------------------------------------------------------------------
# Wiring
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("key", "expected"), [(HEAT_INDEX, wslink_heat_index), (CHILL_INDEX, wslink_chill_index)])
def test_descriptions_use_the_wslink_helpers(key, expected) -> None:
"""`value_from_data_fn` wins in WeatherSensor.native_value, so it must be set."""
desc = _description(key)
assert desc.value_from_data_fn is expected
def test_wu_platform_still_computes_in_fahrenheit() -> None:
"""The WU descriptions are Fahrenheit-native; they must keep the raw helpers."""
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
by_key = {d.key: d for d in SENSOR_TYPES_WEATHER_API}
assert by_key[HEAT_INDEX].value_from_data_fn is heat_index
assert by_key[CHILL_INDEX].value_from_data_fn is chill_index

8
tools/.cargo/config.toml Normal file
View File

@ -0,0 +1,8 @@
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
[env]
CC_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-gcc"
CXX_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-g++"
AR_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-ar"

@ -0,0 +1 @@
Subproject commit 81539fc79d8c3af267241ad6fd63b1924583354f