SWS-12500-custom-component/custom_components/sws12500/sensor.py

249 lines
8.8 KiB
Python

"""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
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 .const import (
CHILL_INDEX,
DOMAIN,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
SENSORS_TO_LOAD,
WIND_AZIMUT,
WIND_DIR,
WIND_SPEED,
WSLINK,
)
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
_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.
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)
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)
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
# Store the platform callback so we can add entities later (auto-discovery) without reload.
entry_data[ENTRY_ADD_ENTITIES] = async_add_entities
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, SensorEntity
): # pyright: ignore[reportIncompatibleVariableOverride]
"""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,
description: WeatherSensorEntityDescription,
coordinator: Any,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = description.key
@property
def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the current sensor state.
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`.
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
description = cast("WeatherSensorEntityDescription", self.entity_description)
if description.value_from_data_fn is not None:
return description.value_from_data_fn(data)
raw = data.get(key)
if raw is None or raw == "":
return None
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)
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info."""
return DeviceInfo(
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)