249 lines
8.8 KiB
Python
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",
|
|
)
|