fix: address remaining review findings across forwarders and utils

Forwarders (Windy / Pocasi):
- `enabled` becomes a property reading the option live. Toggling it does not
  reload the entry, so the cached copy left the diagnostics sensor reporting a
  stale value indefinitely for a disabled forwarder.
- Reject empty credentials explicitly: an empty string is still a `str`, so
  `checked` alone let an unconfigured forwarder send a request that could only
  ever be refused.
- Use `persistent_notification.async_create` instead of the sync variant.
- Use WINDY_MAX_RETRIES consistently; add POCASI_CZ_MAX_RETRIES.

Utils:
- `voc_level_to_text` / `battery_5step_to_pct` go through `to_int` like every
  other value_fn. A bare `int()` raised on a garbage payload value, which
  `WeatherSensor.native_value` then logged with a full traceback on every push.
  Out-of-range battery steps are clamped to a valid percentage.
- Simplify `check_disabled` (drop the redundant camelCase flag).

Config flow:
- `retain_data` re-reads SENSORS_TO_LOAD at submit time. Auto-discovery appends
  to it from the webhook handler, so writing back the snapshot taken when the
  step opened silently rolled back any sensor discovered meanwhile.

Coordinator:
- Drop the per-push staleness recalculation; the hourly timer already covers it.

Ecowitt:
- Tolerate aioecowitt moving/renaming the internal SENSOR_MAP rather than
  failing the whole module import; unit-variant dedup degrades instead.

Translations:
- Battery state key `unknown` -> `drained`: the entity emits UnitOfBat.UNKNOWN,
  whose value is "drained", so the old key never matched and the raw state
  leaked into the UI. cs.json was already correct.
- Remove the dead `migration` step; `async_step_migration` does not exist.

Also: remove verified-dead constants/helpers and fix ~30 docstring typos.
ecowitt_support
SchiZzA 2026-07-25 21:39:44 +02:00
parent 8b38db6951
commit 5e4b02fd6e
No known key found for this signature in database
21 changed files with 322 additions and 254 deletions

View File

@ -1,7 +1,7 @@
"""Battery sensors templates. """Battery sensors templates.
We create a sensor tempate here. We create a sensor template here.
Actualy loaded senors are gated in coordinator. Actually loaded sensors are gated in coordinator.
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -10,7 +10,6 @@ from yarl import URL
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector from homeassistant.helpers import selector
from homeassistant.helpers.network import get_url from homeassistant.helpers.network import get_url
@ -43,14 +42,6 @@ from .const import (
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)) _PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
class CannotConnect(HomeAssistantError):
"""We can not connect. - not used in push mechanism."""
class InvalidAuth(HomeAssistantError):
"""Invalid auth exception."""
class ConfigOptionsFlowHandler(OptionsFlow): class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow.""" """Handle WeatherStation ConfigFlow."""
@ -63,7 +54,6 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.windy_data_schema = {} self.windy_data_schema = {}
self.user_data: dict[str, Any] = {} self.user_data: dict[str, Any] = {}
self.user_data_schema = {} self.user_data_schema = {}
self.sensors: dict[str, Any] = {}
self.migrate_schema = {} self.migrate_schema = {}
self.pocasi_cz: dict[str, Any] = {} self.pocasi_cz: dict[str, Any] = {}
self.pocasi_cz_schema = {} self.pocasi_cz_schema = {}
@ -91,12 +81,6 @@ class ConfigOptionsFlowHandler(OptionsFlow):
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool, vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
} }
self.sensors = {
SENSORS_TO_LOAD: (
entry_data.get(SENSORS_TO_LOAD) if isinstance(entry_data.get(SENSORS_TO_LOAD), list) else []
)
}
self.windy_data = { self.windy_data = {
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""), WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""), WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
@ -157,7 +141,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle. """Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled. API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credantials empty. For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credentials empty.
""" """
errors: dict[str, str] = {} errors: dict[str, str] = {}
@ -322,16 +306,24 @@ class ConfigOptionsFlowHandler(OptionsFlow):
return self.async_create_entry(title=DOMAIN, data=user_input) return self.async_create_entry(title=DOMAIN, data=user_input)
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]: def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Retain user_data.""" """Merge the submitted step over every other section's current values.
`SENSORS_TO_LOAD` is re-read here rather than taken from the `_get_entry_data`
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
left open while the station reports a new field would otherwise roll that
discovery back on submit.
"""
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
return { return {
**self.user_data, **self.user_data,
**self.windy_data, **self.windy_data,
**self.pocasi_cz, **self.pocasi_cz,
**self.sensors,
**self.ecowitt, **self.ecowitt,
**self.wslink_addon_port, **self.wslink_addon_port,
**dict(data), **dict(data),
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
} }

View File

@ -112,9 +112,9 @@ PURGE_DATA: Final = [
"dailyrainin", "dailyrainin",
] ]
"""NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US: """NOTE: These are sensors that should be available with PWS protocol according to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
I have no option to test, if it will work correctly. So their implementatnion will be in future releases. I have no option to test, if it will work correctly. So their implementation will be in future releases.
leafwetness - [%] leafwetness - [%]
+ for sensor 2 use leafwetness2 + for sensor 2 use leafwetness2
@ -189,9 +189,7 @@ WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
ECOWITT: Final = "ecowitt" ECOWITT: Final = "ecowitt"
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id" ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
ECOWITT_ENABLED: Final = "ecowitt_enabled" ECOWITT_ENABLED: Final = "ecowitt_enabled"
ECOWITT_URL: Final = "/weather/ecowitt"
ECOWITT_URL_PREFIX: Final = "/weatherhub" ECOWITT_URL_PREFIX: Final = "/weatherhub"
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
REMAP_ECOWITT_COMPAT: dict[str, str] = { REMAP_ECOWITT_COMPAT: dict[str, str] = {
"tempf": OUTSIDE_TEMP, "tempf": OUTSIDE_TEMP,
@ -225,11 +223,11 @@ REMAP_ECOWITT_COMPAT: dict[str, str] = {
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY" POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID" POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL" POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox" POCASI_CZ_ENABLED = "pocasi_enabled_checkbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox" POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?" POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo" POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!" POCASI_CZ_UNEXPECTED: Final = "Pocasi Meteo responded unexpectedly 3 times in row. Resending is now disabled!"
WINDY_STATION_ID = "WINDY_STATION_ID" WINDY_STATION_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD" WINDY_STATION_PW = "WINDY_STATION_PWD"
@ -243,13 +241,6 @@ WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully i
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!" WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
PURGE_DATA_POCAS: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
]
REMAP_WSLINK_ITEMS: dict[str, str] = { REMAP_WSLINK_ITEMS: dict[str, str] = {
"intem": INDOOR_TEMP, "intem": INDOOR_TEMP,
@ -308,10 +299,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
# #
# 'inbat' indoor battery level (1 normal, 0 low) # 'inbat' indoor battery level (1 normal, 0 low)
# 't1bat': outdoor battery level (1 normal, 0 low) # 't1bat': outdoor battery level (1 normal, 0 low)
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLin # 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
# #
# In the following there are sensors that should be available by WSLink. # In the following there are sensors that should be available by WSLink.
# We need to compare them to PWS API to make sure, we have the same intarnal # We need to compare them to PWS API to make sure, we have the same internal
# representation of same sensors. # representation of same sensors.
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation: ### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
@ -360,38 +351,12 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer # &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
# &t11co= CO concentration integer ppm # &t11co= CO concentration integer ppm
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer # &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
# &t11cn= CO sensor connection (Connected=1, No connect=0) integero # &t11cn= CO sensor connection (Connected=1, No connect=0) integer
# #
DISABLED_BY_DEFAULT: Final = [ # Station reports batteries as 0/1 (low/normal). Sensors reporting a 0-5 level
CH2_TEMP, # (e.g. T9_BATTERY) are plain sensors, not binary ones.
CH2_HUMIDITY,
CH2_BATTERY,
CH3_TEMP,
CH3_HUMIDITY,
CH3_BATTERY,
CH4_TEMP,
CH4_HUMIDITY,
CH4_BATTERY,
CH5_TEMP,
CH5_HUMIDITY,
CH5_BATTERY,
CH6_TEMP,
CH6_HUMIDITY,
CH6_BATTERY,
CH7_TEMP,
CH7_HUMIDITY,
CH7_BATTERY,
CH8_TEMP,
CH8_HUMIDITY,
CH8_BATTERY,
OUTSIDE_BATTERY,
WBGT_TEMP,
]
# Station reports batteries as 0/1 (low/normal) for most of sensors.
# Batteries reported as 0-5 level are stored in `BATTERY_NON_BINARY` tuple
BATTERY_LIST: Final[tuple[str, ...]] = ( BATTERY_LIST: Final[tuple[str, ...]] = (
OUTSIDE_BATTERY, OUTSIDE_BATTERY,
INDOOR_BATTERY, INDOOR_BATTERY,
@ -404,7 +369,6 @@ BATTERY_LIST: Final[tuple[str, ...]] = (
CH8_BATTERY, CH8_BATTERY,
) )
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,)
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = { CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
# Multi-channel temp/humidity probes (CH2 - CH8) # Multi-channel temp/humidity probes (CH2 - CH8)
@ -487,9 +451,3 @@ class UnitOfBat(StrEnum):
NORMAL = "normal" NORMAL = "normal"
UNKNOWN = "drained" UNKNOWN = "drained"
BATTERY_LEVEL: list[UnitOfBat] = [
UnitOfBat.LOW,
UnitOfBat.NORMAL,
UnitOfBat.UNKNOWN,
]

View File

@ -46,7 +46,6 @@ from .ecowitt import EcowittBridge
from .health_coordinator import HealthCoordinator from .health_coordinator import HealthCoordinator
from .pocasti_cz import PocasiPush from .pocasti_cz import PocasiPush
from .sensor import add_new_sensors from .sensor import add_new_sensors
from .staleness import update_stale_sensors_issue
from .utils import ( from .utils import (
anonymize, anonymize,
check_disabled, check_disabled,
@ -218,7 +217,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
now = dt_util.utcnow() now = dt_util.utcnow()
for key in mapped_data: for key in mapped_data:
self.config.runtime_data.last_seen[key] = now self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health: if health:
health.update_ingress_result( health.update_ingress_result(
@ -313,7 +311,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
now = dt_util.utcnow() now = dt_util.utcnow()
for key in remaped_items: for key in remaped_items:
self.config.runtime_data.last_seen[key] = now self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health: if health:
health.update_ingress_result( health.update_ingress_result(

View File

@ -16,7 +16,12 @@ import logging
from typing import Any, Final from typing import Any, Final
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
from aioecowitt.sensor import SENSOR_MAP
try:
# Internal to aioecowitt; see `_build_unit_twins` for why this is tolerated.
from aioecowitt.sensor import SENSOR_MAP
except ImportError: # pragma: no cover - defensive, module moved upstream
SENSOR_MAP = {}
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -42,10 +47,25 @@ def _build_unit_twins() -> dict[str, frozenset[str]]:
aioecowitt exposes both metric and imperial sensors for many readings (e.g. aioecowitt exposes both metric and imperial sensors for many readings (e.g.
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name. `tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
`SENSOR_MAP` is aioecowitt-internal (not part of its public API), so an upstream
rename must not take the whole integration down with an ImportError/AttributeError
at module import. Degrading to "no twins known" only costs us the duplicate-unit
dedup in `_on_new_sensor`.
""" """
try:
sensor_map = SENSOR_MAP.items()
except AttributeError: # pragma: no cover - defensive, shape changed upstream
_LOGGER.warning("aioecowitt SENSOR_MAP is not a mapping; unit-variant dedup disabled")
return {}
by_name: dict[str, set[str]] = {} by_name: dict[str, set[str]] = {}
for key, meta in SENSOR_MAP.items(): for key, meta in sensor_map:
by_name.setdefault(meta.name, set()).add(key) name = getattr(meta, "name", None)
if name is None: # pragma: no cover - defensive
continue
by_name.setdefault(name, set()).add(key)
twins: dict[str, frozenset[str]] = {} twins: dict[str, frozenset[str]] = {}
for keys in by_name.values(): for keys in by_name.values():
if len(keys) > 1: if len(keys) > 1:
@ -210,7 +230,7 @@ class EcowittBridge:
"""Bridge between HA webhook and aioecowitt parsing. """Bridge between HA webhook and aioecowitt parsing.
We do not run EcoWittListener.start() - this would start separate HTTP server. We do not run EcoWittListener.start() - this would start separate HTTP server.
Instead we are calling listener.process_data() manualy from our webhook handler Instead we are calling listener.process_data() manually from our webhook handler
and we are just using parsing/discovery logic. and we are just using parsing/discovery logic.
""" """
@ -249,8 +269,8 @@ class EcowittBridge:
"""Process raw Ecowitt POST payload. """Process raw Ecowitt POST payload.
Returns: Returns:
Dict of internal sensor keys -> values (fro mapped senors). Dict of internal sensor keys -> values (for mapped sensors).
Unmapped sensors are handeled via _on_new_sensor callback. Unmapped sensors are handled via _on_new_sensor callback.
""" """
@ -269,7 +289,7 @@ class EcowittBridge:
def _on_new_sensor(self, sensor: EcoWittSensor) -> None: def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
"""Call me by aioecowitt when a new sensor is discovered. """Call me by aioecowitt when a new sensor is discovered.
If the senosor does not have internal mapping, If the sensor does not have internal mapping,
create native Ecowitt entity. create native Ecowitt entity.
""" """
@ -313,16 +333,17 @@ class EcowittBridge:
@property @property
def unmapped_sensor(self) -> dict[str, EcoWittSensor]: def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
"""Return al sensors that don't have an internal mapping.""" """Return all sensors that don't have an internal mapping."""
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS} return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
@property @property
def all_sensors(self) -> dict[str, EcoWittSensor]: def all_sensors(self) -> dict[str, EcoWittSensor]:
"""Return all discovered sensors.""" """Return every sensor aioecowitt has parsed so far."""
return self._listener.sensors return self._listener.sensors
class EcoWittNativeSensor(SensorEntity): class EcoWittNativeSensor(SensorEntity):
"""Sensor entity for Ecowitt sensors without internal mapping. """Sensor entity for Ecowitt sensors without internal mapping.

View File

@ -366,7 +366,7 @@ class HealthCoordinator(DataUpdateCoordinator):
- whether the request was rejected before processing - whether the request was rejected before processing
""" """
# We do not want to proccess health requests # We do not want to process health requests
if request.path == HEALTH_URL: if request.path == HEALTH_URL:
return return

View File

@ -1,8 +1,8 @@
"""Legacy battery sensor deprecation. """Legacy battery sensor deprecation.
The integration used to expose battery state as regular SensorEntity instance The integration used to expose battery state as regular SensorEntity instance
(unique_id == bettery key), they have been migrated to BinarySensorEntity (uniqui_id == `key`_binary). Old entity-registry entries from (unique_id == battery key), they have been migrated to BinarySensorEntity (unique_id == `key`_binary). Old entity-registry entries from
pre-migration installs orphan. This module raises a Repairs issue so user can celan them up. pre-migration installs orphan. This module raises a Repairs issue so user can clean them up.
""" """
from __future__ import annotations from __future__ import annotations
@ -33,17 +33,17 @@ LEGACY_BATTERY_KEYS: Final[frozenset[str]] = frozenset(
def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str: def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id fpr this config entry.""" """Return Repairs issue id for this config entry."""
return f"legacy_battery_sensor_deprecation_{entry.entry_id}" return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
@callback @callback
def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]: def _orphan_legacy_battery_entries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]:
"""Return entity_ids of legacy battery sensors still present in entity registry. """Return entity_ids of legacy battery sensors still present in entity registry.
Old non-binary battery entities have: Old non-binary battery entities have:
- domian == "sensor" - domain == "sensor"
- unique_id matches a LEGACY_BATTERY_KESY entry (without `_binary` suffix) - unique_id matches a LEGACY_BATTERY_KEYS entry (without `_binary` suffix)
""" """
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
return [ return [
@ -55,10 +55,10 @@ def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) ->
@callback @callback
def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None: def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for orphan legacy battery snesors.""" """Create or clear a Repairs issue for orphan legacy battery sensors."""
issue_id = _legacy_battery_issue_id(entry=entry) issue_id = _legacy_battery_issue_id(entry=entry)
orphans = _orphan_legacy_battery_etries(hass, entry) orphans = _orphan_legacy_battery_entries(hass, entry)
if orphans: if orphans:
ir.async_create_issue( ir.async_create_issue(

View File

@ -7,7 +7,7 @@ import logging
from typing import Any, Literal from typing import Any, Literal
from aiohttp import ClientError from aiohttp import ClientError
from py_typecheck.core import checked from py_typecheck.core import checked_or
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -43,7 +43,6 @@ class PocasiPush:
"""Init.""" """Init."""
self.hass = hass self.hass = hass
self.config = config self.config = config
self.enabled: bool = self.config.options.get(POCASI_CZ_ENABLED, False)
self.last_status: str = "disabled" if not self.enabled else "idle" self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None self.last_error: str | None = None
self.last_attempt_at: str | None = None self.last_attempt_at: str | None = None
@ -55,6 +54,16 @@ class PocasiPush:
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED) self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
self.invalid_response_count = 0 self.invalid_response_count = 0
@property
def enabled(self) -> bool:
"""Whether forwarding is currently on, read live from the options.
Toggling this option does not reload the entry (see `update_listener`), so a
cached copy would leave the diagnostics sensor reporting a stale value until
the next push - or forever, since a disabled forwarder is never called again.
"""
return checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
def verify_response(self, status: int, body: str) -> PocasiResult: def verify_response(self, status: int, body: str) -> PocasiResult:
"""Classify a send by its HTTP status. """Classify a send by its HTTP status.
@ -76,7 +85,6 @@ class PocasiPush:
async def _disable_pocasi(self, reason: str) -> None: async def _disable_pocasi(self, reason: str) -> None:
"""Turn resending off and persist it, so it survives a restart.""" """Turn resending off and persist it, so it survives a restart."""
self.enabled = False
self.last_error = reason self.last_error = reason
if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False): if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False):
@ -86,17 +94,18 @@ class PocasiPush:
"""Pushes weather data to server.""" """Pushes weather data to server."""
_data = data.copy() _data = data.copy()
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
self.last_attempt_at = dt_util.utcnow().isoformat() self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None self.last_error = None
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None: # An empty string is still a `str`, so `checked` alone would let unconfigured
# credentials through and send a request that can only ever be rejected.
if not (_api_id := checked_or(self.config.options.get(POCASI_CZ_API_ID), str, "")):
_LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.") _LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error" self.last_status = "config_error"
self.last_error = "Missing API ID." self.last_error = "Missing API ID."
return return
if (_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)) is None: if not (_api_key := checked_or(self.config.options.get(POCASI_CZ_API_KEY), str, "")):
_LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.") _LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error" self.last_status = "config_error"
self.last_error = "Missing API key." self.last_error = "Missing API key."
@ -112,7 +121,7 @@ class PocasiPush:
if self.next_update > dt_util.utcnow(): if self.next_update > dt_util.utcnow():
self.last_status = "rate_limited_local" self.last_status = "rate_limited_local"
_LOGGER.debug( _LOGGER.debug(
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s", "Triggered update interval limit of %s seconds. Next possible update is set to: %s",
self._interval, self._interval,
self.next_update, self.next_update,
) )
@ -163,7 +172,7 @@ class PocasiPush:
self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo." self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo."
self.invalid_response_count += 1 self.invalid_response_count += 1
_LOGGER.warning( _LOGGER.warning(
"Unexpected HTTP status %s from Pocasi Meteo. Retries before disabling resend: %s", "Unexpected HTTP status %s from Pocasi Meteo. Rentries before disabling resend: %s",
http_status, http_status,
POCASI_CZ_MAX_RETRIES - self.invalid_response_count, POCASI_CZ_MAX_RETRIES - self.invalid_response_count,
) )

View File

@ -93,7 +93,7 @@ class Routes:
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None: def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
"""Enable or disable the Ecowitt sticky route. """Enable or disable the Ecowitt sticky route.
switch_route() does not involves sticky routes, so we need another switch_route() does not involve sticky routes, so we need another
method for Ecowitt state at reload. method for Ecowitt state at reload.
""" """
@ -229,5 +229,5 @@ async def unregistered(request: Request) -> Response:
a clear error message when the station pushes to the wrong endpoint. a clear error message when the station pushes to the wrong endpoint.
""" """
_ = request _ = request
_LOGGER.debug("Received data to unregistred or disabled webhook.") _LOGGER.debug("Received data to unregistered or disabled webhook.")
return Response(text="Unregistred webhook. Check your settings.", status=400) return Response(text="Unregistered webhook. Check your settings.", status=400)

View File

@ -142,18 +142,6 @@
"data_description": { "data_description": {
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)." "WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
} }
},
"migration": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
"data": {
"sensor_to_migrate": "Sensor to migrate",
"trigger_action": "Trigger migration"
},
"data_description": {
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
"trigger_action": "Trigger the sensor statistics migration after checking."
}
} }
} }
}, },
@ -475,7 +463,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch2_battery": { "ch2_battery": {
@ -483,7 +471,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch3_battery": { "ch3_battery": {
@ -491,7 +479,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch4_battery": { "ch4_battery": {
@ -499,7 +487,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch5_battery": { "ch5_battery": {
@ -507,7 +495,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch6_battery": { "ch6_battery": {
@ -515,7 +503,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch7_battery": { "ch7_battery": {
@ -523,7 +511,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch8_battery": { "ch8_battery": {
@ -531,7 +519,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"indoor_battery": { "indoor_battery": {
@ -539,7 +527,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
} }
} }

View File

@ -67,8 +67,7 @@
"windy": "Nastavení pro přeposílání dat na Windy", "windy": "Nastavení pro přeposílání dat na Windy",
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ", "pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
"ecowitt": "Nastavení pro stanice Ecowitt", "ecowitt": "Nastavení pro stanice Ecowitt",
"wslink_port_setup": "Nastavení portu WSLink Addonu", "wslink_port_setup": "Nastavení portu WSLink Addonu"
"migration": "Migrace statistiky senzoru"
} }
}, },
"basic": { "basic": {
@ -143,18 +142,6 @@
"data_description": { "data_description": {
"WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu." "WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu."
} }
},
"migration": {
"title": "Migrace statistiky senzoru.",
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
"data": {
"sensor_to_migrate": "Senzor pro migraci",
"trigger_action": "Spustit migraci"
},
"data_description": {
"sensor_to_migrate": "Vyberte správný senzor pri migraci statistiky. \n Hodnoty senzoru budou zachovány, nepřepočítají se, pouze se změní jednotka v dlouhodobé statistice. ",
"trigger_action": "Po zaškrtnutí se spustí migrace statistiky senzoru."
}
} }
} }
}, },
@ -476,7 +463,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"indoor_battery": { "indoor_battery": {
@ -492,7 +479,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch3_battery": { "ch3_battery": {
@ -500,7 +487,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch4_battery": { "ch4_battery": {
@ -508,7 +495,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch5_battery": { "ch5_battery": {
@ -516,7 +503,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch6_battery": { "ch6_battery": {
@ -524,7 +511,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch7_battery": { "ch7_battery": {
@ -532,7 +519,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
}, },
"ch8_battery": { "ch8_battery": {
@ -540,7 +527,7 @@
"state": { "state": {
"low": "Nízká", "low": "Nízká",
"normal": "Normální", "normal": "Normální",
"unknown": "Neznámá / zcela vybitá" "drained": "Neznámá / zcela vybitá"
} }
} }
} }

View File

@ -142,18 +142,6 @@
"data_description": { "data_description": {
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)." "WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
} }
},
"migration": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
"data": {
"sensor_to_migrate": "Sensor to migrate",
"trigger_action": "Trigger migration"
},
"data_description": {
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
"trigger_action": "Trigger the sensor statistics migration after checking."
}
} }
} }
}, },
@ -475,7 +463,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch2_battery": { "ch2_battery": {
@ -483,7 +471,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch3_battery": { "ch3_battery": {
@ -491,7 +479,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch4_battery": { "ch4_battery": {
@ -499,7 +487,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch5_battery": { "ch5_battery": {
@ -507,7 +495,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch6_battery": { "ch6_battery": {
@ -515,7 +503,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch7_battery": { "ch7_battery": {
@ -523,7 +511,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"ch8_battery": { "ch8_battery": {
@ -531,7 +519,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
}, },
"indoor_battery": { "indoor_battery": {
@ -539,7 +527,7 @@
"state": { "state": {
"normal": "OK", "normal": "OK",
"low": "Low", "low": "Low",
"unknown": "Unknown / drained out" "drained": "Unknown / drained out"
} }
} }
} }

View File

@ -170,7 +170,6 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str
log = checked_or(config_entry.options.get(DEV_DBG), bool, False) log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
entityFound: bool = False
_loaded_sensors: list[str] = loaded_sensors(config_entry) _loaded_sensors: list[str] = loaded_sensors(config_entry)
missing_sensors: list[str] = [] missing_sensors: list[str] = []
@ -180,11 +179,10 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str
if item not in _loaded_sensors: if item not in _loaded_sensors:
missing_sensors.append(item) missing_sensors.append(item)
entityFound = True
if log: if log:
_LOGGER.info("Add sensor (%s) to loading queue", item) _LOGGER.info("Add sensor (%s) to loading queue", item)
return missing_sensors if entityFound else None return missing_sensors or None
def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None: def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
@ -235,18 +233,6 @@ def battery_level(battery: int | str | None) -> UnitOfBat:
return level_map.get(vi, UnitOfBat.UNKNOWN) return level_map.get(vi, UnitOfBat.UNKNOWN)
def battery_level_to_icon(battery: UnitOfBat) -> str:
"""Return battery level in icon representation.
Returns str
"""
icons = {
UnitOfBat.LOW: "mdi:battery-low",
UnitOfBat.NORMAL: "mdi:battery",
}
return icons.get(battery, "mdi:battery-unknown")
def fahrenheit_to_celsius(fahrenheit: float) -> float: def fahrenheit_to_celsius(fahrenheit: float) -> float:
@ -382,17 +368,27 @@ def chill_index(data: dict[str, str | float | int], convert: bool = False) -> fl
) )
def voc_level_to_text(value: str | None) -> VOCLevel | None: def voc_level_to_text(value: Any) -> VOCLevel | None:
"""Map 1-5 VOC level to text state.""" """Map the 1-5 VOC level to a text state.
if value in (None, ""):
Goes through `to_int` like every other value_fn: a bare `int()` raises on a garbage
payload value, which `WeatherSensor.native_value` then logs with a full traceback on
every push.
"""
level = to_int(value)
if level is None:
return None return None
return VOC_LEVEL_MAP.get(int(value)) return VOC_LEVEL_MAP.get(level)
def battery_5step_to_pct(value: str) -> int | None: def battery_5step_to_pct(value: Any) -> int | None:
"""Convert 0-5 battery steps to percentage.""" """Convert the 0-5 battery step to a percentage.
if value in (None, ""): Out-of-range steps are clamped so the reading stays valid for a battery
device class (see `voc_level_to_text` for why `to_int` is used).
"""
step = to_int(value)
if step is None:
return None return None
return round(int(value) / 5 * 100) return round(min(max(step, 0), 5) / 5 * 100)

View File

@ -7,7 +7,7 @@ import logging
from aiohttp.client import ClientResponse from aiohttp.client import ClientResponse
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from py_typecheck import checked from py_typecheck import checked_or
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -81,7 +81,6 @@ class WindyPush:
"""Init.""" """Init."""
self.hass = hass self.hass = hass
self.config = config self.config = config
self.enabled: bool = self.config.options.get(WINDY_ENABLED, False)
self.last_status: str = "disabled" if not self.enabled else "idle" self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None self.last_error: str | None = None
self.last_attempt_at: str | None = None self.last_attempt_at: str | None = None
@ -94,10 +93,20 @@ class WindyPush:
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False) self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
# Lets chcek if Windy server is responding right. # Lets check if Windy server is responding right.
# Otherwise, try 3 times and then disable resending. # Otherwise, try WINDY_MAX_RETRIES times and then disable resending.
self.invalid_response_count: int = 0 self.invalid_response_count: int = 0
@property
def enabled(self) -> bool:
"""Whether forwarding is currently on, read live from the options.
Toggling this option does not reload the entry (see `update_listener`), so a
cached copy would leave the diagnostics sensor reporting a stale value until
the next push - or forever, since a disabled forwarder is never called again.
"""
return checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
# Refactored responses verification. # Refactored responses verification.
# #
# We now comply to API at https://stations.windy.com/api-reference # We now comply to API at https://stations.windy.com/api-reference
@ -150,15 +159,18 @@ class WindyPush:
return indata return indata
async def _disable_windy(self, reason: str) -> None: async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending.""" """Disable Windy resending.
self.enabled = False
`enabled` reads the option back, so persisting it here is what actually turns
forwarding off.
"""
self.last_status = "disabled" self.last_status = "disabled"
self.last_error = reason self.last_error = reason
if not await update_options(self.hass, self.config, WINDY_ENABLED, False): if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to false.") _LOGGER.debug("Failed to set Windy options to false.")
persistent_notification.create(self.hass, reason, "Windy resending disabled.") persistent_notification.async_create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool: async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
"""Pushes weather data do Windy stations. """Pushes weather data do Windy stations.
@ -170,19 +182,20 @@ class WindyPush:
""" """
# First check if we have valid credentials, before any data manipulation. # First check if we have valid credentials, before any data manipulation.
self.enabled = self.config.options.get(WINDY_ENABLED, False)
self.last_attempt_at = dt_util.utcnow().isoformat() self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None self.last_error = None
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None: # An empty string is still a `str`, so `checked` alone would let unconfigured
_LOGGER.error("Windy API key is not provided! Check your configuration.") # credentials through and send a request that can only ever be rejected.
if not (windy_station_id := checked_or(self.config.options.get(WINDY_STATION_ID), str, "")):
_LOGGER.error("Windy station ID is not provided! Check your configuration.")
self.last_status = "config_error" self.last_status = "config_error"
await self._disable_windy( await self._disable_windy(
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration." "Windy station ID is not provided. Resending is disabled for now. Reconfigure your integration."
) )
return False return False
if (windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)) is None: if not (windy_station_pw := checked_or(self.config.options.get(WINDY_STATION_PW), str, "")):
_LOGGER.error("Windy station password is missing! Check your configuration.") _LOGGER.error("Windy station password is missing! Check your configuration.")
self.last_status = "config_error" self.last_status = "config_error"
await self._disable_windy( await self._disable_windy(
@ -238,7 +251,7 @@ class WindyPush:
# log despite of settings # log despite of settings
_LOGGER.error( _LOGGER.error(
"%s Max retries before disable resend function: %s", "%s Max rentries before disable resend function: %s",
WINDY_NOT_INSERTED, WINDY_NOT_INSERTED,
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
@ -255,7 +268,7 @@ class WindyPush:
self.last_status = "duplicate" self.last_status = "duplicate"
self.last_error = "Duplicate payload detected by Windy server." self.last_error = "Duplicate payload detected by Windy server."
_LOGGER.critical( _LOGGER.critical(
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", "Duplicate payload detected by Windy server. Will try again later. Max rentries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
self.invalid_response_count += 1 self.invalid_response_count += 1
@ -283,12 +296,15 @@ class WindyPush:
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.log: if self.log:
_LOGGER.debug( _LOGGER.debug(
"Unexpected response from Windy. Max retries before disabling resend function: %s", "Unexpected response from Windy. Max rentries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
finally: finally:
if self.invalid_response_count >= 3: if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical("Invalid response from Windy 3 times. Disabling resend option.") _LOGGER.critical(
"Invalid response from Windy %s times. Disabling resend option.",
WINDY_MAX_RETRIES,
)
await self._disable_windy( await self._disable_windy(
reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards." reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
) )
@ -299,7 +315,7 @@ class WindyPush:
# attributes; str(ex) could embed the request URL. # attributes; str(ex) could embed the request URL.
self.last_error = type(ex).__name__ self.last_error = type(ex).__name__
_LOGGER.critical( _LOGGER.critical(
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s", "Invalid response from Windy: %s. Will try again later, max rentries before disabling resend function: %s",
str(ex), str(ex),
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )

View File

@ -20,6 +20,7 @@ from custom_components.sws12500.const import (
POCASI_CZ_LOGGER_ENABLED, POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL, POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM, POCASI_CZ_SEND_MINIMUM,
SENSORS_TO_LOAD,
WINDY_ENABLED, WINDY_ENABLED,
WINDY_LOGGER_ENABLED, WINDY_LOGGER_ENABLED,
WINDY_STATION_ID, WINDY_STATION_ID,
@ -463,3 +464,49 @@ async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integration
assert done["type"] == "create_entry" assert done["type"] == "create_entry"
assert done["data"][ECOWITT_ENABLED] is True assert done["data"][ECOWITT_ENABLED] is True
assert done["data"][LEGACY_ENABLED] is False assert done["data"][LEGACY_ENABLED] is False
@pytest.mark.asyncio
async def test_options_flow_does_not_roll_back_concurrent_autodiscovery(
hass,
enable_custom_integrations,
) -> None:
"""Auto-discovery that lands while the dialog is open must survive the submit.
The options flow snapshots the entry when a step opens, but the webhook handler
appends to SENSORS_TO_LOAD independently. Writing back the snapshot would silently
drop any sensor discovered in between.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
API_ID: "station",
API_KEY: "secret",
LEGACY_ENABLED: True,
SENSORS_TO_LOAD: ["outside_temp"],
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
# The station starts reporting a new field while the form is on screen.
hass.config_entries.async_update_entry(
entry,
options={**entry.options, SENSORS_TO_LOAD: ["outside_temp", "wind_gust"]},
)
done = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "station",
API_KEY: "secret",
WSLINK: False,
LEGACY_ENABLED: True,
},
)
assert done["type"] == "create_entry"
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]

View File

@ -80,6 +80,21 @@ def _make_entry(
return entry return entry
def _write_through_update_options(entry: Any) -> AsyncMock:
"""Mock `update_options` that really mutates the entry, like the real helper.
`PocasiPush.enabled` reads the option back, so a mock that only records the call
would leave `enabled` reporting the pre-disable value.
"""
async def _apply(_hass, _entry, key, value):
entry.options[key] = value
return True
return AsyncMock(side_effect=_apply)
@pytest.fixture @pytest.fixture
def hass(): def hass():
# Minimal hass-like object; we patch client session retrieval. # Minimal hass-like object; we patch client session retrieval.
@ -206,7 +221,7 @@ async def test_push_data_to_server_auth_error_disables_feature(monkeypatch, hass
) )
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
update_options = AsyncMock(return_value=True) update_options = _write_through_update_options(entry)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options "custom_components.sws12500.pocasti_cz.update_options", update_options
) )
@ -265,7 +280,7 @@ async def test_push_data_to_server_server_error_disables_after_max_retries(monke
) )
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
update_options = AsyncMock(return_value=True) update_options = _write_through_update_options(entry)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options "custom_components.sws12500.pocasti_cz.update_options", update_options
) )
@ -292,7 +307,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
entry = _make_entry() entry = _make_entry()
pp = PocasiPush(hass, entry) pp = PocasiPush(hass, entry)
update_options = AsyncMock(return_value=True) update_options = _write_through_update_options(entry)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options "custom_components.sws12500.pocasti_cz.update_options", update_options
) )
@ -369,6 +384,57 @@ async def test_disable_pocasi_logs_when_option_write_fails(monkeypatch, hass):
await pp._disable_pocasi("because") await pp._disable_pocasi("because")
assert pp.enabled is False # `enabled` mirrors the persisted option: if the write failed, forwarding is still
# on as far as the config is concerned, and the failure is logged instead.
assert pp.enabled is True
assert pp.last_error == "because" assert pp.last_error == "because"
dbg.assert_called() dbg.assert_called()
# ---------------------------------------------------------------------------
# Live `enabled` and empty-credential rejection
# ---------------------------------------------------------------------------
def test_enabled_reads_options_live(hass):
"""Toggling the option is visible immediately - no reload, no cached copy.
`update_listener` deliberately skips the reload when only this flag changes, so a
value cached in __init__ would leave the diagnostics sensor permanently stale.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
assert pp.enabled is True
entry.options[POCASI_CZ_ENABLED] = False
assert pp.enabled is False
entry.options[POCASI_CZ_ENABLED] = True
assert pp.enabled is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("api_id", "api_key"),
[("", "key"), ("id", ""), ("", "")],
ids=["empty-id", "empty-key", "both-empty"],
)
async def test_empty_credentials_never_reach_the_network(monkeypatch, hass, api_id, api_key):
"""An empty string is still a `str`, so it must be rejected explicitly.
Otherwise a blank configuration sends a request that can only ever be refused.
"""
entry = _make_entry(api_id=api_id, api_key=api_key)
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
assert pp.last_status == "config_error"

View File

@ -182,7 +182,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
- process_payload returns a mapped dict - process_payload returns a mapped dict
- check_disabled returns new keys -> autodiscovery (update_options, - check_disabled returns new keys -> autodiscovery (update_options,
add_new_binary_sensors, add_new_sensors) add_new_binary_sensors, add_new_sensors)
- async_set_updated_data + last_seen + update_stale_sensors_issue - async_set_updated_data + last_seen
- health.update_ingress_result(accepted) + windy + pocasi forwarding - health.update_ingress_result(accepted) + windy + pocasi forwarding
- health.update_forwarding - health.update_forwarding
- dev log via anonymize - dev log via anonymize
@ -223,11 +223,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
"custom_components.sws12500.coordinator.add_new_binary_sensors", "custom_components.sws12500.coordinator.add_new_binary_sensors",
add_new_binary_sensors, add_new_binary_sensors,
) )
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.windy.push_data_to_windy = AsyncMock() coordinator.windy.push_data_to_windy = AsyncMock()
coordinator.pocasi.push_data_to_server = AsyncMock() coordinator.pocasi.push_data_to_server = AsyncMock()
@ -261,7 +256,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
# Coordinator data + staleness + last_seen. # Coordinator data + staleness + last_seen.
coordinator.async_set_updated_data.assert_called_once_with(mapped) coordinator.async_set_updated_data.assert_called_once_with(mapped)
update_stale.assert_called_once()
assert "outside_temp" in entry.runtime_data.last_seen assert "outside_temp" in entry.runtime_data.last_seen
# Forwarding: windy receives the raw data dict + False, pocasi receives "WU". # Forwarding: windy receives the raw data dict + False, pocasi receives "WU".
@ -314,11 +308,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding
"custom_components.sws12500.coordinator.check_disabled", "custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: [], lambda _mapped, _config: [],
) )
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.windy.push_data_to_windy = AsyncMock() coordinator.windy.push_data_to_windy = AsyncMock()
coordinator.pocasi.push_data_to_server = AsyncMock() coordinator.pocasi.push_data_to_server = AsyncMock()
@ -329,7 +318,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding
assert resp.status == 200 assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(mapped) coordinator.async_set_updated_data.assert_called_once_with(mapped)
update_stale.assert_called_once()
coordinator.windy.push_data_to_windy.assert_not_awaited() coordinator.windy.push_data_to_windy.assert_not_awaited()
coordinator.pocasi.push_data_to_server.assert_not_awaited() coordinator.pocasi.push_data_to_server.assert_not_awaited()
@ -361,9 +349,6 @@ async def test_received_ecowitt_autodiscovery_extends_with_loaded_sensors(hass,
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock() "custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock()
) )
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
)
coordinator.async_set_updated_data = MagicMock() coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
@ -392,9 +377,6 @@ async def test_health_coordinator_attribute_error_returns_none(hass, monkeypatch
"custom_components.sws12500.coordinator.check_disabled", "custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: [], lambda _mapped, _config: [],
) )
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
)
coordinator.async_set_updated_data = MagicMock() coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
@ -410,11 +392,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={}) coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={})
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.async_set_updated_data = MagicMock() coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
@ -422,7 +399,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc
assert resp.status == 200 assert resp.status == 200
coordinator.async_set_updated_data.assert_not_called() coordinator.async_set_updated_data.assert_not_called()
update_stale.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -4,7 +4,7 @@ Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
``t9cn`` parameters: ``t9cn`` parameters:
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``, - the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``) ``VOCLevel`` / ``VOC_LEVEL_MAP``)
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers - the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
- the connection gating in ``utils.remap_wslink_items`` - the connection gating in ``utils.remap_wslink_items``
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions - the new ``SENSOR_TYPES_WSLINK`` entity descriptions
@ -20,7 +20,6 @@ import pytest
from custom_components.sws12500.const import ( from custom_components.sws12500.const import (
BATTERY_LIST, BATTERY_LIST,
BATTERY_NON_BINARY,
CONNECTION_GATED_SENSORS, CONNECTION_GATED_SENSORS,
HCHO, HCHO,
OUTSIDE_TEMP, OUTSIDE_TEMP,
@ -76,7 +75,6 @@ def test_connection_gated_sensors_definition() -> None:
def test_t9_battery_is_non_binary_only() -> None: def test_t9_battery_is_non_binary_only() -> None:
assert BATTERY_NON_BINARY == (T9_BATTERY,)
# the 0-5 / percentage battery must not be treated as a binary low/normal one # the 0-5 / percentage battery must not be treated as a binary low/normal one
assert T9_BATTERY not in BATTERY_LIST assert T9_BATTERY not in BATTERY_LIST

View File

@ -19,8 +19,8 @@ from custom_components.sws12500.const import (
) )
from custom_components.sws12500.utils import ( from custom_components.sws12500.utils import (
anonymize, anonymize,
battery_5step_to_pct,
battery_level, battery_level,
battery_level_to_icon,
celsius_to_fahrenheit, celsius_to_fahrenheit,
check_disabled, check_disabled,
chill_index, chill_index,
@ -32,6 +32,7 @@ from custom_components.sws12500.utils import (
translated_notification, translated_notification,
translations, translations,
update_options, update_options,
voc_level_to_text,
wind_dir_to_text, wind_dir_to_text,
) )
@ -253,11 +254,6 @@ def test_battery_level_handles_none_empty_invalid_and_known_values():
assert battery_level("2") == UnitOfBat.UNKNOWN assert battery_level("2") == UnitOfBat.UNKNOWN
def test_battery_level_to_icon_maps_all_and_unknown():
assert battery_level_to_icon(UnitOfBat.LOW) == "mdi:battery-low"
assert battery_level_to_icon(UnitOfBat.NORMAL) == "mdi:battery"
assert battery_level_to_icon(UnitOfBat.UNKNOWN) == "mdi:battery-unknown"
def test_temperature_conversions_round_trip(): def test_temperature_conversions_round_trip():
# Use a value that is exactly representable in binary-ish floats # Use a value that is exactly representable in binary-ish floats
@ -362,3 +358,36 @@ def test_chill_index_returns_temp_when_not_cold_or_not_windy():
def test_chill_index_convert_from_celsius_path(): def test_chill_index_convert_from_celsius_path():
out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True) out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True)
assert out is not None assert out is not None
# ---------------------------------------------------------------------------
# Converters must degrade to None, not raise
#
# `WeatherSensor.native_value` catches value_fn exceptions and logs them with
# `_LOGGER.exception`, so a bare int() on a garbage payload value produced a full
# traceback on *every* push rather than a quiet `unknown` state.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", "1.5.2", [], {}])
def test_voc_level_to_text_rejects_garbage(bad):
assert voc_level_to_text(bad) is None
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", object()])
def test_battery_5step_to_pct_rejects_garbage(bad):
assert battery_5step_to_pct(bad) is None
@pytest.mark.parametrize(
("value", "expected"),
[(0, 0), ("0", 0), (1, 20), ("3", 60), (5, 100), ("5.0", 100)],
)
def test_battery_5step_to_pct_maps_the_scale(value, expected):
assert battery_5step_to_pct(value) == expected
@pytest.mark.parametrize(("value", "expected"), [(-3, 0), (9, 100)])
def test_battery_5step_to_pct_clamps_out_of_range(value, expected):
"""Out-of-range steps stay a valid battery percentage."""
assert battery_5step_to_pct(value) == expected

View File

@ -107,7 +107,7 @@ async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
"custom_components.sws12500.windy_func.update_options", update_options "custom_components.sws12500.windy_func.update_options", update_options
) )
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
monkeypatch.setattr( monkeypatch.setattr(

View File

@ -225,7 +225,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
"custom_components.sws12500.windy_func.update_options", update_options "custom_components.sws12500.windy_func.update_options", update_options
) )
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
@ -252,7 +252,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
"custom_components.sws12500.windy_func.update_options", update_options "custom_components.sws12500.windy_func.update_options", update_options
) )
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
@ -281,7 +281,7 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha
"custom_components.sws12500.windy_func.update_options", update_options "custom_components.sws12500.windy_func.update_options", update_options
) )
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
@ -314,7 +314,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
dbg = MagicMock() dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg) monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
@ -416,7 +416,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
crit = MagicMock() crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit) monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )
@ -462,7 +462,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
dbg = MagicMock() dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg) monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr( monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create", "custom_components.sws12500.windy_func.persistent_notification.async_create",
MagicMock(), MagicMock(),
) )