Compare commits

...

3 Commits

Author SHA1 Message Date
SchiZzA 3e573087a2
Add multiple health sensors and device info
Introduce HealthSensorEntityDescription and a tuple of sensor
descriptions for integration status, source IP, base URL and addon
response. Instantiate one HealthDiagnosticSensor per description in
async_setup_entry. Update HealthDiagnosticSensor to accept a
description, derive unique_id from description.key and add a cached
device_info returning a SERVICE-type device. Adjust imports.
2026-03-02 22:08:40 +01:00
SchiZzA 6a4eed2ff9
Validate hass data with py_typecheck.checked
Replace manual isinstance checks and casts with py_typecheck.checked()
to validate hass and entry data and return early on errors. Simplify
add_new_sensors by unwrapping values, renaming vars, and passing the
coordinator to WeatherSensor
2026-03-02 22:08:01 +01:00
SchiZzA b3aae77132
Replace casts with checked type helpers
Use checked and checked_or to validate option and hass.data types,
remove unsafe typing.cast calls, simplify coordinator and entry_data
handling, and cache boolean option flags for Windy and Pocasí checks
2026-03-02 22:06:09 +01:00
3 changed files with 117 additions and 59 deletions

View File

@ -28,7 +28,7 @@ period where no entities are subscribed, causing stale states until another full
from asyncio import timeout
import logging
from typing import Any, cast
from typing import Any
from aiohttp import ClientConnectionError
import aiohttp.web
@ -242,10 +242,16 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
# 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):
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(
self.config.options.get(POCASI_CZ_ENABLED), bool, False
)
if _windy_enabled:
await self.windy.push_data_to_windy(data, _wslink)
if self.config.options.get(POCASI_CZ_ENABLED, False):
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
# Convert raw payload keys to our internal sensor keys (stable identifiers).
@ -402,18 +408,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
hass_data_any = hass.data.setdefault(DOMAIN, {})
hass_data = cast("dict[str, Any]", hass_data_any)
hass_data = hass.data.setdefault(DOMAIN, {})
# hass_data = cast("dict[str, Any]", hass_data_any)
# 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)
if (entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])) is None:
entry_data = {}
hass_data[entry.entry_id] = entry_data
# Reuse the existing coordinator across reloads so webhook handlers and entities
# remain connected to the same coordinator instance.
@ -421,14 +426,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 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
coordinator = entry_data.get(ENTRY_COORDINATOR)
if isinstance(coordinator, WeatherDataUpdateCoordinator):
coordinator.config = entry
# 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
coordinator.windy = WindyPush(hass, entry)
coordinator.pocasi = PocasiPush(hass, entry)
else:
coordinator = WeatherDataUpdateCoordinator(hass, entry)
entry_data[ENTRY_COORDINATOR] = coordinator
@ -482,16 +486,16 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
- 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)
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is not None:
if (
entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])
) is not None:
if (
old_options := checked(
entry_data.get(ENTRY_LAST_OPTIONS), dict[str, Any]
)
) is not None:
new_options = dict(entry.options)
changed_keys = {

View File

@ -6,12 +6,15 @@ This file is a helper module and must be wired from `sensor.py`.
from __future__ import annotations
from functools import cached_property
from typing import Any, cast
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -19,6 +22,34 @@ from .const import DOMAIN
from .data import ENTRY_HEALTH_COORD
class HealthSensorEntityDescription(SensorEntityDescription):
"""Description for health diagnostic sensors."""
HEALTH_SENSOR_DESCRIPTIONS: tuple[HealthSensorEntityDescription, ...] = (
HealthSensorEntityDescription(
key="Integration status",
name="Integration status",
icon="mdi:heart-pulse",
),
HealthSensorEntityDescription(
key="HomeAssistant source_ip",
name="Home Assistant source IP",
icon="mdi:ip",
),
HealthSensorEntityDescription(
key="HomeAssistant base_url",
name="Home Assistant base URL",
icon="mdi:link-variant",
),
HealthSensorEntityDescription(
key="WSLink Addon response",
name="WSLink Addon response",
icon="mdi:server-network",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@ -40,7 +71,14 @@ async def async_setup_entry(
if coordinator_any is None:
return
async_add_entities([HealthDiagnosticSensor(coordinator_any, entry)])
entities = [
HealthDiagnosticSensor(
coordinator=coordinator_any, entry=entry, description=description
)
for description in HEALTH_SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
# async_add_entities([HealthDiagnosticSensor(coordinator_any, entry)])
class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride]
@ -51,14 +89,19 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, coordinator: Any, entry: ConfigEntry) -> None:
def __init__(
self,
coordinator: Any,
entry: ConfigEntry,
description: HealthSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_unique_id = f"{entry.entry_id}_health"
self._attr_name = "Health"
self._attr_icon = "mdi:heart-pulse"
self._attr_unique_id = f"{description.key}_health"
# self._attr_name = description.name
# self._attr_icon = "mdi:heart-pulse"
@property
def native_value(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride]
@ -76,3 +119,15 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
if not isinstance(data_any, dict):
return None
return cast("dict[str, Any]", data_any)
@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",
)

View File

@ -20,7 +20,7 @@ from functools import cached_property
import logging
from typing import Any, cast
from py_typecheck import checked_or
from py_typecheck import checked, checked_or
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
@ -92,15 +92,17 @@ async def async_setup_entry(
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)
if (hass_data := checked(hass.data.setdefault(DOMAIN, {}), dict[str, Any])) is None:
return
# we have to check if entry_data are present
# It is created by integration setup, so it should be presnet
if (
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
) is None:
# This should not happen in normal operation.
return
coordinator = entry_data.get(ENTRY_COORDINATOR)
if coordinator is None:
@ -151,34 +153,31 @@ def add_new_sensors(
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:
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
return
add_entities_fn = cast("_AddEntitiesFn", add_entities_any)
descriptions_map = cast(
"dict[str, WeatherSensorEntityDescription]", descriptions_any
)
if (
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
) is None:
return
add_entities = entry_data.get(ENTRY_ADD_ENTITIES)
descriptions = entry_data.get(ENTRY_DESCRIPTIONS)
coordinator = entry_data.get(ENTRY_COORDINATOR)
if add_entities is None or descriptions is None or coordinator is None:
return
add_entities_fn = cast("_AddEntitiesFn", add_entities)
descriptions_map = cast("dict[str, WeatherSensorEntityDescription]", descriptions)
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))
new_entities.append(WeatherSensor(desc, coordinator))
if new_entities:
add_entities_fn(new_entities)