"""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 an HTTP 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. - `entry.runtime_data` stores per-entry runtime state (coordinator instance, options snapshot, and sensor platform callbacks). Shared aiohttp route registrations stay under `hass.data[DOMAIN]["routes"]` because they must survive a config-entry reload. 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 from homeassistant.helpers.event import async_track_time_interval from .conflicts import effective_protocols, update_protocol_conflict_issue from .const import ( CHANNEL_TYPES, DEFAULT_URL, DOMAIN, ECOWITT_URL_PREFIX, HEALTH_URL, POCASI_CZ_ENABLED, POCASI_CZ_ENABLED_LEGACY, SENSORS_TO_LOAD, WINDY_ENABLED, 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 .predecessor import async_adopt_predecessor, inherit_predecessor_options from .routes import Routes from .staleness import update_stale_sensors_issue _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] # Keep in sync with `ConfigFlowHandler.VERSION`. CONFIG_ENTRY_VERSION: int = 2 def _shared_routes(hass: HomeAssistant) -> Routes | None: """Return the route dispatcher shared across entry reloads, if it exists yet.""" domain_data = hass.data.get(DOMAIN) routes = domain_data.get("routes") if isinstance(domain_data, dict) else None return routes if isinstance(routes, Routes) else None 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. """ if entry.version > CONFIG_ENTRY_VERSION: # Downgrades are not supported - the entry was written by a newer version. return False 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) hass.config_entries.async_update_entry(entry, options=options, version=2) return True def register_path( hass: HomeAssistant, coordinator: WeatherDataUpdateCoordinator, coordinator_h: HealthCoordinator, config: SWSConfigEntry, ) -> bool: """Register webhook paths. We register the supported station endpoints and use an internal dispatcher (`Routes`) to enable only the configured ingress path. This lets us toggle protocols 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/ _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 # ValueError as well as RuntimeError: aiohttp rejects a duplicate route *name* # with ValueError, and the names here are fixed rather than domain-scoped. That # is what a half-finished migration looks like - the previous version is still # installed and holding the same routes - so it deserves a message the user can # act on rather than an unhandled traceback. except (RuntimeError, ValueError) as Ex: _LOGGER.critical("Routes cannot be added. Integration will not work as expected. %s", Ex) raise ConfigEntryNotReady( "Webhook routes are already registered by another instance of this " "integration. Remove the previous version in HACS and restart Home Assistant." ) 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.activate() 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, {}) # First, because everything below reads the options: the protocol flags decide which # coordinator and which webhook routes get wired up, and inheriting `WSLINK` after # that point would leave the entry listening on the wrong endpoint until a reload. inherit_predecessor_options(hass, entry) 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.activate() 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) # Late enough that the routes are proven, early enough that no entity exists yet. # # Late: adoption is the one destructive step - it removes the predecessor's config # entry. Running it before the routes are up means a station that still has the old # version installed gets its entry deleted and then a failed setup, with nothing to # fall back to. Everything above this line can raise ConfigEntryNotReady and leave # the user exactly where they started. # # Early: adoption rewrites the predecessor's registry entries in place and our # platforms then bind to those same entries by unique id, keeping their entity_id # and with it their recorder history. After `async_forward_entry_setups` the # entity_ids would already have been handed out to freshly created entities. await async_adopt_predecessor(hass, entry) 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), - `CHANNEL_TYPES` (the webhook handler persists the probe types it is told about, and they are only read when an entity is created), 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, CHANNEL_TYPES, 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: 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. """ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: if (routes := _shared_routes(hass)) is not None: routes.deactivate() setattr(entry, "runtime_data", None) return unload_ok async def async_remove_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> None: """Release what the shared dispatcher keeps across an unload. Unload is deliberately not the end of the line - the dispatcher keeps the ingress observer so a payload that lands mid-reload is still recorded, and the next setup repoints it at the new coordinator. Removal has no next setup, so the references have to go here or the deleted entry's coordinators stay reachable from `hass.data[DOMAIN]["routes"]` for the rest of the process. """ del entry # single_config_entry: the shared dispatcher belongs to the one entry if (routes := _shared_routes(hass)) is not None: routes.release() _LOGGER.debug("Config entry removed; dispatcher references released.")