Merge 0e5a8ce10c into 7582f1e9b4
commit
08e9891925
|
|
@ -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
119
README.md
|
|
@ -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 ~2–3 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 1–5 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 1–5 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.
|
||||

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

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

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

|
||||
|
||||
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
|
||||
- In dialog box choose `Windy configuration`.
|
||||

|
||||
|
||||
- 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`.
|
||||

|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"indoor_battery": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"state": {
|
||||
"low": "mdi:battery-low",
|
||||
"normal": "mdi:battery",
|
||||
"drained": "mdi:battery-alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
../dev/custom_components/sws12500
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
"""Tests for the SWS12500 config entry diagnostics.
|
||||
|
||||
These cover both branches of `async_get_config_entry_diagnostics`:
|
||||
- `runtime_data.health_data` is populated and returned directly.
|
||||
- `runtime_data.health_data` is None, so it falls back to the live
|
||||
`health_coordinator.data` snapshot.
|
||||
|
||||
In both cases secret keys listed in `TO_REDACT` must be replaced by the
|
||||
Home Assistant `async_redact_data` sentinel, while non-secret values pass
|
||||
through untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.sws12500.const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DOMAIN,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
WINDY_STATION_ID,
|
||||
WINDY_STATION_PW,
|
||||
)
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from custom_components.sws12500.diagnostics import async_get_config_entry_diagnostics
|
||||
|
||||
REDACTED = "**REDACTED**"
|
||||
|
||||
|
||||
def _make_runtime(*, health_data, health_coordinator) -> SWSRuntimeData:
|
||||
"""Build a runtime data container with lightweight stub coordinators.
|
||||
|
||||
Diagnostics only ever reads `health_data` and `health_coordinator.data`,
|
||||
so the real coordinators can be replaced with plain stubs.
|
||||
"""
|
||||
return SWSRuntimeData(
|
||||
coordinator=object(), # type: ignore[arg-type]
|
||||
health_coordinator=health_coordinator, # type: ignore[arg-type]
|
||||
last_options={},
|
||||
health_data=health_data,
|
||||
)
|
||||
|
||||
|
||||
async def test_diagnostics_uses_persisted_health_data(hass) -> None:
|
||||
"""When `health_data` is present it is returned and secrets are redacted."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
API_ID: "secret-api-id",
|
||||
API_KEY: "secret-api-key",
|
||||
"name": "Station",
|
||||
},
|
||||
options={
|
||||
WINDY_STATION_ID: "secret-windy-id",
|
||||
WINDY_STATION_PW: "secret-windy-pw",
|
||||
"interval": 60,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
health_data = {
|
||||
"ID": "secret-station-id",
|
||||
"PASSWORD": "secret-station-pw",
|
||||
POCASI_CZ_API_ID: "secret-pocasi-id",
|
||||
POCASI_CZ_API_KEY: "secret-pocasi-key",
|
||||
"wsid": "secret-wsid",
|
||||
"wspw": "secret-wspw",
|
||||
"status": "ok",
|
||||
}
|
||||
# A separate coordinator snapshot that must NOT be used in this branch.
|
||||
health_coordinator = SimpleNamespace(data={"status": "stale-should-not-appear"})
|
||||
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=health_data,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
# entry_data: secrets redacted, plain value preserved.
|
||||
assert result["entry_data"][API_ID] == REDACTED
|
||||
assert result["entry_data"][API_KEY] == REDACTED
|
||||
assert result["entry_data"]["name"] == "Station"
|
||||
|
||||
# entry_options: secrets redacted, plain value preserved.
|
||||
assert result["entry_options"][WINDY_STATION_ID] == REDACTED
|
||||
assert result["entry_options"][WINDY_STATION_PW] == REDACTED
|
||||
assert result["entry_options"]["interval"] == 60
|
||||
|
||||
# health_data: the persisted payload is used (not the coordinator snapshot).
|
||||
assert result["health_data"]["status"] == "ok"
|
||||
assert result["health_data"]["ID"] == REDACTED
|
||||
assert result["health_data"]["PASSWORD"] == REDACTED
|
||||
assert result["health_data"][POCASI_CZ_API_ID] == REDACTED
|
||||
assert result["health_data"][POCASI_CZ_API_KEY] == REDACTED
|
||||
assert result["health_data"]["wsid"] == REDACTED
|
||||
assert result["health_data"]["wspw"] == REDACTED
|
||||
|
||||
# The original payload must be untouched (deepcopy is used internally).
|
||||
assert health_data["ID"] == "secret-station-id"
|
||||
|
||||
|
||||
async def test_diagnostics_falls_back_to_coordinator_data(hass) -> None:
|
||||
"""When `health_data` is None it falls back to the coordinator snapshot."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={API_ID: "secret-api-id", "name": "Station"},
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
coordinator_data = {
|
||||
"ID": "secret-station-id",
|
||||
"PASSWORD": "secret-station-pw",
|
||||
"status": "live",
|
||||
}
|
||||
health_coordinator = SimpleNamespace(data=coordinator_data)
|
||||
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=None,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["entry_data"][API_ID] == REDACTED
|
||||
assert result["entry_data"]["name"] == "Station"
|
||||
assert result["entry_options"] == {}
|
||||
|
||||
# Fallback snapshot is used and redacted.
|
||||
assert result["health_data"]["status"] == "live"
|
||||
assert result["health_data"]["ID"] == REDACTED
|
||||
assert result["health_data"]["PASSWORD"] == REDACTED
|
||||
|
||||
|
||||
async def test_diagnostics_empty_health_data(hass) -> None:
|
||||
"""A falsy fallback snapshot yields an empty health_data dict."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
health_coordinator = SimpleNamespace(data=None)
|
||||
entry.runtime_data = _make_runtime(
|
||||
health_data=None,
|
||||
health_coordinator=health_coordinator,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["health_data"] == {}
|
||||
assert result["entry_data"] == {}
|
||||
assert result["entry_options"] == {}
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Test file for sensors_common.py module
|
||||
|
||||
def test_sensors_common_functionality():
|
||||
# Add your test cases here
|
||||
pass
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Test file for sensors_weather.py module
|
||||
|
||||
def test_sensors_weather_functionality():
|
||||
# Add your test cases here
|
||||
pass
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
# Test file for translations/cs.json module
|
||||
|
||||
def test_translations_cs_functionality():
|
||||
# Add your test cases here
|
||||
pass
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Test file for translations/en.json module
|
||||
|
||||
def test_translations_en_functionality():
|
||||
# Add your test cases here
|
||||
pass
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Test file for windy_func.py module
|
||||
|
||||
def test_windy_func_functionality():
|
||||
# Add your test cases here
|
||||
pass
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue