Merge 1fec8313d4 into 9f36ab5d4c
commit
e2f4adb22e
|
|
@ -1,21 +1,52 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
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.exceptions import (
|
||||
ConfigEntryNotReady,
|
||||
InvalidStateError,
|
||||
PlatformNotReady,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DEFAULT_URL,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
POCASI_CZ_ENABLED,
|
||||
SENSORS_TO_LOAD,
|
||||
|
|
@ -23,8 +54,9 @@ from .const import (
|
|||
WSLINK,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from .data import ENTRY_COORDINATOR, ENTRY_LAST_OPTIONS
|
||||
from .pocasti_cz import PocasiPush
|
||||
from .routes import Routes, unregistred
|
||||
from .routes import Routes
|
||||
from .utils import (
|
||||
anonymize,
|
||||
check_disabled,
|
||||
|
|
@ -45,24 +77,54 @@ class IncorrectDataError(InvalidStateError):
|
|||
"""Invalid exception."""
|
||||
|
||||
|
||||
# NOTE:
|
||||
# We intentionally avoid importing the sensor platform module at import-time here.
|
||||
# Home Assistant can import modules in different orders; keeping imports acyclic
|
||||
# prevents "partially initialized module" failures (circular imports / partially initialized modules).
|
||||
#
|
||||
# When we need to dynamically add sensors, we do a local import inside the webhook handler.
|
||||
|
||||
|
||||
class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Manage fetched data."""
|
||||
"""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 to this coordinator update themselves
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||
"""Init global updater."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.windy = WindyPush(hass, config)
|
||||
"""Initialize the coordinator.
|
||||
|
||||
`config` is the config entry for this integration instance. We store it because
|
||||
the webhook handler needs access to options (auth data, enabled features, etc.).
|
||||
"""
|
||||
self.hass: HomeAssistant = hass
|
||||
self.config: ConfigEntry = config
|
||||
self.windy: WindyPush = WindyPush(hass, config)
|
||||
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||
|
||||
async def recieved_data(self, webdata):
|
||||
"""Handle incoming data query."""
|
||||
_wslink = self.config_entry.options.get(WSLINK)
|
||||
data = webdata.query
|
||||
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
"""Handle incoming webhook payload from the station.
|
||||
|
||||
response = None
|
||||
This method:
|
||||
- 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
|
||||
"""
|
||||
|
||||
# WSLink uses different auth and payload field naming than the legacy endpoint.
|
||||
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
||||
|
||||
# Incoming station payload is delivered as query params.
|
||||
# We copy it to a plain dict so it can be passed around safely.
|
||||
data: dict[str, Any] = dict(webdata.query)
|
||||
|
||||
# Validate auth keys (different parameter names depending on endpoint mode).
|
||||
if not _wslink and ("ID" not in data or "PASSWORD" not in data):
|
||||
_LOGGER.error("Invalid request. No security data provided!")
|
||||
raise HTTPUnauthorized
|
||||
|
|
@ -71,44 +133,73 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
_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_data: str = ""
|
||||
key_data: str = ""
|
||||
|
||||
_id = self.config_entry.options.get(API_ID)
|
||||
_key = self.config_entry.options.get(API_KEY)
|
||||
if _wslink:
|
||||
id_data = data.get("wsid", "")
|
||||
key_data = data.get("wspw", "")
|
||||
else:
|
||||
id_data = data.get("ID", "")
|
||||
key_data = data.get("PASSWORD", "")
|
||||
|
||||
# Validate credentials against the integration's configured options.
|
||||
# If auth doesn't match, we reject the request (prevents random pushes from the LAN/Internet).
|
||||
|
||||
if (_id := checked(self.config.options.get(API_ID), str)) is None:
|
||||
_LOGGER.error("We don't have API ID set! Update your config!")
|
||||
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!")
|
||||
raise IncorrectDataError
|
||||
|
||||
if id_data != _id or key_data != _key:
|
||||
_LOGGER.error("Unauthorised access!")
|
||||
raise HTTPUnauthorized
|
||||
|
||||
if self.config_entry.options.get(WINDY_ENABLED):
|
||||
response = await self.windy.push_data_to_windy(data)
|
||||
# Optional forwarding to external services. This is kept here (in the webhook handler)
|
||||
# to avoid additional background polling tasks.
|
||||
if self.config.options.get(WINDY_ENABLED, False):
|
||||
await self.windy.push_data_to_windy(data)
|
||||
|
||||
if self.config.options.get(POCASI_CZ_ENABLED):
|
||||
if self.config.options.get(POCASI_CZ_ENABLED, False):
|
||||
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)
|
||||
# Convert raw payload keys to our internal sensor keys (stable identifiers).
|
||||
remaped_items: dict[str, str] = (
|
||||
remap_wslink_items(data) if _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"
|
||||
# Auto-discovery: if payload contains keys that are not enabled/loaded yet,
|
||||
# add them to the option list and create entities dynamically.
|
||||
if sensors := check_disabled(remaped_items, self.config):
|
||||
if (
|
||||
translate_sensors := checked(
|
||||
[
|
||||
await translations(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"sensor.{t_key}",
|
||||
key="name",
|
||||
category="entity",
|
||||
)
|
||||
for t_key in sensors
|
||||
if await translations(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"sensor.{t_key}",
|
||||
key="name",
|
||||
category="entity",
|
||||
)
|
||||
is not None
|
||||
],
|
||||
list[str],
|
||||
)
|
||||
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)
|
||||
) is not None:
|
||||
human_readable: str = "\n".join(translate_sensors)
|
||||
else:
|
||||
human_readable = ""
|
||||
|
||||
await translated_notification(
|
||||
self.hass,
|
||||
|
|
@ -116,117 +207,143 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
"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)
|
||||
|
||||
# Persist newly discovered sensor keys to options (so they remain enabled after restart).
|
||||
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)
|
||||
|
||||
# Dynamically add newly discovered sensors *without* reloading the entry.
|
||||
#
|
||||
# Why: Reloading a config entry unloads platforms temporarily. That removes coordinator
|
||||
# listeners; with frequent webhook pushes the UI can appear "frozen" until the listeners
|
||||
# are re-established. Dynamic adds avoid this window completely.
|
||||
#
|
||||
# We do a local import to avoid circular imports at module import time.
|
||||
#
|
||||
# NOTE: Some linters prefer top-level imports. In this case the local import is
|
||||
# intentional and prevents "partially initialized module" errors.
|
||||
|
||||
from .sensor import ( # noqa: PLC0415 (local import is intentional)
|
||||
add_new_sensors,
|
||||
)
|
||||
|
||||
add_new_sensors(self.hass, self.config, newly_discovered)
|
||||
|
||||
# Fan-out update: notify all subscribed entities.
|
||||
self.async_set_updated_data(remaped_items)
|
||||
|
||||
if self.config_entry.options.get(DEV_DBG):
|
||||
# Optional dev logging (keep it lightweight to avoid log spam under high-frequency updates).
|
||||
if self.config.options.get("dev_debug_checkbox"):
|
||||
_LOGGER.info("Dev log: %s", anonymize(data))
|
||||
|
||||
response = response or "OK"
|
||||
return aiohttp.web.Response(body=f"{response or 'OK'}", status=200)
|
||||
return aiohttp.web.Response(body="OK", status=200)
|
||||
|
||||
|
||||
def register_path(
|
||||
hass: HomeAssistant,
|
||||
url_path: str,
|
||||
coordinator: WeatherDataUpdateCoordinator,
|
||||
config: ConfigEntry,
|
||||
):
|
||||
"""Register path to handle incoming data."""
|
||||
) -> bool:
|
||||
"""Register webhook paths.
|
||||
|
||||
hass_data = hass.data.setdefault(DOMAIN, {})
|
||||
debug = config.options.get(DEV_DBG)
|
||||
_wslink = config.options.get(WSLINK, False)
|
||||
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.
|
||||
"""
|
||||
|
||||
routes: Routes = hass_data.get("routes", Routes())
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
if (hass_data := checked(hass.data[DOMAIN], dict[str, Any])) is None:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
if not routes.routes:
|
||||
routes = Routes()
|
||||
_LOGGER.info("Routes not found, creating new routes")
|
||||
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||
|
||||
if debug:
|
||||
_LOGGER.debug("Enabled route is: %s, WSLink is %s", url_path, _wslink)
|
||||
# Create internal route dispatcher with provided urls
|
||||
routes: Routes = Routes()
|
||||
routes.add_route(DEFAULT_URL, coordinator.received_data, enabled=not _wslink)
|
||||
routes.add_route(WSLINK_URL, coordinator.received_data, enabled=_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)
|
||||
# Register webhooks in HomeAssistant with dispatcher
|
||||
try:
|
||||
_ = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch)
|
||||
_ = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch)
|
||||
|
||||
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)
|
||||
# Save initialised routes
|
||||
hass_data["routes"] = routes
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
except RuntimeError as Ex:
|
||||
_LOGGER.critical(
|
||||
"Routes cannot be added. Integration will not work as expected. %s", Ex
|
||||
)
|
||||
|
||||
if _wslink:
|
||||
routes.switch_route(coordinator.recieved_data, WSLINK_URL)
|
||||
raise ConfigEntryNotReady from Ex
|
||||
else:
|
||||
routes.switch_route(coordinator.recieved_data, DEFAULT_URL)
|
||||
|
||||
return routes
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the config entry for my device."""
|
||||
"""Set up a config entry.
|
||||
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
Important:
|
||||
- We store per-entry runtime state under `hass.data[DOMAIN][entry_id]` as a dict.
|
||||
- We reuse the same coordinator instance across reloads so that:
|
||||
- the webhook handler keeps updating the same coordinator
|
||||
- already-created entities remain subscribed
|
||||
|
||||
hass_data = hass.data.setdefault(DOMAIN, {})
|
||||
hass_data[entry.entry_id] = coordinator
|
||||
"""
|
||||
|
||||
_wslink = entry.options.get(WSLINK)
|
||||
debug = entry.options.get(DEV_DBG)
|
||||
hass_data_any = hass.data.setdefault(DOMAIN, {})
|
||||
hass_data = cast("dict[str, Any]", hass_data_any)
|
||||
|
||||
if debug:
|
||||
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
||||
# Per-entry runtime storage:
|
||||
# hass.data[DOMAIN][entry_id] is always a dict (never the coordinator itself).
|
||||
# Mixing types here (sometimes dict, sometimes coordinator) is a common source of hard-to-debug
|
||||
# issues where entities stop receiving updates.
|
||||
entry_data_any = hass_data.get(entry.entry_id)
|
||||
if not isinstance(entry_data_any, dict):
|
||||
entry_data_any = {}
|
||||
hass_data[entry.entry_id] = entry_data_any
|
||||
entry_data = cast("dict[str, Any]", entry_data_any)
|
||||
|
||||
route = register_path(
|
||||
hass, DEFAULT_URL if not _wslink else WSLINK_URL, coordinator, entry
|
||||
)
|
||||
# Reuse the existing coordinator across reloads so webhook handlers and entities
|
||||
# remain connected to the same coordinator instance.
|
||||
#
|
||||
# Note: Routes store a bound method (`coordinator.received_data`). If we replaced the coordinator
|
||||
# instance on reload, the dispatcher could keep calling the old instance while entities listen
|
||||
# to the new one, causing updates to "disappear".
|
||||
coordinator_any = entry_data.get(ENTRY_COORDINATOR)
|
||||
if isinstance(coordinator_any, WeatherDataUpdateCoordinator):
|
||||
coordinator_any.config = entry
|
||||
|
||||
if not route:
|
||||
_LOGGER.error("Fatal: path not registered!")
|
||||
raise PlatformNotReady
|
||||
# Recreate helper instances so they pick up updated options safely.
|
||||
coordinator_any.windy = WindyPush(hass, entry)
|
||||
coordinator_any.pocasi = PocasiPush(hass, entry)
|
||||
coordinator = coordinator_any
|
||||
else:
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
entry_data[ENTRY_COORDINATOR] = coordinator
|
||||
|
||||
hass_data["route"] = route
|
||||
routes: Routes | None = hass_data.get("routes", None)
|
||||
|
||||
# Keep an options snapshot so update_listener can skip reloads when only `SENSORS_TO_LOAD` changes.
|
||||
# Auto-discovery updates this option frequently and we do not want to reload for that case.
|
||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||
|
||||
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
||||
|
||||
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
||||
|
||||
if routes:
|
||||
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
||||
routes.switch_route(DEFAULT_URL if not _wslink else WSLINK_URL)
|
||||
_LOGGER.debug("%s", routes.show_enabled())
|
||||
else:
|
||||
routes_enabled = register_path(hass, coordinator, entry)
|
||||
|
||||
if not routes_enabled:
|
||||
_LOGGER.error("Fatal: path not registered!")
|
||||
raise PlatformNotReady
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
|
@ -236,10 +353,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Update setup listener."""
|
||||
"""Handle config entry option updates.
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
We skip reloading when only `SENSORS_TO_LOAD` changes.
|
||||
|
||||
Why:
|
||||
- Auto-discovery updates `SENSORS_TO_LOAD` as new payload fields appear.
|
||||
- Reloading a push-based integration temporarily unloads platforms and removes
|
||||
coordinator listeners, which can make the UI appear "stuck" until restart.
|
||||
"""
|
||||
hass_data_any = hass.data.get(DOMAIN)
|
||||
if isinstance(hass_data_any, dict):
|
||||
hass_data = cast("dict[str, Any]", hass_data_any)
|
||||
entry_data_any = hass_data.get(entry.entry_id)
|
||||
if isinstance(entry_data_any, dict):
|
||||
entry_data = cast("dict[str, Any]", entry_data_any)
|
||||
|
||||
old_options_any = entry_data.get(ENTRY_LAST_OPTIONS)
|
||||
if isinstance(old_options_any, dict):
|
||||
old_options = cast("dict[str, Any]", old_options_any)
|
||||
new_options = dict(entry.options)
|
||||
|
||||
changed_keys = {
|
||||
k
|
||||
for k in set(old_options.keys()) | set(new_options.keys())
|
||||
if old_options.get(k) != new_options.get(k)
|
||||
}
|
||||
|
||||
# Update snapshot early for the next comparison.
|
||||
entry_data[ENTRY_LAST_OPTIONS] = new_options
|
||||
|
||||
if changed_keys == {SENSORS_TO_LOAD}:
|
||||
_LOGGER.debug(
|
||||
"Options updated (%s); skipping reload.", SENSORS_TO_LOAD
|
||||
)
|
||||
return
|
||||
else:
|
||||
# No/invalid snapshot: store current options for next comparison.
|
||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||
|
||||
_ = await hass.config_entries.async_reload(entry.entry_id)
|
||||
_LOGGER.info("Settings updated")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
"""Config flow for Sencor SWS 12500 Weather Station integration."""
|
||||
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
INVALID_CREDENTIALS,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
|
|
@ -51,10 +61,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
self.migrate_schema = {}
|
||||
self.pocasi_cz: dict[str, Any] = {}
|
||||
self.pocasi_cz_schema = {}
|
||||
self.ecowitt: dict[str, Any] = {}
|
||||
self.ecowitt_schema = {}
|
||||
|
||||
@property
|
||||
def config_entry(self):
|
||||
return self.hass.config_entries.async_get_entry(self.handler)
|
||||
# @property
|
||||
# def config_entry(self) -> ConfigEntry:
|
||||
# return self.hass.config_entries.async_get_entry(self.handler)
|
||||
|
||||
async def _get_entry_data(self):
|
||||
"""Get entry data."""
|
||||
|
|
@ -133,15 +145,20 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
): bool,
|
||||
}
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
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_init(self, user_input: dict[str, Any] = {}):
|
||||
"""Manage the options - show menu first."""
|
||||
return self.async_show_menu(
|
||||
step_id="init", menu_options=["basic", "windy", "pocasi"]
|
||||
step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"]
|
||||
)
|
||||
|
||||
async def async_step_basic(self, user_input=None):
|
||||
async def async_step_basic(self, user_input: Any = None):
|
||||
"""Manage basic options - credentials."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
await self._get_entry_data()
|
||||
|
||||
|
|
@ -159,14 +176,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
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)
|
||||
user_input = self.retain_data(user_input)
|
||||
|
||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||
|
||||
|
|
@ -179,9 +189,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()
|
||||
|
||||
|
|
@ -200,22 +210,14 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
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()
|
||||
|
||||
|
|
@ -241,17 +243,63 @@ 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 None:
|
||||
url: URL = URL(get_url(self.hass))
|
||||
|
||||
if not url.host:
|
||||
url.host = "UNKNOWN"
|
||||
|
||||
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": url.host,
|
||||
"port": str(url.port),
|
||||
"webhook_id": webhook,
|
||||
},
|
||||
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]:
|
||||
"""Retain user_data."""
|
||||
|
||||
return {
|
||||
**self.user_data,
|
||||
**self.windy_data,
|
||||
**self.pocasi_cz,
|
||||
**self.sensors,
|
||||
**self.ecowitt,
|
||||
**dict(data),
|
||||
}
|
||||
|
||||
|
||||
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
||||
|
|
@ -265,7 +313,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
async def async_step_user(self, user_input: Any = None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
|
|
@ -276,7 +324,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
data_schema=vol.Schema(self.data_schema),
|
||||
)
|
||||
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input[API_ID] in INVALID_CREDENTIALS:
|
||||
errors[API_ID] = "valid_credentials_api"
|
||||
|
|
@ -297,6 +345,6 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry) -> ConfigOptionsFlowHandler:
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return ConfigOptionsFlowHandler()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ DATABASE_PATH = "/config/home-assistant_v2.db"
|
|||
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
|
||||
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
|
||||
|
||||
|
||||
ICON = "mdi:weather"
|
||||
|
||||
API_KEY = "API_KEY"
|
||||
|
|
@ -23,6 +24,10 @@ SENSOR_TO_MIGRATE: Final = "sensor_to_migrate"
|
|||
DEV_DBG: Final = "dev_debug_checkbox"
|
||||
WSLINK: Final = "wslink"
|
||||
|
||||
ECOWITT: Final = "ecowitt"
|
||||
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
|
||||
ECOWITT_ENABLED: Final = "ecowitt_enabled"
|
||||
|
||||
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
||||
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
||||
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
|
||||
|
|
@ -244,7 +249,7 @@ class UnitOfBat(StrEnum):
|
|||
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
UNKNOWN = "unknown"
|
||||
UNKNOWN = "drained"
|
||||
|
||||
|
||||
BATTERY_LEVEL: list[UnitOfBat] = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
"""Shared keys for storing integration runtime state in `hass.data`.
|
||||
|
||||
This integration stores runtime state under:
|
||||
|
||||
hass.data[DOMAIN][entry_id] -> dict
|
||||
|
||||
Keeping keys in a dedicated module prevents subtle bugs where different modules
|
||||
store different types under the same key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Per-entry dict keys stored under hass.data[DOMAIN][entry_id]
|
||||
ENTRY_COORDINATOR: Final[str] = "coordinator"
|
||||
ENTRY_ADD_ENTITIES: Final[str] = "async_add_entities"
|
||||
ENTRY_DESCRIPTIONS: Final[str] = "sensor_descriptions"
|
||||
ENTRY_LAST_OPTIONS: Final[str] = "last_options"
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"indoor_battery": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"state": {
|
||||
"low": "mdi:battery-low",
|
||||
"normal": "mdi:battery",
|
||||
"drained": "mdi:battery-alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
"homekit": {},
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
||||
"requirements": [],
|
||||
"requirements": ["typecheck-runtime==0.2.0"],
|
||||
"ssdp": [],
|
||||
"version": "1.6.9",
|
||||
"zeroconf": []
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import logging
|
|||
from typing import Any, Literal
|
||||
|
||||
from aiohttp import ClientError
|
||||
from py_typecheck.core import checked
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
@ -75,8 +76,20 @@ class PocasiPush:
|
|||
"""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)
|
||||
|
||||
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
||||
_LOGGER.error(
|
||||
"No API ID is provided for Pocasi Meteo. Check your configuration."
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)
|
||||
) is None:
|
||||
_LOGGER.error(
|
||||
"No API Key is provided for Pocasi Meteo. Check your configuration."
|
||||
)
|
||||
return
|
||||
|
||||
if self.log:
|
||||
_LOGGER.info(
|
||||
|
|
@ -91,7 +104,7 @@ class PocasiPush:
|
|||
self._interval,
|
||||
self.next_update,
|
||||
)
|
||||
return False
|
||||
return
|
||||
|
||||
request_url: str = ""
|
||||
if mode == "WSLINK":
|
||||
|
|
@ -139,5 +152,3 @@ class PocasiPush:
|
|||
|
||||
if self.log:
|
||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,77 +1,102 @@
|
|||
"""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 collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
Handler = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
|
||||
@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
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation."""
|
||||
return f"{self.url_path} -> {self.handler}"
|
||||
fallback: Handler = field(default_factory=lambda: unregistred)
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
def switch_route(self, coordinator: Callable, url_path: str):
|
||||
"""Switch route."""
|
||||
async def dispatch(self, request: Request) -> Response:
|
||||
"""Dispatch incoming request to either the enabled handler or a fallback."""
|
||||
info = self.routes.get(request.path)
|
||||
if not info:
|
||||
_LOGGER.debug("Route %s is not registered!", request.path)
|
||||
return await unregistred(request)
|
||||
handler = info.handler if info.enabled else info.fallback
|
||||
return await handler(request)
|
||||
|
||||
for url, route in self.routes.items():
|
||||
if url == url_path:
|
||||
_LOGGER.info("New coordinator to route: %s", route.url_path)
|
||||
route.enabled = True
|
||||
route.handler = coordinator
|
||||
route.route._handler = coordinator # noqa: SLF001
|
||||
else:
|
||||
route.enabled = False
|
||||
route.handler = unregistred
|
||||
route.route._handler = unregistred # noqa: SLF001
|
||||
def switch_route(self, url_path: str) -> None:
|
||||
"""Enable exactly one route and disable all others.
|
||||
|
||||
This is called when options change (e.g. WSLink toggle). The aiohttp router stays
|
||||
untouched; we only flip which internal handler is active.
|
||||
"""
|
||||
for path, info in self.routes.items():
|
||||
info.enabled = path == url_path
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
url_path: str,
|
||||
route: AbstractRoute,
|
||||
handler: Callable,
|
||||
enabled: bool = False,
|
||||
):
|
||||
"""Add route."""
|
||||
self.routes[url_path] = Route(url_path, route, handler, enabled)
|
||||
self, url_path: str, handler: Handler, *, enabled: bool = False
|
||||
) -> None:
|
||||
"""Register a route in the dispatcher.
|
||||
|
||||
def get_route(self, url_path: str) -> Route:
|
||||
"""Get route."""
|
||||
return self.routes.get(url_path, Route)
|
||||
This does not register anything in aiohttp. It only stores routing metadata that
|
||||
`dispatch` uses after aiohttp has routed the request by path.
|
||||
"""
|
||||
self.routes[url_path] = RouteInfo(url_path, handler, enabled=enabled)
|
||||
_LOGGER.debug("Registered dispatcher for route %s", url_path)
|
||||
|
||||
def get_enabled(self) -> str:
|
||||
"""Get enabled routes."""
|
||||
enabled_routes = [
|
||||
route.url_path for route in self.routes.values() if route.enabled
|
||||
]
|
||||
return "".join(enabled_routes) if enabled_routes else "None"
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation."""
|
||||
return "\n".join([str(route) for route in self.routes.values()])
|
||||
def show_enabled(self) -> str:
|
||||
"""Return a human-readable description of the currently enabled route."""
|
||||
for url, route in self.routes.items():
|
||||
if route.enabled:
|
||||
return (
|
||||
f"Dispatcher enabled for URL: {url}, with handler: {route.handler}"
|
||||
)
|
||||
return "No routes is enabled."
|
||||
|
||||
|
||||
async def unregistred(*args, **kwargs):
|
||||
"""Unregister path to handle incoming data."""
|
||||
async def unregistred(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 unregistred or disabled webhook.")
|
||||
return Response(text="Unregistred webhook. Check your settings.", status=400)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,36 @@
|
|||
"""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 `hass.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.
|
||||
|
||||
Runtime state is stored under:
|
||||
hass.data[DOMAIN][entry_id] -> dict with known keys (see `data.py`)
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
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 .const import (
|
||||
BATTERY_LIST,
|
||||
CHILL_INDEX,
|
||||
DOMAIN,
|
||||
HEAT_INDEX,
|
||||
|
|
@ -23,133 +41,202 @@ from .const import (
|
|||
WIND_DIR,
|
||||
WIND_SPEED,
|
||||
WSLINK,
|
||||
UnitOfBat,
|
||||
)
|
||||
from .data import ENTRY_ADD_ENTITIES, ENTRY_COORDINATOR, ENTRY_DESCRIPTIONS
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The `async_add_entities` callback accepts a list of Entity-like objects.
|
||||
# We keep the type loose here to avoid propagating HA generics (`DataUpdateCoordinator[T]`)
|
||||
# that often end up as "partially unknown" under type-checkers.
|
||||
_AddEntitiesFn = Callable[[list[SensorEntity]], None]
|
||||
|
||||
|
||||
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,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Weather Station sensors."""
|
||||
"""Set up Weather Station sensors.
|
||||
|
||||
coordinator: WeatherDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
We also store `async_add_entities` and a map of sensor descriptions in `hass.data`
|
||||
so the webhook handler can add newly discovered entities dynamically without
|
||||
reloading the config entry.
|
||||
"""
|
||||
hass_data_any = hass.data.setdefault(DOMAIN, {})
|
||||
hass_data = cast("dict[str, Any]", hass_data_any)
|
||||
|
||||
sensors_to_load: list = []
|
||||
sensors: list = []
|
||||
_wslink = config_entry.options.get(WSLINK)
|
||||
entry_data_any = hass_data.get(config_entry.entry_id)
|
||||
if not isinstance(entry_data_any, dict):
|
||||
# Created by the integration setup, but keep this defensive for safety.
|
||||
entry_data_any = {}
|
||||
hass_data[config_entry.entry_id] = entry_data_any
|
||||
entry_data = cast("dict[str, Any]", entry_data_any)
|
||||
|
||||
SENSOR_TYPES = SENSOR_TYPES_WSLINK if _wslink else SENSOR_TYPES_WEATHER_API
|
||||
coordinator = entry_data.get(ENTRY_COORDINATOR)
|
||||
if coordinator is None:
|
||||
# Coordinator is created by the integration (`__init__.py`). Without it, we cannot set up entities.
|
||||
# This should not happen in normal operation; treat it as a no-op setup.
|
||||
return
|
||||
|
||||
# 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)
|
||||
# Store the platform callback so we can add entities later (auto-discovery) without reload.
|
||||
entry_data[ENTRY_ADD_ENTITIES] = async_add_entities
|
||||
|
||||
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)
|
||||
wslink_enabled = checked_or(config_entry.options.get(WSLINK), bool, False)
|
||||
sensor_types = SENSOR_TYPES_WSLINK if wslink_enabled else SENSOR_TYPES_WEATHER_API
|
||||
|
||||
# Keep a descriptions map for dynamic entity creation by key.
|
||||
# When the station starts sending a new payload field, the webhook handler can
|
||||
# look up its description here and instantiate the matching entity.
|
||||
entry_data[ENTRY_DESCRIPTIONS] = {desc.key: desc for desc in sensor_types}
|
||||
|
||||
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: ConfigEntry, 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).
|
||||
"""
|
||||
hass_data_any = hass.data.get(DOMAIN)
|
||||
if not isinstance(hass_data_any, dict):
|
||||
return
|
||||
hass_data = cast("dict[str, Any]", hass_data_any)
|
||||
|
||||
entry_data_any = hass_data.get(config_entry.entry_id)
|
||||
if not isinstance(entry_data_any, dict):
|
||||
return
|
||||
entry_data = cast("dict[str, Any]", entry_data_any)
|
||||
|
||||
add_entities_any = entry_data.get(ENTRY_ADD_ENTITIES)
|
||||
descriptions_any = entry_data.get(ENTRY_DESCRIPTIONS)
|
||||
coordinator_any = entry_data.get(ENTRY_COORDINATOR)
|
||||
|
||||
if add_entities_any is None or descriptions_any is None or coordinator_any is None:
|
||||
return
|
||||
|
||||
add_entities_fn = cast("_AddEntitiesFn", add_entities_any)
|
||||
descriptions_map = cast(
|
||||
"dict[str, WeatherSensorEntityDescription]", descriptions_any
|
||||
)
|
||||
|
||||
new_entities: list[SensorEntity] = []
|
||||
for key in keys:
|
||||
desc = descriptions_map.get(key)
|
||||
if desc is None:
|
||||
continue
|
||||
new_entities.append(WeatherSensor(desc, coordinator_any))
|
||||
|
||||
if new_entities:
|
||||
add_entities_fn(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.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
description: WeatherSensorEntityDescription,
|
||||
coordinator: WeatherDataUpdateCoordinator,
|
||||
coordinator: Any,
|
||||
) -> 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()
|
||||
|
||||
@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]
|
||||
description = cast("WeatherSensorEntityDescription", self.entity_description)
|
||||
if description.value_from_data_fn is not None:
|
||||
return description.value_from_data_fn(data)
|
||||
|
||||
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]
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
|
||||
return (
|
||||
None if self._data == "" else self.entity_description.value_fn(self._data) # pyright: ignore[ reportAttributeAccessIssue]
|
||||
)
|
||||
if description.value_fn is None:
|
||||
return None
|
||||
|
||||
return description.value_fn(raw)
|
||||
|
||||
@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]
|
||||
@cached_property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
|
|
|
|||
|
|
@ -11,4 +11,7 @@ from homeassistant.components.sensor import SensorEntityDescription
|
|||
class WeatherSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe Weather Sensor entities."""
|
||||
|
||||
value_fn: Callable[[Any], int | float | str | None]
|
||||
value_fn: Callable[[Any], int | float | str | None] | None = None
|
||||
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | None] | None = (
|
||||
None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, wind_dir_to_text
|
||||
|
||||
SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -133,8 +133,11 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
key=WIND_AZIMUT,
|
||||
icon="mdi:sign-direction",
|
||||
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
|
||||
value_from_data_fn=lambda data: cast(
|
||||
"str", wind_dir_to_text(cast("float", data.get(WIND_DIR) or 0.0))
|
||||
),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(UnitOfDir),
|
||||
options=[e.value for e in UnitOfDir],
|
||||
translation_key=WIND_AZIMUT,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -244,6 +247,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
icon="mdi:weather-sunny",
|
||||
translation_key=HEAT_INDEX,
|
||||
value_fn=lambda data: cast("int", data),
|
||||
value_from_data_fn=lambda data: heat_index(data),
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CHILL_INDEX,
|
||||
|
|
@ -255,5 +259,6 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
icon="mdi:weather-sunny",
|
||||
translation_key=CHILL_INDEX,
|
||||
value_fn=lambda data: cast("int", data),
|
||||
value_from_data_fn=lambda data: chill_index(data),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,10 +44,11 @@ from .const import (
|
|||
WIND_GUST,
|
||||
WIND_SPEED,
|
||||
YEARLY_RAIN,
|
||||
UnitOfBat,
|
||||
UnitOfDir,
|
||||
)
|
||||
from .sensors_common import WeatherSensorEntityDescription
|
||||
from .utils import wind_dir_to_text
|
||||
from .utils import battery_level, wind_dir_to_text
|
||||
|
||||
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -139,8 +140,11 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
key=WIND_AZIMUT,
|
||||
icon="mdi:sign-direction",
|
||||
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
|
||||
value_from_data_fn=lambda data: cast(
|
||||
"str", wind_dir_to_text(cast("float", data.get(WIND_DIR) or 0.0))
|
||||
),
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(UnitOfDir),
|
||||
options=[e.value for e in UnitOfDir],
|
||||
translation_key=WIND_AZIMUT,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -265,25 +269,6 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
translation_key=CH3_HUMIDITY,
|
||||
value_fn=lambda data: cast("int", data),
|
||||
),
|
||||
# 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=HEAT_INDEX,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
|
|
@ -309,23 +294,32 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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=None,
|
||||
value_from_data_fn=lambda data: battery_level(
|
||||
data.get(INDOOR_BATTERY, None)
|
||||
).value,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=WBGT_TEMP,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,18 @@
|
|||
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
||||
}
|
||||
},
|
||||
"ecowitt": {
|
||||
"description": "Nastavení pro Ecowitt",
|
||||
"title": "Konfigurace pro stanice Ecowitt",
|
||||
"data": {
|
||||
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||
"ecowitt_enabled": "Povolit data ze stanice 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"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
|
@ -166,6 +178,21 @@
|
|||
"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_index": {
|
||||
"name": "WBGT index"
|
||||
},
|
||||
"wind_azimut": {
|
||||
"name": "Bearing",
|
||||
"state": {
|
||||
|
|
@ -185,14 +212,30 @@
|
|||
"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",
|
||||
"unknown": "Unknown / drained out"
|
||||
}
|
||||
},
|
||||
"ch2_battery": {
|
||||
"name": "Channel 2 battery level",
|
||||
"state": {
|
||||
"normal": "OK",
|
||||
"low": "Low",
|
||||
"unknown": "Unknown / drained out"
|
||||
}
|
||||
},
|
||||
"indoor_battery": {
|
||||
"name": "Console battery level",
|
||||
"state": {
|
||||
"normal": "OK",
|
||||
"low": "Low",
|
||||
"unknown": "Unknown / drained out"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"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",
|
||||
"ecowitt": "Nastavení pro stanice Ecowitt",
|
||||
"migration": "Migrace statistiky senzoru"
|
||||
}
|
||||
},
|
||||
|
|
@ -92,6 +93,18 @@
|
|||
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
|
||||
}
|
||||
},
|
||||
"ecowitt": {
|
||||
"description": "Nastavení pro Ecowitt",
|
||||
"title": "Konfigurace pro stanice Ecowitt",
|
||||
"data": {
|
||||
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||
"ecowitt_enabled": "Povolit data ze stanice 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"
|
||||
}
|
||||
},
|
||||
"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ů.",
|
||||
|
|
@ -220,7 +233,7 @@
|
|||
"state": {
|
||||
"low": "Nízká",
|
||||
"normal": "Normální",
|
||||
"unknown": "Neznámá / zcela vybitá"
|
||||
"drained": "Neznámá / zcela vybitá"
|
||||
}
|
||||
},
|
||||
"ch2_battery": {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,18 @@
|
|||
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
||||
}
|
||||
},
|
||||
"ecowitt": {
|
||||
"description": "Nastavení pro Ecowitt",
|
||||
"title": "Konfigurace pro stanice Ecowitt",
|
||||
"data": {
|
||||
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||
"ecowitt_enabled": "Povolit data ze stanice 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"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
from py_typecheck.core import checked_or
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
|
@ -15,7 +25,6 @@ from homeassistant.helpers.translation import async_get_translations
|
|||
|
||||
from .const import (
|
||||
AZIMUT,
|
||||
DATABASE_PATH,
|
||||
DEV_DBG,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
|
|
@ -37,19 +46,19 @@ 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(
|
||||
|
|
@ -70,7 +79,7 @@ async def translated_notification(
|
|||
f"component.{translation_domain}.{category}.{translation_key}.title"
|
||||
)
|
||||
|
||||
language = hass.config.language
|
||||
language: str = cast("str", hass.config.language)
|
||||
|
||||
_translations = await async_get_translations(
|
||||
hass, language, category, [translation_domain]
|
||||
|
|
@ -91,7 +100,10 @@ async def translated_notification(
|
|||
|
||||
|
||||
async def update_options(
|
||||
hass: HomeAssistant, entry: ConfigEntry, update_key, update_value
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
update_key: str,
|
||||
update_value: str | list[str] | bool,
|
||||
) -> bool:
|
||||
"""Update config.options entry."""
|
||||
conf = {**entry.options}
|
||||
|
|
@ -100,57 +112,79 @@ async def update_options(
|
|||
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"}
|
||||
|
||||
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):
|
||||
"""Remap items in query for WSLink API."""
|
||||
items = {}
|
||||
for item in entities:
|
||||
if item in REMAP_WSLINK_ITEMS:
|
||||
items[REMAP_WSLINK_ITEMS[item]] = entities[item]
|
||||
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
||||
"""Remap WSLink payload field names into internal sensor keys.
|
||||
|
||||
return items
|
||||
WSLink uses a different naming scheme than the legacy endpoint (e.g. "t1tem", "t1ws").
|
||||
Just like `remap_items`, this function normalizes the payload to the integration's stable
|
||||
internal keys.
|
||||
"""
|
||||
return {
|
||||
REMAP_WSLINK_ITEMS[key]: value
|
||||
for key, value in entities.items()
|
||||
if key in REMAP_WSLINK_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.
|
||||
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)
|
||||
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
|
||||
|
||||
entityFound: bool = False
|
||||
_loaded_sensors = loaded_sensors(config_entry)
|
||||
missing_sensors: list = []
|
||||
_loaded_sensors: list[str] = loaded_sensors(config_entry)
|
||||
missing_sensors: list[str] = []
|
||||
|
||||
for item in items:
|
||||
if log:
|
||||
|
|
@ -177,8 +211,11 @@ def wind_dir_to_text(deg: float) -> UnitOfDir | None:
|
|||
return None
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
|
|
@ -188,10 +225,19 @@ 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:
|
||||
|
|
@ -218,21 +264,40 @@ 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_float(val: Any) -> float | None:
|
||||
"""Convert int or string to float."""
|
||||
|
||||
if not val:
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -263,21 +328,30 @@ 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
|
||||
"""
|
||||
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)
|
||||
|
|
@ -294,109 +368,3 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
|
|||
if temp < 50 and wind > 3
|
||||
else temp
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
|
||||
"""Migrate data from mm/d to mm."""
|
||||
|
||||
_LOGGER.debug("Sensor %s is required for data migration", sensor_id)
|
||||
updated_rows = 0
|
||||
|
||||
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:
|
||||
_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
|
||||
|
||||
|
||||
def migrate_data_old(sensor_id: str | None = None):
|
||||
"""Migrate data from mm/d to mm."""
|
||||
updated_rows = 0
|
||||
|
||||
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:
|
||||
_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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
|||
import logging
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from py_typecheck.core import checked
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
@ -24,8 +25,6 @@ from .utils import update_options
|
|||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RESPONSE_FOR_TEST = False
|
||||
|
||||
|
||||
class WindyNotInserted(Exception):
|
||||
"""NotInserted state."""
|
||||
|
|
@ -58,16 +57,16 @@ class WindyPush:
|
|||
""" lets wait for 1 minute to get initial data from station
|
||||
and then try to push first data to Windy
|
||||
"""
|
||||
self.last_update = datetime.now()
|
||||
self.next_update = datetime.now() + timed(minutes=1)
|
||||
self.last_update: datetime = datetime.now()
|
||||
self.next_update: datetime = datetime.now() + 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)
|
||||
self.invalid_response_count: int = 0
|
||||
|
||||
def verify_windy_response( # pylint: disable=useless-return
|
||||
def verify_windy_response(
|
||||
self,
|
||||
response: str,
|
||||
) -> WindyNotInserted | WindySuccess | WindyApiKeyError | None:
|
||||
):
|
||||
"""Verify answer form Windy."""
|
||||
|
||||
if self.log:
|
||||
|
|
@ -85,9 +84,7 @@ class WindyPush:
|
|||
if "Unauthorized" in response:
|
||||
raise WindyApiKeyError
|
||||
|
||||
return None
|
||||
|
||||
async def push_data_to_windy(self, data):
|
||||
async def push_data_to_windy(self, data: dict[str, str]) -> bool:
|
||||
"""Pushes weather data do Windy stations.
|
||||
|
||||
Interval is 5 minutes, otherwise Windy would not accepts data.
|
||||
|
|
@ -96,8 +93,6 @@ class WindyPush:
|
|||
from station. But we need to do some clean up.
|
||||
"""
|
||||
|
||||
text_for_test = None
|
||||
|
||||
if self.log:
|
||||
_LOGGER.info(
|
||||
"Windy last update = %s, next update at: %s",
|
||||
|
|
@ -112,13 +107,18 @@ class WindyPush:
|
|||
|
||||
for purge in PURGE_DATA:
|
||||
if purge in purged_data:
|
||||
purged_data.pop(purge)
|
||||
_ = purged_data.pop(purge)
|
||||
|
||||
if "dewptf" in purged_data:
|
||||
dewpoint = round(((float(purged_data.pop("dewptf")) - 32) / 1.8), 1)
|
||||
purged_data["dewpoint"] = str(dewpoint)
|
||||
|
||||
windy_api_key = self.config.options.get(WINDY_API_KEY)
|
||||
if (
|
||||
windy_api_key := checked(self.config.options.get(WINDY_API_KEY), str)
|
||||
) is None:
|
||||
_LOGGER.error("Windy API key is not provided! Check your configuration.")
|
||||
return False
|
||||
|
||||
request_url = f"{WINDY_URL}{windy_api_key}"
|
||||
|
||||
if self.log:
|
||||
|
|
@ -133,27 +133,33 @@ class WindyPush:
|
|||
# log despite of settings
|
||||
_LOGGER.error(WINDY_NOT_INSERTED)
|
||||
|
||||
text_for_test = WINDY_NOT_INSERTED
|
||||
|
||||
except WindyApiKeyError:
|
||||
# log despite of settings
|
||||
_LOGGER.critical(WINDY_INVALID_KEY)
|
||||
text_for_test = WINDY_INVALID_KEY
|
||||
|
||||
await update_options(self.hass, self.config, WINDY_ENABLED, False)
|
||||
if not (
|
||||
await update_options(
|
||||
self.hass, self.config, WINDY_ENABLED, False
|
||||
)
|
||||
):
|
||||
_LOGGER.debug("Failed to set Windy option to false.")
|
||||
|
||||
except WindySuccess:
|
||||
if self.log:
|
||||
_LOGGER.info(WINDY_SUCCESS)
|
||||
text_for_test = WINDY_SUCCESS
|
||||
else:
|
||||
if self.log:
|
||||
_LOGGER.debug(WINDY_NOT_INSERTED)
|
||||
|
||||
except ClientError as ex:
|
||||
_LOGGER.critical("Invalid response from Windy: %s", str(ex))
|
||||
self.invalid_response_count += 1
|
||||
if self.invalid_response_count > 3:
|
||||
_LOGGER.critical(WINDY_UNEXPECTED)
|
||||
text_for_test = WINDY_UNEXPECTED
|
||||
await update_options(self.hass, self.config, WINDY_ENABLED, False)
|
||||
if not await update_options(
|
||||
self.hass, self.config, WINDY_ENABLED, False
|
||||
):
|
||||
_LOGGER.debug("Failed to set Windy options to false.")
|
||||
|
||||
self.last_update = datetime.now()
|
||||
self.next_update = self.last_update + timed(minutes=5)
|
||||
|
|
@ -161,6 +167,4 @@ class WindyPush:
|
|||
if self.log:
|
||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||
|
||||
if RESPONSE_FOR_TEST and text_for_test:
|
||||
return text_for_test
|
||||
return None
|
||||
return True
|
||||
|
|
|
|||
Loading…
Reference in New Issue