240 lines
9.8 KiB
Python
240 lines
9.8 KiB
Python
"""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
|
|
|
|
from py_typecheck import checked, checked_or
|
|
|
|
from homeassistant.const import Platform
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
|
|
from .const import (
|
|
DEFAULT_URL,
|
|
DOMAIN,
|
|
ECOWITT_ENABLED,
|
|
ECOWITT_URL_PREFIX,
|
|
HEALTH_URL,
|
|
LEGACY_ENABLED,
|
|
SENSORS_TO_LOAD,
|
|
WSLINK,
|
|
WSLINK_URL,
|
|
)
|
|
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, Platform.BINARY_SENSOR]
|
|
|
|
|
|
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)
|
|
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
|
|
_legacy: bool = checked_or(config.options.get(LEGACY_ENABLED), bool, True)
|
|
|
|
# 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 = checked_or(entry.options.get(LEGACY_ENABLED), bool, True)
|
|
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
|
|
_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 PlatformNotReady
|
|
|
|
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)
|
|
|
|
return True
|
|
|
|
|
|
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
|
"""Handle config entry option updates.
|
|
|
|
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.
|
|
"""
|
|
|
|
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 == {SENSORS_TO_LOAD}:
|
|
_LOGGER.debug("Options updated (%s); skipping reload.", SENSORS_TO_LOAD)
|
|
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: SWSConfigEntry) -> bool:
|
|
"""Unload a config entry.
|
|
|
|
`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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|