Compare commits
No commits in common. "80c944fc5aacec07e9eddfcf12fb87c3dc221bf8" and "bf6a02c5b316ad02ca147d24141bd94599dcbbb7" have entirely different histories.
80c944fc5a
...
bf6a02c5b3
|
|
@ -36,7 +36,7 @@ from py_typecheck import checked, checked_or
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
|
@ -46,9 +46,7 @@ from .const import (
|
||||||
ECOWITT_URL_PREFIX,
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
LEGACY_ENABLED,
|
LEGACY_ENABLED,
|
||||||
POCASI_CZ_ENABLED,
|
|
||||||
SENSORS_TO_LOAD,
|
SENSORS_TO_LOAD,
|
||||||
WINDY_ENABLED,
|
|
||||||
WSLINK,
|
WSLINK,
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
|
|
@ -178,7 +176,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
|
||||||
else:
|
else:
|
||||||
if not register_path(hass, coordinator, coordinator_health, entry):
|
if not register_path(hass, coordinator, coordinator_health, entry):
|
||||||
_LOGGER.error("Fatal: path not registered!")
|
_LOGGER.error("Fatal: path not registered!")
|
||||||
raise ConfigEntryNotReady("Webhook routes could not be registered")
|
raise PlatformNotReady
|
||||||
|
|
||||||
routes = hass.data[DOMAIN].get("routes")
|
routes = hass.data[DOMAIN].get("routes")
|
||||||
if routes is not None:
|
if routes is not None:
|
||||||
|
|
@ -204,13 +202,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
|
||||||
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
||||||
"""Handle config entry option updates.
|
"""Handle config entry option updates.
|
||||||
|
|
||||||
We skip reloading when only live-read options change:
|
We skip reloading when only `SENSORS_TO_LOAD` changes.
|
||||||
- `SENSORS_TO_LOAD` (auto-discovery updates it as new payload fields appear), and
|
|
||||||
- the forwarding enable flags (`WINDY_ENABLED`/`POCASI_CZ_ENABLED`), which the
|
|
||||||
forwarders read on every push - so a forwarder that auto-disables itself from the
|
|
||||||
hot path no longer triggers a disruptive reload.
|
|
||||||
|
|
||||||
Why:
|
Why:
|
||||||
|
- Auto-discovery updates `SENSORS_TO_LOAD` as new payload fields appear.
|
||||||
- Reloading a push-based integration temporarily unloads platforms and removes
|
- Reloading a push-based integration temporarily unloads platforms and removes
|
||||||
coordinator listeners, which can make the UI appear "stuck" until restart.
|
coordinator listeners, which can make the UI appear "stuck" until restart.
|
||||||
"""
|
"""
|
||||||
|
|
@ -224,8 +219,8 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
||||||
|
|
||||||
runtime.last_options = new_options
|
runtime.last_options = new_options
|
||||||
|
|
||||||
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
|
if changed_keys == {SENSORS_TO_LOAD}:
|
||||||
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
|
_LOGGER.debug("Options updated (%s); skipping reload.", SENSORS_TO_LOAD)
|
||||||
return
|
return
|
||||||
|
|
||||||
update_legacy_battery_issue(hass, entry)
|
update_legacy_battery_issue(hass, entry)
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ from typing import Any
|
||||||
from py_typecheck import checked_or
|
from py_typecheck import checked_or
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .data import build_device_info
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
@ -61,5 +62,12 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Device info (single shared device for the whole integration)."""
|
"""Device info."""
|
||||||
return build_device_info(self.coordinator.config)
|
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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,7 @@ def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: lis
|
||||||
|
|
||||||
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
|
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
|
||||||
|
|
||||||
runtime = getattr(entry, "runtime_data", None)
|
runtime = entry.runtime_data
|
||||||
if runtime is None:
|
|
||||||
return
|
|
||||||
add_entities = runtime.add_binary_entities
|
add_entities = runtime.add_binary_entities
|
||||||
if add_entities is None:
|
if add_entities is None:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,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.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import selector
|
|
||||||
from homeassistant.helpers.network import get_url
|
from homeassistant.helpers.network import get_url
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
|
@ -38,9 +37,6 @@ from .const import (
|
||||||
WSLINK_ADDON_PORT,
|
WSLINK_ADDON_PORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Masked text input for secret fields (API keys / station passwords).
|
|
||||||
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
|
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(HomeAssistantError):
|
class CannotConnect(HomeAssistantError):
|
||||||
"""We can not connect. - not used in push mechanism."""
|
"""We can not connect. - not used in push mechanism."""
|
||||||
|
|
@ -83,7 +79,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
|
|
||||||
self.user_data_schema = {
|
self.user_data_schema = {
|
||||||
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
|
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
|
||||||
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): _PASSWORD_SELECTOR,
|
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): str,
|
||||||
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
|
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
|
||||||
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
|
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
|
||||||
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,
|
||||||
|
|
@ -109,7 +105,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
WINDY_STATION_PW,
|
WINDY_STATION_PW,
|
||||||
default=self.windy_data.get(WINDY_STATION_PW, ""),
|
default=self.windy_data.get(WINDY_STATION_PW, ""),
|
||||||
): _PASSWORD_SELECTOR,
|
): str,
|
||||||
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
|
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
WINDY_LOGGER_ENABLED,
|
WINDY_LOGGER_ENABLED,
|
||||||
|
|
@ -127,7 +123,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
|
|
||||||
self.pocasi_cz_schema = {
|
self.pocasi_cz_schema = {
|
||||||
vol.Required(POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
|
vol.Required(POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
|
||||||
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): _PASSWORD_SELECTOR,
|
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): str,
|
||||||
vol.Required(
|
vol.Required(
|
||||||
POCASI_CZ_SEND_INTERVAL,
|
POCASI_CZ_SEND_INTERVAL,
|
||||||
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
|
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
|
||||||
|
|
@ -334,7 +330,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
pws_schema = {
|
pws_schema = {
|
||||||
vol.Required(API_ID): str,
|
vol.Required(API_ID): str,
|
||||||
vol.Required(API_KEY): _PASSWORD_SELECTOR,
|
vol.Required(API_KEY): str,
|
||||||
vol.Optional(WSLINK): bool,
|
vol.Optional(WSLINK): bool,
|
||||||
vol.Optional(DEV_DBG): bool,
|
vol.Optional(DEV_DBG): bool,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,68 +92,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _validate_credentials(
|
|
||||||
self,
|
|
||||||
data: dict[str, Any],
|
|
||||||
webdata: aiohttp.web.Request,
|
|
||||||
*,
|
|
||||||
wslink: bool,
|
|
||||||
health: HealthCoordinator | None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate station credentials for the legacy / WSLink endpoint.
|
|
||||||
|
|
||||||
Raises HTTPUnauthorized (missing/empty/wrong credentials) or IncorrectDataError
|
|
||||||
(integration not configured); returns None on success.
|
|
||||||
"""
|
|
||||||
id_key, pw_key = ("wsid", "wspw") if wslink else ("ID", "PASSWORD")
|
|
||||||
|
|
||||||
if id_key not in data or pw_key not in data:
|
|
||||||
_LOGGER.error("Invalid request. No security data provided!")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="missing_credentials")
|
|
||||||
raise HTTPUnauthorized
|
|
||||||
|
|
||||||
id_data = data.get(id_key, "")
|
|
||||||
key_data = data.get(pw_key, "")
|
|
||||||
|
|
||||||
if (_id := checked(self.config.options.get(API_ID), str)) is None:
|
|
||||||
_LOGGER.error("We don't have API ID set! Update your config!")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(webdata, accepted=False, authorized=None, reason="config_missing_api_id")
|
|
||||||
raise IncorrectDataError
|
|
||||||
|
|
||||||
if (_key := checked(self.config.options.get(API_KEY), str)) is None:
|
|
||||||
_LOGGER.error("We don't have API KEY set! Update your config!")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(webdata, accepted=False, authorized=None, reason="config_missing_api_key")
|
|
||||||
raise IncorrectDataError
|
|
||||||
|
|
||||||
# Defense-in-depth: reject empty configured/incoming credentials so this handler
|
|
||||||
# is self-protecting regardless of how options were set.
|
|
||||||
if not _id or not _key:
|
|
||||||
_LOGGER.error("API ID/KEY is empty! Update your config!")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(
|
|
||||||
webdata, accepted=False, authorized=None, reason="config_missing_credentials"
|
|
||||||
)
|
|
||||||
raise IncorrectDataError
|
|
||||||
|
|
||||||
if not id_data or not key_data:
|
|
||||||
_LOGGER.error("Unauthorised access! Empty credentials.")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="unauthorized")
|
|
||||||
raise HTTPUnauthorized
|
|
||||||
|
|
||||||
# Constant-time comparison; both operands are always compared (no short-circuit)
|
|
||||||
# and encoded to bytes so non-ASCII credentials are handled safely.
|
|
||||||
id_ok = hmac.compare_digest(id_data.encode("utf-8"), _id.encode("utf-8"))
|
|
||||||
key_ok = hmac.compare_digest(key_data.encode("utf-8"), _key.encode("utf-8"))
|
|
||||||
if not (id_ok & key_ok):
|
|
||||||
_LOGGER.error("Unauthorised access!")
|
|
||||||
if health:
|
|
||||||
health.update_ingress_result(webdata, accepted=False, authorized=False, reason="unauthorized")
|
|
||||||
raise HTTPUnauthorized
|
|
||||||
|
|
||||||
async def received_ecowitt_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
async def received_ecowitt_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||||
"""Handle incoming Ecowitt webhook payload.
|
"""Handle incoming Ecowitt webhook payload.
|
||||||
|
|
||||||
|
|
@ -162,10 +100,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
entities through the bridge callback.
|
entities through the bridge callback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# See received_data: reject cleanly if the entry is mid-reload (no runtime_data).
|
|
||||||
if getattr(self.config, "runtime_data", None) is None:
|
|
||||||
return aiohttp.web.Response(text="Integration is reloading.", status=503)
|
|
||||||
|
|
||||||
health = self._health_coordinator()
|
health = self._health_coordinator()
|
||||||
|
|
||||||
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
|
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
|
||||||
|
|
@ -198,11 +132,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
post_data = await webdata.post()
|
post_data = await webdata.post()
|
||||||
data: dict[str, Any] = dict(post_data)
|
data: dict[str, Any] = dict(post_data)
|
||||||
|
|
||||||
# Record the Ecowitt station model (used as the device model) before parsing,
|
|
||||||
# so native entities created during process_payload report it.
|
|
||||||
if model := checked(data.get("model"), str):
|
|
||||||
self.config.runtime_data.ecowitt_model = model
|
|
||||||
|
|
||||||
mapped_data = await self.ecowitt_bridge.process_payload(data)
|
mapped_data = await self.ecowitt_bridge.process_payload(data)
|
||||||
|
|
||||||
if mapped_data:
|
if mapped_data:
|
||||||
|
|
@ -255,12 +184,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
- updates coordinator data so existing entities refresh immediately
|
- updates coordinator data so existing entities refresh immediately
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The aiohttp routes outlive a config-entry reload and keep pointing at this
|
|
||||||
# bound method. If a payload arrives while the entry is unloaded, runtime_data
|
|
||||||
# is gone; reject cleanly with 503 instead of raising AttributeError (500).
|
|
||||||
if getattr(self.config, "runtime_data", None) is None:
|
|
||||||
return aiohttp.web.Response(text="Integration is reloading.", status=503)
|
|
||||||
|
|
||||||
# WSLink uses different auth and payload field naming than the legacy endpoint.
|
# WSLink uses different auth and payload field naming than the legacy endpoint.
|
||||||
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
||||||
|
|
||||||
|
|
@ -270,7 +193,72 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
|
||||||
health = self._health_coordinator()
|
health = self._health_coordinator()
|
||||||
|
|
||||||
self._validate_credentials(data, webdata, wslink=_wslink, health=health)
|
if not _wslink and ("ID" not in data or "PASSWORD" not in data):
|
||||||
|
_LOGGER.error("Invalid request. No security data provided!")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=False,
|
||||||
|
reason="missing_credentials",
|
||||||
|
)
|
||||||
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
|
if _wslink and ("wsid" not in data or "wspw" not in data):
|
||||||
|
_LOGGER.error("Invalid request. No security data provided!")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=False,
|
||||||
|
reason="missing_credentials",
|
||||||
|
)
|
||||||
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
|
if _wslink:
|
||||||
|
id_data = data.get("wsid", "")
|
||||||
|
key_data = data.get("wspw", "")
|
||||||
|
else:
|
||||||
|
id_data = data.get("ID", "")
|
||||||
|
key_data = data.get("PASSWORD", "")
|
||||||
|
|
||||||
|
if (_id := checked(self.config.options.get(API_ID), str)) is None:
|
||||||
|
_LOGGER.error("We don't have API ID set! Update your config!")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=None,
|
||||||
|
reason="config_missing_api_id",
|
||||||
|
)
|
||||||
|
raise IncorrectDataError
|
||||||
|
|
||||||
|
if (_key := checked(self.config.options.get(API_KEY), str)) is None:
|
||||||
|
_LOGGER.error("We don't have API KEY set! Update your config!")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=None,
|
||||||
|
reason="config_missing_api_key",
|
||||||
|
)
|
||||||
|
raise IncorrectDataError
|
||||||
|
|
||||||
|
# Constant-time comparison to avoid leaking credential length/content via timing.
|
||||||
|
# Both operands are compared even if the first fails, so the branch order doesn't
|
||||||
|
# short-circuit. Encode to bytes so non-ASCII credentials are handled safely.
|
||||||
|
id_ok = hmac.compare_digest(id_data.encode("utf-8"), _id.encode("utf-8"))
|
||||||
|
key_ok = hmac.compare_digest(key_data.encode("utf-8"), _key.encode("utf-8"))
|
||||||
|
if not (id_ok & key_ok):
|
||||||
|
_LOGGER.error("Unauthorised access!")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=False,
|
||||||
|
reason="unauthorized",
|
||||||
|
)
|
||||||
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
|
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,10 @@ from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from py_typecheck import checked_or
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN, ECOWITT_ENABLED, WSLINK
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
|
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
|
||||||
|
|
||||||
|
|
@ -57,38 +51,6 @@ class SWSRuntimeData:
|
||||||
started_at: datetime = field(default_factory=dt_util.utcnow)
|
started_at: datetime = field(default_factory=dt_util.utcnow)
|
||||||
last_seen: dict[str, datetime] = field(default_factory=dict)
|
last_seen: dict[str, datetime] = field(default_factory=dict)
|
||||||
|
|
||||||
# Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload.
|
|
||||||
ecowitt_model: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# Type alias for typed ConfigEntry
|
# Type alias for typed ConfigEntry
|
||||||
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
|
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
|
||||||
|
|
||||||
|
|
||||||
def _station_model(entry: SWSConfigEntry) -> str:
|
|
||||||
"""Return the device model label reflecting the running station type.
|
|
||||||
|
|
||||||
Ecowitt (with the learned model when available), else WSLink, else PWS.
|
|
||||||
"""
|
|
||||||
if checked_or(entry.options.get(ECOWITT_ENABLED), bool, False):
|
|
||||||
runtime = getattr(entry, "runtime_data", None)
|
|
||||||
model = getattr(runtime, "ecowitt_model", None) if runtime is not None else None
|
|
||||||
return f"Ecowitt {model}" if model else "Ecowitt"
|
|
||||||
if checked_or(entry.options.get(WSLINK), bool, False):
|
|
||||||
return "WSLink"
|
|
||||||
return "PWS"
|
|
||||||
|
|
||||||
|
|
||||||
def build_device_info(entry: SWSConfigEntry) -> DeviceInfo:
|
|
||||||
"""Single device shared by all entities (SWS, battery, health, native Ecowitt).
|
|
||||||
|
|
||||||
Keeps the existing ``{(DOMAIN,)}`` identifier so no device-registry migration is
|
|
||||||
needed; the model reflects the active station type.
|
|
||||||
"""
|
|
||||||
return DeviceInfo(
|
|
||||||
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
|
|
||||||
name="Weather Station SWS 12500",
|
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
manufacturer="Schizza",
|
|
||||||
model=_station_model(entry),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,6 @@ TO_REDACT = {
|
||||||
"PASSWORD",
|
"PASSWORD",
|
||||||
"wsid",
|
"wsid",
|
||||||
"wspw",
|
"wspw",
|
||||||
# Internal network details from the health snapshot (admin-only download, but
|
|
||||||
# diagnostics are commonly shared in bug reports).
|
|
||||||
"home_assistant_source_ip",
|
|
||||||
"home_assistant_url",
|
|
||||||
"health_url",
|
|
||||||
"info_url",
|
|
||||||
"raw_status",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,19 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final
|
from typing import Any
|
||||||
|
|
||||||
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
||||||
from aioecowitt.sensor import SENSOR_MAP
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
from .const import REMAP_ECOWITT_COMPAT
|
from .const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||||
from .data import SWSConfigEntry, build_device_info
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -32,64 +33,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
# we need to know which key is internally covered.
|
# we need to know which key is internally covered.
|
||||||
_MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
|
_MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
|
||||||
|
|
||||||
# Upper bound on auto-created native Ecowitt entities. Bounds entity-registry growth
|
|
||||||
# from an (authenticated) sender that fabricates many distinct sensor keys.
|
|
||||||
MAX_NATIVE_ECOWITT_SENSORS: Final = 64
|
|
||||||
|
|
||||||
|
|
||||||
def _build_unit_twins() -> dict[str, frozenset[str]]:
|
|
||||||
"""Group aioecowitt keys that are unit variants of the same quantity.
|
|
||||||
|
|
||||||
aioecowitt exposes both metric and imperial sensors for many readings (e.g.
|
|
||||||
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
|
|
||||||
"""
|
|
||||||
by_name: dict[str, set[str]] = {}
|
|
||||||
for key, meta in SENSOR_MAP.items():
|
|
||||||
by_name.setdefault(meta.name, set()).add(key)
|
|
||||||
twins: dict[str, frozenset[str]] = {}
|
|
||||||
for keys in by_name.values():
|
|
||||||
if len(keys) > 1:
|
|
||||||
group = frozenset(keys)
|
|
||||||
for key in keys:
|
|
||||||
twins[key] = group
|
|
||||||
return twins
|
|
||||||
|
|
||||||
|
|
||||||
# key -> set of its unit-variant twin keys (incl. itself).
|
|
||||||
_UNIT_TWINS: dict[str, frozenset[str]] = _build_unit_twins()
|
|
||||||
|
|
||||||
# Curated translation keys for the common native (unmapped) Ecowitt sensors. Both unit
|
|
||||||
# variants map to the same slug so the name is stable regardless of the station's units.
|
|
||||||
# Long-tail / multi-channel sensors fall back to aioecowitt's English name.
|
|
||||||
_ECOWITT_TRANSLATIONS: dict[str, str] = {
|
|
||||||
"baromabsin": "ecowitt_absolute_pressure",
|
|
||||||
"baromabshpa": "ecowitt_absolute_pressure",
|
|
||||||
"rainratein": "ecowitt_rain_rate",
|
|
||||||
"rainratemm": "ecowitt_rain_rate",
|
|
||||||
"eventrainin": "ecowitt_event_rain",
|
|
||||||
"eventrainmm": "ecowitt_event_rain",
|
|
||||||
"hourlyrainin": "ecowitt_hourly_rain",
|
|
||||||
"hourlyrainmm": "ecowitt_hourly_rain",
|
|
||||||
"weeklyrainin": "ecowitt_weekly_rain",
|
|
||||||
"weeklyrainmm": "ecowitt_weekly_rain",
|
|
||||||
"monthlyrainin": "ecowitt_monthly_rain",
|
|
||||||
"monthlyrainmm": "ecowitt_monthly_rain",
|
|
||||||
"yearlyrainin": "ecowitt_yearly_rain",
|
|
||||||
"yearlyrainmm": "ecowitt_yearly_rain",
|
|
||||||
"totalrainin": "ecowitt_total_rain",
|
|
||||||
"totalrainmm": "ecowitt_total_rain",
|
|
||||||
"last24hrainin": "ecowitt_24h_rain",
|
|
||||||
"last24hrainmm": "ecowitt_24h_rain",
|
|
||||||
"tempfeelsc": "ecowitt_feels_like",
|
|
||||||
"tempfeelsf": "ecowitt_feels_like",
|
|
||||||
"dewpointinc": "ecowitt_indoor_dewpoint",
|
|
||||||
"dewpointinf": "ecowitt_indoor_dewpoint",
|
|
||||||
"co2in": "ecowitt_console_co2",
|
|
||||||
"co2in_24h": "ecowitt_console_co2_24h",
|
|
||||||
"co2": "ecowitt_co2",
|
|
||||||
"co2_24h": "ecowitt_co2_24h",
|
|
||||||
}
|
|
||||||
|
|
||||||
# aioecowitt sensor type to HA device class + unit
|
# aioecowitt sensor type to HA device class + unit
|
||||||
# We cover most common types, additional will be covered later.
|
# We cover most common types, additional will be covered later.
|
||||||
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
|
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
|
||||||
|
|
@ -168,16 +111,6 @@ STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None
|
||||||
"mm",
|
"mm",
|
||||||
SensorStateClass.TOTAL_INCREASING,
|
SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
EcoWittSensorTypes.RAIN_RATE_INCHES: (
|
|
||||||
SensorDeviceClass.PRECIPITATION_INTENSITY,
|
|
||||||
"in/h",
|
|
||||||
SensorStateClass.MEASUREMENT,
|
|
||||||
),
|
|
||||||
EcoWittSensorTypes.RAIN_COUNT_INCHES: (
|
|
||||||
SensorDeviceClass.PRECIPITATION,
|
|
||||||
"in",
|
|
||||||
SensorStateClass.TOTAL_INCREASING,
|
|
||||||
),
|
|
||||||
EcoWittSensorTypes.LIGHTNING_COUNT: (
|
EcoWittSensorTypes.LIGHTNING_COUNT: (
|
||||||
None,
|
None,
|
||||||
"strikes",
|
"strikes",
|
||||||
|
|
@ -214,7 +147,7 @@ class EcowittBridge:
|
||||||
and we are just using parsing/discovery logic.
|
and we are just using parsing/discovery logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||||
"""Initialize bridge."""
|
"""Initialize bridge."""
|
||||||
|
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
|
@ -233,18 +166,10 @@ class EcowittBridge:
|
||||||
self._add_entities_cb: AddEntitiesCallback | None = None
|
self._add_entities_cb: AddEntitiesCallback | None = None
|
||||||
|
|
||||||
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
|
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
|
||||||
"""Store the platform callback for dynamic entity creation.
|
"""Store the platform callback for dynamic entity creation."""
|
||||||
|
|
||||||
aioecowitt fires `new_sensor_cb` only once per key. If a payload arrived
|
|
||||||
before the platform was ready (callback unset), those unmapped sensors were
|
|
||||||
skipped and would never get an entity. Flush them now so nothing is lost.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._add_entities_cb = callback
|
self._add_entities_cb = callback
|
||||||
|
|
||||||
for sensor in self.unmapped_sensor.values():
|
|
||||||
self._on_new_sensor(sensor)
|
|
||||||
|
|
||||||
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
|
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
|
||||||
"""Process raw Ecowitt POST payload.
|
"""Process raw Ecowitt POST payload.
|
||||||
|
|
||||||
|
|
@ -285,28 +210,12 @@ class EcowittBridge:
|
||||||
if sensor.key in self._know_native_keys:
|
if sensor.key in self._know_native_keys:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Skip unit-variant duplicates: if a twin (e.g. the °C form of an already
|
|
||||||
# mapped °F sensor, or the other unit of an already-created native one) is
|
|
||||||
# handled, don't create a second entity. HA converts units via device_class.
|
|
||||||
twins = _UNIT_TWINS.get(sensor.key, frozenset())
|
|
||||||
if any(twin in _MAPPED_ECOWITT_KEYS or twin in self._know_native_keys for twin in twins):
|
|
||||||
_LOGGER.debug("Ecowitt sensor %s skipped: unit-variant twin already handled", sensor.key)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._add_entities_cb is None:
|
if self._add_entities_cb is None:
|
||||||
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
||||||
return
|
return
|
||||||
|
|
||||||
if len(self._know_native_keys) >= MAX_NATIVE_ECOWITT_SENSORS:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Reached the cap of %s native Ecowitt sensors; ignoring new key %s",
|
|
||||||
MAX_NATIVE_ECOWITT_SENSORS,
|
|
||||||
sensor.key,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._know_native_keys.add(sensor.key)
|
self._know_native_keys.add(sensor.key)
|
||||||
entity = EcoWittNativeSensor(sensor, self.config)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
self._add_entities_cb([entity])
|
self._add_entities_cb([entity])
|
||||||
|
|
||||||
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
|
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
|
||||||
|
|
@ -334,18 +243,13 @@ class EcoWittNativeSensor(SensorEntity):
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
|
||||||
def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
|
def __init__(self, sensor: EcoWittSensor) -> None:
|
||||||
"""Initialize native EcoWittSensor."""
|
"""Initialize native EcoWittSensor."""
|
||||||
|
|
||||||
self._ecowitt_sensor = sensor
|
self._ecowitt_sensor = sensor
|
||||||
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
||||||
|
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||||
# Use a curated translation_key for common native sensors; otherwise fall back
|
self._attr_name = sensor.name # default name, can be overridden by translation_key if we had one
|
||||||
# to aioecowitt's English name. _attr_translation_key is always set (None when
|
|
||||||
# there is no curated translation) so the attribute is well defined.
|
|
||||||
self._attr_translation_key = _ECOWITT_TRANSLATIONS.get(sensor.key)
|
|
||||||
if self._attr_translation_key is None:
|
|
||||||
self._attr_name = sensor.name
|
|
||||||
|
|
||||||
# set HomeAssistant metadata from aioecowitt sensor type.
|
# set HomeAssistant metadata from aioecowitt sensor type.
|
||||||
# Unknown types still get a usable entity (raw value, no device class/unit).
|
# Unknown types still get a usable entity (raw value, no device class/unit).
|
||||||
|
|
@ -356,9 +260,17 @@ class EcoWittNativeSensor(SensorEntity):
|
||||||
self._attr_native_unit_of_measurement = unit
|
self._attr_native_unit_of_measurement = unit
|
||||||
self._attr_state_class = state_class
|
self._attr_state_class = state_class
|
||||||
|
|
||||||
# Share the single integration device (see data.build_device_info); the station
|
# Always attach device info so the entity is grouped under the Ecowitt
|
||||||
# type is reflected in the device model rather than a separate Ecowitt device.
|
# station device even when its sensor type has no HA mapping.
|
||||||
self._attr_device_info = build_device_info(config)
|
station = sensor.station
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
connections=set(),
|
||||||
|
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
|
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
|
||||||
|
manufacturer="Ecowitt impl. from Schizza for SWS12500",
|
||||||
|
model=station.model if station else None,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]
|
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,8 @@ from homeassistant.util import dt as dt_util
|
||||||
from .const import (
|
from .const import (
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ECOWITT_ENABLED,
|
|
||||||
ECOWITT_URL_PREFIX,
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
LEGACY_ENABLED,
|
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
WSLINK,
|
WSLINK,
|
||||||
|
|
@ -55,23 +53,9 @@ from .windy_func import WindyPush
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Protocols that represent a real, accepted ingress (not health / unknown).
|
def _protocol_name(wslink_enabled: bool) -> str:
|
||||||
_REAL_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink", "ecowitt"})
|
"""Return the configured protocol name."""
|
||||||
_LEGACY_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink"})
|
return "wslink" if wslink_enabled else "wu"
|
||||||
|
|
||||||
|
|
||||||
def _configured_protocol(config: SWSConfigEntry) -> str:
|
|
||||||
"""Return the primary configured protocol (wu / wslink / ecowitt).
|
|
||||||
|
|
||||||
The legacy PWS/WSLink endpoint takes precedence when enabled; otherwise an
|
|
||||||
Ecowitt-only setup reports "ecowitt". (Legacy and Ecowitt can be enabled at the
|
|
||||||
same time; this just labels the primary protocol for the summary.)
|
|
||||||
"""
|
|
||||||
if checked_or(config.options.get(LEGACY_ENABLED), bool, True):
|
|
||||||
return "wslink" if checked_or(config.options.get(WSLINK), bool, False) else "wu"
|
|
||||||
if checked_or(config.options.get(ECOWITT_ENABLED), bool, False):
|
|
||||||
return "ecowitt"
|
|
||||||
return "wu"
|
|
||||||
|
|
||||||
|
|
||||||
def _protocol_from_path(path: str) -> str:
|
def _protocol_from_path(path: str) -> str:
|
||||||
|
|
@ -99,28 +83,6 @@ def _sanitize_path(path: str) -> str:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
# Fields under "addon" that reveal internal network topology. They must not be
|
|
||||||
# exposed via entity attributes (readable by any HA user, incl. non-admins).
|
|
||||||
_SENSITIVE_ADDON_FIELDS: frozenset[str] = frozenset(
|
|
||||||
{"health_url", "info_url", "home_assistant_url", "home_assistant_source_ip", "raw_status"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def public_health_snapshot(data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Return a copy of the health snapshot safe to expose to any HA user.
|
|
||||||
|
|
||||||
Removes internal network details (HA source IP, internal URLs, raw add-on
|
|
||||||
status). The full snapshot stays available only via the authenticated
|
|
||||||
`/station/health` endpoint and the admin-only (redacted) diagnostics download.
|
|
||||||
"""
|
|
||||||
public: dict[str, Any] = deepcopy(data)
|
|
||||||
addon = checked(public.get("addon"), dict[str, Any])
|
|
||||||
if addon is not None:
|
|
||||||
for field in _SENSITIVE_ADDON_FIELDS:
|
|
||||||
addon.pop(field, None)
|
|
||||||
return public
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
|
def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
|
||||||
"""Build the default forwarding status payload."""
|
"""Build the default forwarding status payload."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -133,7 +95,7 @@ def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
|
||||||
|
|
||||||
def _default_health_data(config: SWSConfigEntry) -> dict[str, Any]:
|
def _default_health_data(config: SWSConfigEntry) -> dict[str, Any]:
|
||||||
"""Build the default health/debug payload for this config entry."""
|
"""Build the default health/debug payload for this config entry."""
|
||||||
configured_protocol = _configured_protocol(config)
|
configured_protocol = _protocol_name(checked_or(config.options.get(WSLINK), bool, False))
|
||||||
return {
|
return {
|
||||||
"integration_status": f"online_{configured_protocol}",
|
"integration_status": f"online_{configured_protocol}",
|
||||||
"configured_protocol": configured_protocol,
|
"configured_protocol": configured_protocol,
|
||||||
|
|
@ -227,33 +189,22 @@ class HealthCoordinator(DataUpdateCoordinator):
|
||||||
accepted = bool(ingress.get("accepted"))
|
accepted = bool(ingress.get("accepted"))
|
||||||
reason = ingress.get("reason")
|
reason = ingress.get("reason")
|
||||||
|
|
||||||
# A WU vs WSLink mismatch means the station is misconfigured for the legacy
|
if (reason in {"route_disabled", "route_not_registered", "unauthorized"}) or (
|
||||||
# endpoint. Ecowitt coexists with the legacy endpoint, so it never counts as a
|
last_protocol in {"wu", "wslink"} and last_protocol != configured_protocol
|
||||||
# mismatch - it is a valid protocol whenever a payload arrives on its route.
|
):
|
||||||
legacy_mismatch = (
|
|
||||||
last_protocol in _LEGACY_PROTOCOLS
|
|
||||||
and configured_protocol in _LEGACY_PROTOCOLS
|
|
||||||
and last_protocol != configured_protocol
|
|
||||||
)
|
|
||||||
|
|
||||||
if (reason in {"route_disabled", "route_not_registered", "unauthorized"}) or legacy_mismatch:
|
|
||||||
integration_status = "degraded"
|
integration_status = "degraded"
|
||||||
elif accepted and last_protocol in _REAL_PROTOCOLS:
|
elif accepted and last_protocol in {"wu", "wslink"}:
|
||||||
integration_status = f"online_{last_protocol}"
|
integration_status = f"online_{last_protocol}"
|
||||||
else:
|
else:
|
||||||
integration_status = "online_idle"
|
integration_status = "online_idle"
|
||||||
|
|
||||||
data["integration_status"] = integration_status
|
data["integration_status"] = integration_status
|
||||||
data["active_protocol"] = (
|
data["active_protocol"] = (
|
||||||
last_protocol if accepted and last_protocol in _REAL_PROTOCOLS else configured_protocol
|
last_protocol if accepted and last_protocol in {"wu", "wslink"} else configured_protocol
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Refresh add-on health metadata from the WSLink proxy.
|
"""Refresh add-on health metadata from the WSLink proxy."""
|
||||||
|
|
||||||
The proxy add-on can front any protocol (WU / WSLink / Ecowitt), so the probe
|
|
||||||
is not gated on a specific protocol option - it always runs.
|
|
||||||
"""
|
|
||||||
session = async_get_clientsession(self.hass, False)
|
session = async_get_clientsession(self.hass, False)
|
||||||
url = get_url(self.hass)
|
url = get_url(self.hass)
|
||||||
ip = await async_get_source_ip(self.hass)
|
ip = await async_get_source_ip(self.hass)
|
||||||
|
|
@ -304,7 +255,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
||||||
def update_routing(self, routes: Routes | None) -> None:
|
def update_routing(self, routes: Routes | None) -> None:
|
||||||
"""Store the currently enabled routes for diagnostics."""
|
"""Store the currently enabled routes for diagnostics."""
|
||||||
data = deepcopy(self.data)
|
data = deepcopy(self.data)
|
||||||
data["configured_protocol"] = _configured_protocol(self.config)
|
data["configured_protocol"] = _protocol_name(checked_or(self.config.options.get(WSLINK), bool, False))
|
||||||
if routes is not None:
|
if routes is not None:
|
||||||
data["routes"] = {
|
data["routes"] = {
|
||||||
"wu_enabled": routes.path_enabled(DEFAULT_URL),
|
"wu_enabled": routes.path_enabled(DEFAULT_URL),
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@ from py_typecheck import checked, checked_or
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .data import SWSConfigEntry, build_device_info
|
from .const import DOMAIN
|
||||||
from .health_coordinator import HealthCoordinator, public_health_snapshot
|
from .data import SWSConfigEntry
|
||||||
|
from .health_coordinator import HealthCoordinator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .health_coordinator import HealthCoordinator
|
from .health_coordinator import HealthCoordinator
|
||||||
|
|
@ -246,22 +248,20 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
|
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
"""Expose the health JSON on the main health sensor for debugging.
|
"""Expose the full health JSON on the main health sensor for debugging."""
|
||||||
|
|
||||||
Entity attributes are readable by any HA user, so internal network details
|
|
||||||
(source IP, internal URLs, raw add-on status) are stripped here; they remain
|
|
||||||
available via the authenticated endpoint and the admin-only diagnostics.
|
|
||||||
"""
|
|
||||||
if self.entity_description.key != "integration_health":
|
if self.entity_description.key != "integration_health":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = checked_or(self.coordinator.data, dict[str, Any], None)
|
return checked_or(self.coordinator.data, dict[str, Any], None)
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return public_health_snapshot(data)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Device info (single shared device for the whole integration)."""
|
"""Device info."""
|
||||||
return build_device_info(self.coordinator.config)
|
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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,14 @@
|
||||||
"http"
|
"http"
|
||||||
],
|
],
|
||||||
"documentation": "https://github.com/schizza/SWS-12500-custom-component",
|
"documentation": "https://github.com/schizza/SWS-12500-custom-component",
|
||||||
"integration_type": "device",
|
"homekit": {},
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
||||||
"loggers": [
|
|
||||||
"aioecowitt"
|
|
||||||
],
|
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"typecheck-runtime==0.2.0",
|
"typecheck-runtime==0.2.0",
|
||||||
"aioecowitt==2025.9.2"
|
"aioecowitt==2025.9.2"
|
||||||
],
|
],
|
||||||
"single_config_entry": true,
|
"ssdp": [],
|
||||||
"version": "2.0.0-pre1"
|
"version": "2.0.0-pre1",
|
||||||
|
"zeroconf": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
@ -12,7 +12,6 @@ from py_typecheck.core import checked
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
|
|
@ -57,8 +56,8 @@ class PocasiPush:
|
||||||
self.last_attempt_at: str | None = None
|
self.last_attempt_at: str | None = None
|
||||||
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
|
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
|
||||||
|
|
||||||
self.last_update = dt_util.utcnow()
|
self.last_update = datetime.now()
|
||||||
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
|
self.next_update = datetime.now() + timedelta(seconds=self._interval)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -82,7 +81,7 @@ class PocasiPush:
|
||||||
|
|
||||||
_data = data.copy()
|
_data = data.copy()
|
||||||
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
|
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
|
||||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
self.last_attempt_at = datetime.now().isoformat()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
||||||
|
|
@ -104,7 +103,7 @@ class PocasiPush:
|
||||||
str(self.next_update),
|
str(self.next_update),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.next_update > dt_util.utcnow():
|
if self.next_update > datetime.now():
|
||||||
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 possilbe update is set to: %s",
|
||||||
|
|
@ -113,9 +112,6 @@ class PocasiPush:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Reserve the next send window before the await to avoid concurrent double-sends.
|
|
||||||
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
|
|
||||||
|
|
||||||
request_url: str = ""
|
request_url: str = ""
|
||||||
if mode == "WSLINK":
|
if mode == "WSLINK":
|
||||||
_data["wsid"] = _api_id
|
_data["wsid"] = _api_id
|
||||||
|
|
@ -157,9 +153,7 @@ class PocasiPush:
|
||||||
|
|
||||||
except ClientError as ex:
|
except ClientError as ex:
|
||||||
self.last_status = "client_error"
|
self.last_status = "client_error"
|
||||||
# Store only the exception class - last_error is surfaced via entity
|
self.last_error = str(ex)
|
||||||
# attributes; str(ex) could embed the request URL.
|
|
||||||
self.last_error = type(ex).__name__
|
|
||||||
_LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex))
|
_LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex))
|
||||||
self.invalid_response_count += 1
|
self.invalid_response_count += 1
|
||||||
if self.invalid_response_count >= 3:
|
if self.invalid_response_count >= 3:
|
||||||
|
|
@ -167,8 +161,8 @@ class PocasiPush:
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
|
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
|
||||||
|
|
||||||
self.last_update = dt_util.utcnow()
|
self.last_update = datetime.now()
|
||||||
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
|
self.next_update = datetime.now() + timedelta(seconds=self._interval)
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from py_typecheck import checked_or
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.core import HomeAssistant
|
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 import DeviceInfo, generate_entity_id
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
@ -32,6 +33,7 @@ from . import health_sensor
|
||||||
from .const import (
|
from .const import (
|
||||||
CHILL_INDEX,
|
CHILL_INDEX,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
|
DOMAIN,
|
||||||
HEAT_INDEX,
|
HEAT_INDEX,
|
||||||
OUTSIDE_HUMIDITY,
|
OUTSIDE_HUMIDITY,
|
||||||
OUTSIDE_TEMP,
|
OUTSIDE_TEMP,
|
||||||
|
|
@ -41,7 +43,7 @@ from .const import (
|
||||||
WIND_SPEED,
|
WIND_SPEED,
|
||||||
WSLINK,
|
WSLINK,
|
||||||
)
|
)
|
||||||
from .data import SWSConfigEntry, build_device_info
|
from .data import SWSConfigEntry
|
||||||
from .sensors_common import WeatherSensorEntityDescription
|
from .sensors_common import WeatherSensorEntityDescription
|
||||||
from .sensors_weather import SENSOR_TYPES_WEATHER_API
|
from .sensors_weather import SENSOR_TYPES_WEATHER_API
|
||||||
from .sensors_wslink import SENSOR_TYPES_WSLINK
|
from .sensors_wslink import SENSOR_TYPES_WSLINK
|
||||||
|
|
@ -130,9 +132,7 @@ def add_new_sensors(hass: HomeAssistant, config_entry: SWSConfigEntry, keys: lis
|
||||||
|
|
||||||
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
|
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
|
||||||
|
|
||||||
runtime = getattr(config_entry, "runtime_data", None)
|
runtime = config_entry.runtime_data
|
||||||
if runtime is None:
|
|
||||||
return
|
|
||||||
add_entities = runtime.add_sensor_entities
|
add_entities = runtime.add_sensor_entities
|
||||||
if add_entities is None:
|
if add_entities is None:
|
||||||
return
|
return
|
||||||
|
|
@ -223,5 +223,12 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def device_info(self) -> DeviceInfo:
|
def device_info(self) -> DeviceInfo:
|
||||||
"""Device info (single shared device for the whole integration)."""
|
"""Device info."""
|
||||||
return build_device_info(self.coordinator.config)
|
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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,7 @@
|
||||||
"valid_credentials_key": "Provide valid API KEY.",
|
"valid_credentials_key": "Provide valid API KEY.",
|
||||||
"valid_credentials_match": "API ID and API KEY should not be the same.",
|
"valid_credentials_match": "API ID and API KEY should not be the same.",
|
||||||
"windy_id_required": "Windy API ID is required if you want to enable this function.",
|
"windy_id_required": "Windy API ID is required if you want to enable this function.",
|
||||||
"windy_pw_required": "Windy API password is required if you want to enable this function.",
|
"windy_pw_required": "Windy API password is required if you want to enable this function."
|
||||||
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",
|
|
||||||
"pocasi_id_required": "Pocasi Meteo API ID is required if you want to enable this function.",
|
|
||||||
"pocasi_key_required": "Pocasi Meteo API key is required if you want to enable this function.",
|
|
||||||
"pocasi_send_minimum": "The resend interval must be at least 12 seconds."
|
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
|
|
@ -62,10 +58,7 @@
|
||||||
"description": "Choose what do you want to configure. If basic access or resending data for Windy site",
|
"description": "Choose what do you want to configure. If basic access or resending data for Windy site",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"basic": "Basic - configure credentials for Weather Station",
|
"basic": "Basic - configure credentials for Weather Station",
|
||||||
"wslink_port_setup": "WSLink add-on port",
|
"windy": "Windy configuration"
|
||||||
"ecowitt": "Ecowitt configuration",
|
|
||||||
"windy": "Windy configuration",
|
|
||||||
"pocasi": "Pocasi Meteo CZ configuration"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic": {
|
"basic": {
|
||||||
|
|
@ -74,16 +67,14 @@
|
||||||
"data": {
|
"data": {
|
||||||
"API_ID": "API ID / Station ID",
|
"API_ID": "API ID / Station ID",
|
||||||
"API_KEY": "API KEY / Password",
|
"API_KEY": "API KEY / Password",
|
||||||
"wslink": "WSLink API",
|
"WSLINK": "WSLink API",
|
||||||
"legacy_enabled": "Enable PWS/WSLink endpoint",
|
|
||||||
"dev_debug_checkbox": "Developer log"
|
"dev_debug_checkbox": "Developer log"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer.",
|
||||||
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
"API_ID": "API ID is the Station ID you set in the Weather Station.",
|
||||||
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
"API_KEY": "API KEY is the password you set in the Weather Station.",
|
||||||
"wslink": "Enable WSLink API if the station is set to send data via WSLink.",
|
"WSLINK": "Enable WSLink API if the station is set to send data via WSLink."
|
||||||
"legacy_enabled": "Enable the PWS/WSLink endpoint. Turn off for an Ecowitt-only setup."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"windy": {
|
"windy": {
|
||||||
|
|
@ -107,38 +98,28 @@
|
||||||
"data": {
|
"data": {
|
||||||
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
|
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
|
||||||
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
|
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
|
||||||
"POCASI_SEND_INTERVAL": "Resend interval in seconds",
|
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds",
|
||||||
"pocasi_enabled_chcekbox": "Enable resending data to Pocasi Meteo",
|
"pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo",
|
||||||
"pocasi_logger_checkbox": "Log data and responses"
|
"pocasi_logger_checkbox": "Log data and responses"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App",
|
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App",
|
||||||
"POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
|
"POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
|
||||||
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
|
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
|
||||||
"pocasi_enabled_chcekbox": "Enables resending data to Pocasi Meteo",
|
"pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo",
|
||||||
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ecowitt": {
|
"ecowitt": {
|
||||||
"description": "Ecowitt configuration.",
|
"description": "Nastavení pro Ecowitt",
|
||||||
"title": "Ecowitt station configuration",
|
"title": "Konfigurace pro stanice Ecowitt",
|
||||||
"data": {
|
"data": {
|
||||||
"ecowitt_webhook_id": "Unique webhook ID",
|
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||||
"ecowitt_enabled": "Enable Ecowitt station data"
|
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
|
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
|
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
|
||||||
}
|
|
||||||
},
|
|
||||||
"wslink_port_setup": {
|
|
||||||
"title": "WSLink add-on port",
|
|
||||||
"description": "Set the external port of the WSLink proxy add-on so the integration can reach its health endpoint.",
|
|
||||||
"data": {
|
|
||||||
"WSLINK_ADDON_PORT": "WSLink add-on port"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
|
|
@ -186,57 +167,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"ecowitt_absolute_pressure": {
|
|
||||||
"name": "Absolute pressure"
|
|
||||||
},
|
|
||||||
"ecowitt_rain_rate": {
|
|
||||||
"name": "Rain rate"
|
|
||||||
},
|
|
||||||
"ecowitt_event_rain": {
|
|
||||||
"name": "Event rain"
|
|
||||||
},
|
|
||||||
"ecowitt_hourly_rain": {
|
|
||||||
"name": "Hourly rain"
|
|
||||||
},
|
|
||||||
"ecowitt_weekly_rain": {
|
|
||||||
"name": "Weekly rain"
|
|
||||||
},
|
|
||||||
"ecowitt_monthly_rain": {
|
|
||||||
"name": "Monthly rain"
|
|
||||||
},
|
|
||||||
"ecowitt_yearly_rain": {
|
|
||||||
"name": "Yearly rain"
|
|
||||||
},
|
|
||||||
"ecowitt_total_rain": {
|
|
||||||
"name": "Total rain"
|
|
||||||
},
|
|
||||||
"ecowitt_24h_rain": {
|
|
||||||
"name": "24h rain"
|
|
||||||
},
|
|
||||||
"ecowitt_feels_like": {
|
|
||||||
"name": "Feels like"
|
|
||||||
},
|
|
||||||
"ecowitt_indoor_dewpoint": {
|
|
||||||
"name": "Indoor dew point"
|
|
||||||
},
|
|
||||||
"ecowitt_console_co2": {
|
|
||||||
"name": "Console CO₂"
|
|
||||||
},
|
|
||||||
"ecowitt_console_co2_24h": {
|
|
||||||
"name": "Console CO₂ (24h avg)"
|
|
||||||
},
|
|
||||||
"ecowitt_co2": {
|
|
||||||
"name": "CO₂"
|
|
||||||
},
|
|
||||||
"ecowitt_co2_24h": {
|
|
||||||
"name": "CO₂ (24h avg)"
|
|
||||||
},
|
|
||||||
"integration_health": {
|
"integration_health": {
|
||||||
"name": "Integration status",
|
"name": "Integration status",
|
||||||
"state": {
|
"state": {
|
||||||
"online_wu": "Online PWS/WU",
|
"online_wu": "Online PWS/WU",
|
||||||
"online_wslink": "Online WSLink",
|
"online_wslink": "Online WSLink",
|
||||||
"online_ecowitt": "Online Ecowitt",
|
|
||||||
"online_idle": "Waiting for data",
|
"online_idle": "Waiting for data",
|
||||||
"degraded": "Degraded",
|
"degraded": "Degraded",
|
||||||
"error": "Error"
|
"error": "Error"
|
||||||
|
|
@ -246,8 +181,7 @@
|
||||||
"name": "Active protocol",
|
"name": "Active protocol",
|
||||||
"state": {
|
"state": {
|
||||||
"wu": "PWS/WU",
|
"wu": "PWS/WU",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink API"
|
||||||
"ecowitt": "Ecowitt"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wslink_addon_status": {
|
"wslink_addon_status": {
|
||||||
|
|
@ -282,8 +216,7 @@
|
||||||
"name": "Last access protocol",
|
"name": "Last access protocol",
|
||||||
"state": {
|
"state": {
|
||||||
"wu": "PWS/WU",
|
"wu": "PWS/WU",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink API"
|
||||||
"ecowitt": "Ecowitt"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"last_ingress_route_enabled": {
|
"last_ingress_route_enabled": {
|
||||||
|
|
@ -428,7 +361,7 @@
|
||||||
"yearly_rain": {
|
"yearly_rain": {
|
||||||
"name": "Yearly precipitation"
|
"name": "Yearly precipitation"
|
||||||
},
|
},
|
||||||
"wbgt_temp": {
|
"wbgt_index": {
|
||||||
"name": "WBGT index"
|
"name": "WBGT index"
|
||||||
},
|
},
|
||||||
"hcho": {
|
"hcho": {
|
||||||
|
|
@ -545,11 +478,7 @@
|
||||||
"issues": {
|
"issues": {
|
||||||
"legacy_battery_sensor_deprecated": {
|
"legacy_battery_sensor_deprecated": {
|
||||||
"title": "Legacy battery sensor detected",
|
"title": "Legacy battery sensor detected",
|
||||||
"description": "Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings -> Devices & Services -> SWS 12500."
|
"description": "Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings Old battery sensor entities ({entities}) were found in the entity registry. They have been replaced by binary battery sensors and will be removed in version {remove_version}. Please delete the legacy entities manually via Settings -> Devices & Services -> SWS 12500."
|
||||||
},
|
|
||||||
"stale_sensors_detected": {
|
|
||||||
"title": "Stale sensors detected",
|
|
||||||
"description": "These sensors have not reported data for an extended period and may be misconfigured or out of range: {sensors}. If you no longer use them, remove them from the integration options."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@
|
||||||
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
|
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
|
||||||
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
|
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
|
||||||
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
|
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
|
||||||
"windy_key_required": "Pro aktivaci je vyžadováno Windy ID i heslo stanice.",
|
|
||||||
"pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ",
|
"pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ",
|
||||||
"pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.",
|
"pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.",
|
||||||
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
|
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
|
||||||
|
|
@ -108,15 +107,15 @@
|
||||||
"data": {
|
"data": {
|
||||||
"POCASI_CZ_API_ID": "ID účtu na Počasí Meteo",
|
"POCASI_CZ_API_ID": "ID účtu na Počasí Meteo",
|
||||||
"POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo",
|
"POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo",
|
||||||
"POCASI_SEND_INTERVAL": "Interval v sekundách",
|
"POCASI_CZ_SEND_INTERVAL": "Interval v sekundách",
|
||||||
"pocasi_enabled_chcekbox": "Povolit přeposílání dat na server Počasí Meteo",
|
"pocasi_enabled_chcekbox": "Povolit přeposílání dat na server Počasí Meteo",
|
||||||
"pocasi_logger_checkbox": "Logovat data a odpovědi z Počasí Meteo"
|
"pocasi_logger_checkbox": "Logovat data a odpovědi z Počasí Meteo"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"POCASI_CZ_API_ID": "ID získáte ve své aplikaci Počasí Meteo",
|
"POCASI_API_ID": "ID získáte ve své aplikaci Počasí Meteo",
|
||||||
"POCASI_CZ_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo",
|
"POCASI_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo",
|
||||||
"POCASI_SEND_INTERVAL": "Interval v jakém se mají data na server přeposílat (minimum 12s, defaultně 30s)",
|
"POCASI_SEND_INTERVAL": "Interval v jakém se mají data na server přeposílat (minimum 12s, defaultně 30s)",
|
||||||
"pocasi_enabled_chcekbox": "Zapne přeposílání data na server Počasí Meteo",
|
"pocasi_enabled_checkbox": "Zapne přeposílání data na server Počasí Meteo",
|
||||||
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
|
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -187,57 +186,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"ecowitt_absolute_pressure": {
|
|
||||||
"name": "Absolutní tlak"
|
|
||||||
},
|
|
||||||
"ecowitt_rain_rate": {
|
|
||||||
"name": "Intenzita srážek"
|
|
||||||
},
|
|
||||||
"ecowitt_event_rain": {
|
|
||||||
"name": "Srážky (událost)"
|
|
||||||
},
|
|
||||||
"ecowitt_hourly_rain": {
|
|
||||||
"name": "Hodinové srážky"
|
|
||||||
},
|
|
||||||
"ecowitt_weekly_rain": {
|
|
||||||
"name": "Týdenní srážky"
|
|
||||||
},
|
|
||||||
"ecowitt_monthly_rain": {
|
|
||||||
"name": "Měsíční srážky"
|
|
||||||
},
|
|
||||||
"ecowitt_yearly_rain": {
|
|
||||||
"name": "Roční srážky"
|
|
||||||
},
|
|
||||||
"ecowitt_total_rain": {
|
|
||||||
"name": "Celkové srážky"
|
|
||||||
},
|
|
||||||
"ecowitt_24h_rain": {
|
|
||||||
"name": "Srážky za 24 h"
|
|
||||||
},
|
|
||||||
"ecowitt_feels_like": {
|
|
||||||
"name": "Pocitová teplota"
|
|
||||||
},
|
|
||||||
"ecowitt_indoor_dewpoint": {
|
|
||||||
"name": "Vnitřní rosný bod"
|
|
||||||
},
|
|
||||||
"ecowitt_console_co2": {
|
|
||||||
"name": "CO₂ konzole"
|
|
||||||
},
|
|
||||||
"ecowitt_console_co2_24h": {
|
|
||||||
"name": "CO₂ konzole (průměr 24 h)"
|
|
||||||
},
|
|
||||||
"ecowitt_co2": {
|
|
||||||
"name": "CO₂"
|
|
||||||
},
|
|
||||||
"ecowitt_co2_24h": {
|
|
||||||
"name": "CO₂ (průměr 24 h)"
|
|
||||||
},
|
|
||||||
"integration_health": {
|
"integration_health": {
|
||||||
"name": "Stav integrace",
|
"name": "Stav integrace",
|
||||||
"state": {
|
"state": {
|
||||||
"online_wu": "Online PWS/WU",
|
"online_wu": "Online PWS/WU",
|
||||||
"online_wslink": "Online WSLink",
|
"online_wslink": "Online WSLink",
|
||||||
"online_ecowitt": "Online Ecowitt",
|
|
||||||
"online_idle": "Čekám na data",
|
"online_idle": "Čekám na data",
|
||||||
"degraded": "Degradovaný",
|
"degraded": "Degradovaný",
|
||||||
"error": "Nefunkční"
|
"error": "Nefunkční"
|
||||||
|
|
@ -247,8 +200,7 @@
|
||||||
"name": "Aktivní protokol",
|
"name": "Aktivní protokol",
|
||||||
"state": {
|
"state": {
|
||||||
"wu": "PWS/WU",
|
"wu": "PWS/WU",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink API"
|
||||||
"ecowitt": "Ecowitt"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wslink_addon_status": {
|
"wslink_addon_status": {
|
||||||
|
|
@ -283,8 +235,7 @@
|
||||||
"name": "Protokol posledního přístupu",
|
"name": "Protokol posledního přístupu",
|
||||||
"state": {
|
"state": {
|
||||||
"wu": "PWS/WU",
|
"wu": "PWS/WU",
|
||||||
"wslink": "WSLink API",
|
"wslink": "WSLink API"
|
||||||
"ecowitt": "Ecowitt"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"last_ingress_route_enabled": {
|
"last_ingress_route_enabled": {
|
||||||
|
|
@ -387,30 +338,6 @@
|
||||||
"ch4_humidity": {
|
"ch4_humidity": {
|
||||||
"name": "Vlhkost sensoru 4"
|
"name": "Vlhkost sensoru 4"
|
||||||
},
|
},
|
||||||
"ch5_temp": {
|
|
||||||
"name": "Teplota senzoru 5"
|
|
||||||
},
|
|
||||||
"ch5_humidity": {
|
|
||||||
"name": "Vlhkost sensoru 5"
|
|
||||||
},
|
|
||||||
"ch6_temp": {
|
|
||||||
"name": "Teplota senzoru 6"
|
|
||||||
},
|
|
||||||
"ch6_humidity": {
|
|
||||||
"name": "Vlhkost sensoru 6"
|
|
||||||
},
|
|
||||||
"ch7_temp": {
|
|
||||||
"name": "Teplota senzoru 7"
|
|
||||||
},
|
|
||||||
"ch7_humidity": {
|
|
||||||
"name": "Vlhkost sensoru 7"
|
|
||||||
},
|
|
||||||
"ch8_temp": {
|
|
||||||
"name": "Teplota senzoru 8"
|
|
||||||
},
|
|
||||||
"ch8_humidity": {
|
|
||||||
"name": "Vlhkost sensoru 8"
|
|
||||||
},
|
|
||||||
"heat_index": {
|
"heat_index": {
|
||||||
"name": "Tepelný index"
|
"name": "Tepelný index"
|
||||||
},
|
},
|
||||||
|
|
@ -492,54 +419,6 @@
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"unknown": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ch3_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 3",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ch4_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 4",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ch5_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 5",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ch6_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 6",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ch7_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 7",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ch8_battery": {
|
|
||||||
"name": "Stav nabití baterie kanálu 8",
|
|
||||||
"state": {
|
|
||||||
"low": "Nízká",
|
|
||||||
"normal": "Normální",
|
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,7 @@ from homeassistant.helpers.translation import async_get_translations
|
||||||
from .const import (
|
from .const import (
|
||||||
AZIMUT,
|
AZIMUT,
|
||||||
CONNECTION_GATED_SENSORS,
|
CONNECTION_GATED_SENSORS,
|
||||||
|
# DATABASE_PATH,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
OUTSIDE_HUMIDITY,
|
OUTSIDE_HUMIDITY,
|
||||||
OUTSIDE_TEMP,
|
OUTSIDE_TEMP,
|
||||||
|
|
@ -111,7 +112,7 @@ def anonymize(
|
||||||
- Keep all keys, but mask sensitive values.
|
- Keep all keys, but mask sensitive values.
|
||||||
- Do not raise on unexpected/missing keys.
|
- Do not raise on unexpected/missing keys.
|
||||||
"""
|
"""
|
||||||
secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"}
|
secrets = {"ID", "PASSWORD", "wsid", "wspw"}
|
||||||
|
|
||||||
return {k: ("***" if k in secrets else v) for k, v in data.items()}
|
return {k: ("***" if k in secrets else v) for k, v in data.items()}
|
||||||
|
|
||||||
|
|
@ -260,7 +261,7 @@ def celsius_to_fahrenheit(celsius: float) -> float:
|
||||||
|
|
||||||
|
|
||||||
def to_int(val: Any) -> int | None:
|
def to_int(val: Any) -> int | None:
|
||||||
"""Convert int or string (including decimal-formatted, e.g. "180.0") to int."""
|
"""Convert int or string to int."""
|
||||||
|
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -269,15 +270,11 @@ def to_int(val: Any) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return int(val)
|
v = int(val)
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# The station sometimes sends integer fields as decimals ("180.0"); accept those.
|
|
||||||
try:
|
|
||||||
return int(float(val))
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
def to_float(val: Any) -> float | None:
|
def to_float(val: Any) -> float | None:
|
||||||
|
|
@ -396,3 +393,110 @@ def battery_5step_to_pct(value: str) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return round(int(value) / 5 * 100)
|
return round(int(value) / 5 * 100)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# def long_term_units_in_statistics_meta():
|
||||||
|
# """Get units in long term statitstics."""
|
||||||
|
# sensor_units = []
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# SELECT statistic_id, unit_of_measurement from statistics_meta
|
||||||
|
# WHERE statistic_id LIKE 'sensor.weather_station_sws%'
|
||||||
|
# """
|
||||||
|
# )
|
||||||
|
# rows = db.fetchall()
|
||||||
|
# sensor_units = {
|
||||||
|
# statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
#
|
||||||
|
# return sensor_units
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
|
||||||
|
# """Migrate data from mm/d to mm."""
|
||||||
|
#
|
||||||
|
# _LOGGER.debug("Sensor %s is required for data migration", sensor_id)
|
||||||
|
# updated_rows = 0
|
||||||
|
#
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# _LOGGER.info(sensor_id)
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE statistics_meta
|
||||||
|
# SET unit_of_measurement = 'mm'
|
||||||
|
# WHERE statistic_id = ?
|
||||||
|
# AND unit_of_measurement = 'mm/d';
|
||||||
|
# """,
|
||||||
|
# (sensor_id,),
|
||||||
|
# )
|
||||||
|
# updated_rows = db.rowcount
|
||||||
|
# conn.commit()
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Data migration completed successfully. Updated rows: %s for %s",
|
||||||
|
# updated_rows,
|
||||||
|
# sensor_id,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
# return updated_rows
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# def migrate_data_old(sensor_id: str | None = None):
|
||||||
|
# """Migrate data from mm/d to mm."""
|
||||||
|
# updated_rows = 0
|
||||||
|
#
|
||||||
|
# if not Path(DATABASE_PATH).exists():
|
||||||
|
# _LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
# db = conn.cursor()
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# _LOGGER.info(sensor_id)
|
||||||
|
# db.execute(
|
||||||
|
# """
|
||||||
|
# UPDATE statistics_meta
|
||||||
|
# SET unit_of_measurement = 'mm'
|
||||||
|
# WHERE statistic_id = ?
|
||||||
|
# AND unit_of_measurement = 'mm/d';
|
||||||
|
# """,
|
||||||
|
# (sensor_id,),
|
||||||
|
# )
|
||||||
|
# updated_rows = db.rowcount
|
||||||
|
# conn.commit()
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Data migration completed successfully. Updated rows: %s for %s",
|
||||||
|
# updated_rows,
|
||||||
|
# sensor_id,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# except sqlite3.Error as e:
|
||||||
|
# _LOGGER.error("Error during data migration: %s", e)
|
||||||
|
# finally:
|
||||||
|
# conn.close()
|
||||||
|
# return updated_rows
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ from homeassistant.components import persistent_notification
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
PURGE_DATA,
|
PURGE_DATA,
|
||||||
|
|
@ -89,8 +88,8 @@ class WindyPush:
|
||||||
""" lets wait for 1 minute to get initial data from station
|
""" lets wait for 1 minute to get initial data from station
|
||||||
and then try to push first data to Windy
|
and then try to push first data to Windy
|
||||||
"""
|
"""
|
||||||
self.last_update: datetime = dt_util.utcnow()
|
self.last_update: datetime = datetime.now()
|
||||||
self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
|
self.next_update: datetime = datetime.now() + timed(minutes=1)
|
||||||
|
|
||||||
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
||||||
|
|
||||||
|
|
@ -171,7 +170,7 @@ 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.enabled = self.config.options.get(WINDY_ENABLED, False)
|
||||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
self.last_attempt_at = datetime.now().isoformat()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
|
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
|
||||||
|
|
@ -197,14 +196,10 @@ class WindyPush:
|
||||||
str(self.next_update),
|
str(self.next_update),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.next_update > dt_util.utcnow():
|
if self.next_update > datetime.now():
|
||||||
self.last_status = "rate_limited_local"
|
self.last_status = "rate_limited_local"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Reserve the next send window now (before the await below) so a concurrent
|
|
||||||
# webhook does not also pass the rate-limit check and double-send.
|
|
||||||
self.next_update = dt_util.utcnow() + timed(minutes=5)
|
|
||||||
|
|
||||||
purged_data = data.copy()
|
purged_data = data.copy()
|
||||||
|
|
||||||
for purge in PURGE_DATA:
|
for purge in PURGE_DATA:
|
||||||
|
|
@ -224,8 +219,7 @@ class WindyPush:
|
||||||
headers = {"Authorization": f"Bearer {windy_station_pw}"}
|
headers = {"Authorization": f"Bearer {windy_station_pw}"}
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
# Mask the station id (a credential) before logging the dataset.
|
_LOGGER.info("Dataset for windy: %s", purged_data)
|
||||||
_LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"})
|
|
||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
try:
|
try:
|
||||||
async with session.get(request_url, params=purged_data, headers=headers) as resp:
|
async with session.get(request_url, params=purged_data, headers=headers) as resp:
|
||||||
|
|
@ -266,7 +260,7 @@ class WindyPush:
|
||||||
_LOGGER.critical(
|
_LOGGER.critical(
|
||||||
"Windy responded with WindyRateLimitExceeded, this should happend only on restarting Home Assistant when we lost track of last send time. Pause resend for next 5 minutes."
|
"Windy responded with WindyRateLimitExceeded, this should happend only on restarting Home Assistant when we lost track of last send time. Pause resend for next 5 minutes."
|
||||||
)
|
)
|
||||||
self.next_update = dt_util.utcnow() + timedelta(minutes=5)
|
self.next_update = datetime.now() + timedelta(minutes=5)
|
||||||
|
|
||||||
except WindySuccess:
|
except WindySuccess:
|
||||||
# reset invalid_response_count
|
# reset invalid_response_count
|
||||||
|
|
@ -295,9 +289,7 @@ class WindyPush:
|
||||||
|
|
||||||
except ClientError as ex:
|
except ClientError as ex:
|
||||||
self.last_status = "client_error"
|
self.last_status = "client_error"
|
||||||
# Store only the exception class - last_error is surfaced via entity
|
self.last_error = str(ex)
|
||||||
# attributes; str(ex) could embed the request URL.
|
|
||||||
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 retries before disabling resend function: %s",
|
||||||
str(ex),
|
str(ex),
|
||||||
|
|
@ -307,7 +299,7 @@ class WindyPush:
|
||||||
if self.invalid_response_count >= WINDY_MAX_RETRIES:
|
if self.invalid_response_count >= WINDY_MAX_RETRIES:
|
||||||
_LOGGER.critical(WINDY_UNEXPECTED)
|
_LOGGER.critical(WINDY_UNEXPECTED)
|
||||||
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.")
|
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.")
|
||||||
self.last_update = dt_util.utcnow()
|
self.last_update = datetime.now()
|
||||||
self.next_update = self.last_update + timed(minutes=5)
|
self.next_update = self.last_update + timed(minutes=5)
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ class _CoordinatorStub:
|
||||||
|
|
||||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||||
self.data: dict[str, Any] = data if data is not None else {}
|
self.data: dict[str, Any] = data if data is not None else {}
|
||||||
# device_info reads coordinator.config for the shared device model.
|
|
||||||
self.config = SimpleNamespace(options={})
|
|
||||||
|
|
||||||
|
|
||||||
def _make_entry(
|
def _make_entry(
|
||||||
|
|
@ -215,11 +213,5 @@ def test_device_info():
|
||||||
info = sensor.device_info
|
info = sensor.device_info
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
assert info["name"] == "Weather Station SWS 12500"
|
||||||
assert info["manufacturer"] == "Schizza"
|
assert info["manufacturer"] == "Schizza"
|
||||||
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
|
assert info["model"] == "Weather Station SWS 12500"
|
||||||
assert info["identifiers"] == {(DOMAIN,)}
|
assert info["identifiers"] == {(DOMAIN,)}
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_binary_sensors_noop_when_runtime_data_missing():
|
|
||||||
"""add_new_binary_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
|
|
||||||
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
|
|
||||||
add_new_binary_sensors(None, entry, keys=["outside_battery"]) # must not raise
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from custom_components.sws12500.data import SWSRuntimeData
|
||||||
|
|
||||||
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, WSLINK
|
|
||||||
from custom_components.sws12500.data import (
|
|
||||||
SWSRuntimeData,
|
|
||||||
_station_model,
|
|
||||||
build_device_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_data_defaults():
|
def test_runtime_data_defaults():
|
||||||
|
|
@ -51,64 +44,3 @@ def test_runtime_data_collections_are_independent_per_instance():
|
||||||
assert b.sensor_descriptions == {}
|
assert b.sensor_descriptions == {}
|
||||||
assert b.added_binary_keys == set()
|
assert b.added_binary_keys == set()
|
||||||
assert b.last_seen == {}
|
assert b.last_seen == {}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# _station_model / build_device_info (single shared device)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_station_model_pws_when_no_flags():
|
|
||||||
"""No ecowitt / wslink flags -> the legacy PWS push station."""
|
|
||||||
entry = SimpleNamespace(options={})
|
|
||||||
assert _station_model(entry) == "PWS" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_station_model_wslink_when_flag_set():
|
|
||||||
"""The WSLINK flag (without ecowitt) yields the WSLink model."""
|
|
||||||
entry = SimpleNamespace(options={WSLINK: True})
|
|
||||||
assert _station_model(entry) == "WSLink" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_station_model_ecowitt_without_learned_model():
|
|
||||||
"""Ecowitt enabled but no model learned yet -> bare "Ecowitt".
|
|
||||||
|
|
||||||
Covers both the missing-runtime_data and the model-is-None paths.
|
|
||||||
"""
|
|
||||||
no_runtime = SimpleNamespace(options={ECOWITT_ENABLED: True})
|
|
||||||
assert _station_model(no_runtime) == "Ecowitt" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
runtime_no_model = SimpleNamespace(
|
|
||||||
options={ECOWITT_ENABLED: True},
|
|
||||||
runtime_data=SimpleNamespace(ecowitt_model=None),
|
|
||||||
)
|
|
||||||
assert _station_model(runtime_no_model) == "Ecowitt" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_station_model_ecowitt_with_learned_model():
|
|
||||||
"""Ecowitt enabled with a learned model -> "Ecowitt <model>"."""
|
|
||||||
entry = SimpleNamespace(
|
|
||||||
options={ECOWITT_ENABLED: True},
|
|
||||||
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
|
|
||||||
)
|
|
||||||
assert _station_model(entry) == "Ecowitt GW1000" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_station_model_ecowitt_takes_precedence_over_wslink():
|
|
||||||
"""When both flags are set the ecowitt model wins."""
|
|
||||||
entry = SimpleNamespace(
|
|
||||||
options={ECOWITT_ENABLED: True, WSLINK: True},
|
|
||||||
runtime_data=SimpleNamespace(ecowitt_model="WS3900"),
|
|
||||||
)
|
|
||||||
assert _station_model(entry) == "Ecowitt WS3900" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_device_info_shared_identity():
|
|
||||||
"""All entities share one device under the legacy ``{(DOMAIN,)}`` identifier."""
|
|
||||||
entry = SimpleNamespace(options={})
|
|
||||||
info = build_device_info(entry) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert info["identifiers"] == {(DOMAIN,)}
|
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
|
||||||
assert info["manufacturer"] == "Schizza"
|
|
||||||
assert info["model"] == "PWS"
|
|
||||||
|
|
|
||||||
|
|
@ -20,24 +20,9 @@ from aioecowitt import EcoWittSensor, EcoWittSensorTypes
|
||||||
from aioecowitt.station import EcoWittStation
|
from aioecowitt.station import EcoWittStation
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, REMAP_ECOWITT_COMPAT
|
from custom_components.sws12500.const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||||
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
|
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
|
||||||
|
|
||||||
# Default config stub for native entities: the integration shares a single device,
|
|
||||||
# so EcoWittNativeSensor only needs the entry to resolve the device model. A bare
|
|
||||||
# PWS config (no ecowitt/wslink flags) is enough for the non-device assertions.
|
|
||||||
_PWS_CONFIG = SimpleNamespace(options={})
|
|
||||||
# An ecowitt config that yields model "Ecowitt GW1000" for device-info assertions.
|
|
||||||
_ECOWITT_CONFIG = SimpleNamespace(
|
|
||||||
options={ECOWITT_ENABLED: True},
|
|
||||||
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _native(sensor: Any, config: Any = _PWS_CONFIG) -> EcoWittNativeSensor:
|
|
||||||
"""Build a native sensor with a default (PWS) config stub."""
|
|
||||||
return EcoWittNativeSensor(sensor, config)
|
|
||||||
|
|
||||||
# A realistic Ecowitt POST payload. `model` is required by aioecowitt's
|
# A realistic Ecowitt POST payload. `model` is required by aioecowitt's
|
||||||
# station extraction. Contains both internally mapped fields (tempf, humidity,
|
# station extraction. Contains both internally mapped fields (tempf, humidity,
|
||||||
# windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2).
|
# windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2).
|
||||||
|
|
@ -253,7 +238,7 @@ def test_native_sensor_init_with_mapped_stype() -> None:
|
||||||
)
|
)
|
||||||
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
|
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
|
||||||
|
|
||||||
entity = _native(sensor, _ECOWITT_CONFIG)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
|
||||||
assert entity._attr_unique_id == "ecowitt_pm25_ch1"
|
assert entity._attr_unique_id == "ecowitt_pm25_ch1"
|
||||||
assert entity._attr_name == "PM2.5 CH1"
|
assert entity._attr_name == "PM2.5 CH1"
|
||||||
|
|
@ -264,13 +249,12 @@ def test_native_sensor_init_with_mapped_stype() -> None:
|
||||||
assert entity._attr_native_unit_of_measurement == unit
|
assert entity._attr_native_unit_of_measurement == unit
|
||||||
assert entity._attr_state_class == state_class
|
assert entity._attr_state_class == state_class
|
||||||
|
|
||||||
# Native sensors join the single shared integration device; the running station
|
# Device info groups the entity under the station device.
|
||||||
# type is reflected in the model, not in a separate Ecowitt device.
|
|
||||||
info = entity._attr_device_info
|
info = entity._attr_device_info
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
assert info["name"] == "Ecowitt GW1000"
|
||||||
assert info["model"] == "Ecowitt GW1000"
|
assert info["model"] == "GW1000"
|
||||||
assert info["identifiers"] == {(DOMAIN,)}
|
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||||
assert info["manufacturer"] == "Schizza"
|
assert info["manufacturer"] == "Ecowitt impl. from Schizza for SWS12500"
|
||||||
|
|
||||||
|
|
||||||
def test_native_sensor_init_with_unmapped_stype() -> None:
|
def test_native_sensor_init_with_unmapped_stype() -> None:
|
||||||
|
|
@ -290,40 +274,37 @@ def test_native_sensor_init_with_unmapped_stype() -> None:
|
||||||
value="1000",
|
value="1000",
|
||||||
)
|
)
|
||||||
|
|
||||||
entity = _native(sensor, _ECOWITT_CONFIG)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
|
||||||
# No HA metadata set for unknown types.
|
# No HA metadata set for unknown types.
|
||||||
assert getattr(entity, "_attr_device_class", None) is None
|
assert getattr(entity, "_attr_device_class", None) is None
|
||||||
assert getattr(entity, "_attr_native_unit_of_measurement", None) is None
|
assert getattr(entity, "_attr_native_unit_of_measurement", None) is None
|
||||||
assert getattr(entity, "_attr_state_class", None) is None
|
assert getattr(entity, "_attr_state_class", None) is None
|
||||||
|
|
||||||
# Device info is still present and points at the shared device.
|
# Device info is still present.
|
||||||
info = entity._attr_device_info
|
info = entity._attr_device_info
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
assert info["name"] == "Ecowitt GW1000"
|
||||||
assert info["identifiers"] == {(DOMAIN,)}
|
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||||
|
|
||||||
|
|
||||||
def test_native_sensor_device_model_follows_config_not_station() -> None:
|
def test_native_sensor_init_without_station() -> None:
|
||||||
"""Device model comes from the entry config, independent of the sensor station.
|
"""A sensor with no station falls back to generic device info."""
|
||||||
|
|
||||||
A native sensor whose ``station`` is missing still lands on the shared device,
|
|
||||||
and the model reflects the configured station type (here a bare PWS config).
|
|
||||||
"""
|
|
||||||
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25)
|
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25)
|
||||||
|
# Force the no-station branch.
|
||||||
sensor.station = None # type: ignore[assignment]
|
sensor.station = None # type: ignore[assignment]
|
||||||
|
|
||||||
entity = _native(sensor) # default PWS config
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
|
||||||
info = entity._attr_device_info
|
info = entity._attr_device_info
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
assert info["name"] == "Ecowitt station"
|
||||||
assert info["model"] == "PWS"
|
assert info["model"] is None
|
||||||
assert info["identifiers"] == {(DOMAIN,)}
|
assert (DOMAIN, "ecowitt") in info["identifiers"]
|
||||||
|
|
||||||
|
|
||||||
def test_native_value_returns_value() -> None:
|
def test_native_value_returns_value() -> None:
|
||||||
"""native_value returns the underlying sensor value."""
|
"""native_value returns the underlying sensor value."""
|
||||||
sensor = _make_sensor(value=42.0)
|
sensor = _make_sensor(value=42.0)
|
||||||
entity = _native(sensor)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
assert entity.native_value == 42.0
|
assert entity.native_value == 42.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -331,7 +312,7 @@ def test_native_value_returns_value() -> None:
|
||||||
def test_native_value_none_or_empty(empty: Any) -> None:
|
def test_native_value_none_or_empty(empty: Any) -> None:
|
||||||
"""native_value maps None and "" to None."""
|
"""native_value maps None and "" to None."""
|
||||||
sensor = _make_sensor(value=empty)
|
sensor = _make_sensor(value=empty)
|
||||||
entity = _native(sensor)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
assert entity.native_value is None
|
assert entity.native_value is None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -339,7 +320,7 @@ def test_native_value_none_or_empty(empty: Any) -> None:
|
||||||
async def test_added_and_removed_callback_lifecycle() -> None:
|
async def test_added_and_removed_callback_lifecycle() -> None:
|
||||||
"""async_added/async_will_remove register and unregister the update cb."""
|
"""async_added/async_will_remove register and unregister the update cb."""
|
||||||
sensor = _make_sensor()
|
sensor = _make_sensor()
|
||||||
entity = _native(sensor)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
|
||||||
assert entity._handle_update not in sensor.update_cb
|
assert entity._handle_update not in sensor.update_cb
|
||||||
|
|
||||||
|
|
@ -354,7 +335,7 @@ async def test_added_and_removed_callback_lifecycle() -> None:
|
||||||
async def test_will_remove_when_callback_absent() -> None:
|
async def test_will_remove_when_callback_absent() -> None:
|
||||||
"""async_will_remove is safe when the callback was never registered."""
|
"""async_will_remove is safe when the callback was never registered."""
|
||||||
sensor = _make_sensor()
|
sensor = _make_sensor()
|
||||||
entity = _native(sensor)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
|
||||||
# Not added; removal must not raise and must not touch the list.
|
# Not added; removal must not raise and must not touch the list.
|
||||||
assert entity._handle_update not in sensor.update_cb
|
assert entity._handle_update not in sensor.update_cb
|
||||||
|
|
@ -365,85 +346,9 @@ async def test_will_remove_when_callback_absent() -> None:
|
||||||
def test_handle_update_writes_ha_state() -> None:
|
def test_handle_update_writes_ha_state() -> None:
|
||||||
"""_handle_update forwards to async_write_ha_state."""
|
"""_handle_update forwards to async_write_ha_state."""
|
||||||
sensor = _make_sensor()
|
sensor = _make_sensor()
|
||||||
entity = _native(sensor)
|
entity = EcoWittNativeSensor(sensor)
|
||||||
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
|
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
entity._handle_update()
|
entity._handle_update()
|
||||||
|
|
||||||
entity.async_write_ha_state.assert_called_once_with()
|
entity.async_write_ha_state.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_add_entities_flushes_pending_unmapped_sensors() -> None:
|
|
||||||
"""Unmapped sensors parsed before the callback was set are flushed on set_add_entities."""
|
|
||||||
bridge = _make_bridge()
|
|
||||||
|
|
||||||
# Payload arrives before the platform is ready (no callback): native sensors are
|
|
||||||
# parsed by aioecowitt but skipped by _on_new_sensor.
|
|
||||||
await bridge.process_payload(dict(_PAYLOAD))
|
|
||||||
assert bridge.unmapped_sensor # e.g. pm25_ch1 / co2 parsed but not yet created
|
|
||||||
|
|
||||||
created: list[Any] = []
|
|
||||||
bridge.set_add_entities(lambda entities: created.extend(entities))
|
|
||||||
|
|
||||||
# The previously-skipped unmapped sensors are created now.
|
|
||||||
assert created
|
|
||||||
assert all(isinstance(e, EcoWittNativeSensor) for e in created)
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_new_sensor_respects_cap() -> None:
|
|
||||||
"""Native entity creation is capped to bound entity-registry growth."""
|
|
||||||
from custom_components.sws12500.ecowitt import MAX_NATIVE_ECOWITT_SENSORS
|
|
||||||
|
|
||||||
bridge = _make_bridge()
|
|
||||||
cb = MagicMock()
|
|
||||||
bridge.set_add_entities(cb)
|
|
||||||
# Pretend we already created the maximum number of native sensors.
|
|
||||||
bridge._know_native_keys = {f"k{i}" for i in range(MAX_NATIVE_ECOWITT_SENSORS)}
|
|
||||||
|
|
||||||
bridge._on_new_sensor(_make_sensor(key="pm25_ch1"))
|
|
||||||
|
|
||||||
cb.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_new_sensor_skips_unit_twin_of_mapped() -> None:
|
|
||||||
"""The metric twin of a mapped (imperial) sensor is not created as a native entity."""
|
|
||||||
bridge = _make_bridge()
|
|
||||||
cb = MagicMock()
|
|
||||||
bridge.set_add_entities(cb)
|
|
||||||
|
|
||||||
# tempc is the °C twin of tempf, which IS mapped into the SWS pipeline.
|
|
||||||
bridge._on_new_sensor(
|
|
||||||
_make_sensor(key="tempc", name="Outdoor Temperature", stype=EcoWittSensorTypes.TEMPERATURE_C)
|
|
||||||
)
|
|
||||||
|
|
||||||
cb.assert_not_called()
|
|
||||||
assert "tempc" not in bridge._know_native_keys
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_new_sensor_skips_already_created_twin() -> None:
|
|
||||||
"""For an unmapped metric/imperial pair only the first-seen unit is created."""
|
|
||||||
bridge = _make_bridge()
|
|
||||||
created: list[Any] = []
|
|
||||||
bridge.set_add_entities(lambda ents: created.extend(ents))
|
|
||||||
|
|
||||||
bridge._on_new_sensor(_make_sensor(key="rainratein", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_INCHES))
|
|
||||||
bridge._on_new_sensor(_make_sensor(key="rainratemm", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_MM))
|
|
||||||
|
|
||||||
keys = [e._ecowitt_sensor.key for e in created]
|
|
||||||
assert keys == ["rainratein"] # the twin rainratemm is deduplicated
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_sensor_translation_key_for_curated() -> None:
|
|
||||||
ent = _native(
|
|
||||||
_make_sensor(key="baromabsin", name="Absolute Pressure", stype=EcoWittSensorTypes.PRESSURE_INHG)
|
|
||||||
)
|
|
||||||
assert ent._attr_translation_key == "ecowitt_absolute_pressure"
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_sensor_name_fallback_for_unknown() -> None:
|
|
||||||
ent = _native(
|
|
||||||
_make_sensor(key="air_ch1", name="Air Gap 1", stype=EcoWittSensorTypes.INTERNAL)
|
|
||||||
)
|
|
||||||
assert ent._attr_name == "Air Gap 1"
|
|
||||||
assert getattr(ent, "_attr_translation_key", None) is None
|
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,8 @@ from custom_components.sws12500 import health_coordinator as hc, health_sensor a
|
||||||
from custom_components.sws12500.const import (
|
from custom_components.sws12500.const import (
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ECOWITT_ENABLED,
|
|
||||||
ECOWITT_URL_PREFIX,
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
LEGACY_ENABLED,
|
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
WSLINK,
|
WSLINK,
|
||||||
|
|
@ -124,14 +122,9 @@ def _patch_network(monkeypatch, session: _FakeSession, ip: str = "1.2.3.4") -> N
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_configured_protocol() -> None:
|
def test_protocol_name() -> None:
|
||||||
# Legacy enabled (default) -> wu / wslink based on the WSLINK flag.
|
assert hc._protocol_name(True) == "wslink"
|
||||||
assert hc._configured_protocol(_make_entry()) == "wu"
|
assert hc._protocol_name(False) == "wu"
|
||||||
assert hc._configured_protocol(_make_entry({WSLINK: True})) == "wslink"
|
|
||||||
# Ecowitt-only (legacy off, ecowitt on) -> ecowitt.
|
|
||||||
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True})) == "ecowitt"
|
|
||||||
# Nothing configured -> wu fallback.
|
|
||||||
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: False})) == "wu"
|
|
||||||
|
|
||||||
|
|
||||||
def test_protocol_from_path_all_branches() -> None:
|
def test_protocol_from_path_all_branches() -> None:
|
||||||
|
|
@ -175,13 +168,6 @@ def test_default_health_data_wu_default() -> None:
|
||||||
assert data["integration_status"] == "online_wu"
|
assert data["integration_status"] == "online_wu"
|
||||||
|
|
||||||
|
|
||||||
def test_default_health_data_ecowitt() -> None:
|
|
||||||
data = hc._default_health_data(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}))
|
|
||||||
assert data["configured_protocol"] == "ecowitt"
|
|
||||||
assert data["active_protocol"] == "ecowitt"
|
|
||||||
assert data["integration_status"] == "online_ecowitt"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HealthCoordinator construction & persistence
|
# HealthCoordinator construction & persistence
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -276,20 +262,6 @@ def test_refresh_summary_online_idle(hass, entry) -> None:
|
||||||
assert data["active_protocol"] == "wu"
|
assert data["active_protocol"] == "wu"
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_summary_online_ecowitt(hass, entry) -> None:
|
|
||||||
# Ecowitt ingress is a valid protocol: active_protocol must track it (not fall back
|
|
||||||
# to the legacy "wu"), and it must not be flagged as a WU/WSLink mismatch.
|
|
||||||
coordinator = HealthCoordinator(hass, entry)
|
|
||||||
data = hc._default_health_data(entry)
|
|
||||||
data["configured_protocol"] = "wu"
|
|
||||||
data["last_ingress"] = {"protocol": "ecowitt", "accepted": True, "reason": "accepted"}
|
|
||||||
|
|
||||||
coordinator._refresh_summary(data)
|
|
||||||
|
|
||||||
assert data["integration_status"] == "online_ecowitt"
|
|
||||||
assert data["active_protocol"] == "ecowitt"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _async_update_data: offline and online paths
|
# _async_update_data: offline and online paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -675,11 +647,8 @@ def _description(key: str) -> hs.HealthSensorEntityDescription:
|
||||||
|
|
||||||
|
|
||||||
def _stub_coordinator(data: dict[str, Any]) -> Any:
|
def _stub_coordinator(data: dict[str, Any]) -> Any:
|
||||||
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.
|
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices."""
|
||||||
|
return SimpleNamespace(data=data)
|
||||||
device_info reads coordinator.config for the shared device model.
|
|
||||||
"""
|
|
||||||
return SimpleNamespace(data=data, config=SimpleNamespace(options={}))
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_native_value_without_value_fn() -> None:
|
def test_sensor_native_value_without_value_fn() -> None:
|
||||||
|
|
@ -694,39 +663,11 @@ def test_sensor_native_value_with_value_fn() -> None:
|
||||||
assert sensor.native_value == "online"
|
assert sensor.native_value == "online"
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_extra_state_attributes_strips_internal_fields() -> None:
|
def test_sensor_extra_state_attributes_for_integration_health() -> None:
|
||||||
data = {
|
data = {"integration_status": "online_wu", "addon": {"online": False}}
|
||||||
"integration_status": "online_wu",
|
|
||||||
"addon": {
|
|
||||||
"online": True,
|
|
||||||
"name": "wslink_proxy",
|
|
||||||
"home_assistant_source_ip": "1.2.3.4",
|
|
||||||
"health_url": "https://1.2.3.4:443/healthz",
|
|
||||||
"info_url": "https://1.2.3.4:443/status/internal",
|
|
||||||
"home_assistant_url": "http://ha:8123",
|
|
||||||
"raw_status": {"secret": "x"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
coordinator = _stub_coordinator(data)
|
coordinator = _stub_coordinator(data)
|
||||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
||||||
|
assert sensor.extra_state_attributes == data
|
||||||
attrs = sensor.extra_state_attributes
|
|
||||||
assert attrs is not None
|
|
||||||
# Non-sensitive fields pass through.
|
|
||||||
assert attrs["integration_status"] == "online_wu"
|
|
||||||
assert attrs["addon"]["online"] is True
|
|
||||||
assert attrs["addon"]["name"] == "wslink_proxy"
|
|
||||||
# Internal network details are stripped from the (any-user-readable) attributes.
|
|
||||||
for field in ("home_assistant_source_ip", "health_url", "info_url", "home_assistant_url", "raw_status"):
|
|
||||||
assert field not in attrs["addon"]
|
|
||||||
# The source snapshot is not mutated (deepcopy).
|
|
||||||
assert "raw_status" in data["addon"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_extra_state_attributes_none_data() -> None:
|
|
||||||
coordinator = _stub_coordinator(None)
|
|
||||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
|
||||||
assert sensor.extra_state_attributes is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_extra_state_attributes_for_other_keys() -> None:
|
def test_sensor_extra_state_attributes_for_other_keys() -> None:
|
||||||
|
|
@ -735,20 +676,13 @@ def test_sensor_extra_state_attributes_for_other_keys() -> None:
|
||||||
assert sensor.extra_state_attributes is None
|
assert sensor.extra_state_attributes is None
|
||||||
|
|
||||||
|
|
||||||
def test_public_health_snapshot_handles_missing_or_nondict_addon() -> None:
|
|
||||||
# addon missing -> returned as-is (copy).
|
|
||||||
assert hc.public_health_snapshot({"integration_status": "x"}) == {"integration_status": "x"}
|
|
||||||
# addon not a dict -> left untouched.
|
|
||||||
assert hc.public_health_snapshot({"addon": "nope"}) == {"addon": "nope"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_device_info() -> None:
|
def test_sensor_device_info() -> None:
|
||||||
coordinator = _stub_coordinator({})
|
coordinator = _stub_coordinator({})
|
||||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
||||||
info = sensor.device_info
|
info = sensor.device_info
|
||||||
assert info["name"] == "Weather Station SWS 12500"
|
assert info["name"] == "Weather Station SWS 12500"
|
||||||
assert info["manufacturer"] == "Schizza"
|
assert info["manufacturer"] == "Schizza"
|
||||||
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
|
assert info["model"] == "Weather Station SWS 12500"
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_unique_id_and_category() -> None:
|
def test_sensor_unique_id_and_category() -> None:
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,8 @@ async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms(
|
||||||
async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||||
hass_with_http, monkeypatch
|
hass_with_http, monkeypatch
|
||||||
):
|
):
|
||||||
"""Cover the fatal branch when `register_path` returns False -> ConfigEntryNotReady."""
|
"""Cover the fatal branch when `register_path` returns False -> PlatformNotReady."""
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
|
|
@ -223,7 +223,7 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
||||||
AsyncMock(return_value=True),
|
AsyncMock(return_value=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ConfigEntryNotReady):
|
with pytest.raises(PlatformNotReady):
|
||||||
await async_setup_entry(hass_with_http, entry)
|
await async_setup_entry(hass_with_http, entry)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,7 +393,6 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||||
)
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
|
||||||
# Missing security params -> unauthorized
|
# Missing security params -> unauthorized
|
||||||
|
|
@ -409,7 +408,6 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||||
# Missing API_ID in options -> IncorrectDataError
|
# Missing API_ID in options -> IncorrectDataError
|
||||||
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
|
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
|
||||||
entry2.add_to_hass(hass)
|
entry2.add_to_hass(hass)
|
||||||
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
|
|
||||||
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
||||||
with pytest.raises(IncorrectDataError):
|
with pytest.raises(IncorrectDataError):
|
||||||
await coordinator2.received_data(
|
await coordinator2.received_data(
|
||||||
|
|
@ -417,20 +415,6 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
||||||
) # type: ignore[arg-type]
|
) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_received_data_returns_503_when_runtime_data_missing(hass):
|
|
||||||
"""A payload arriving while the entry is unloaded is rejected with 503, not 500."""
|
|
||||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", WSLINK: False})
|
|
||||||
entry.add_to_hass(hass)
|
|
||||||
# No runtime_data assigned -> simulates the unloaded/reloading window.
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
|
||||||
|
|
||||||
resp = await coordinator.received_data(
|
|
||||||
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
|
|
||||||
) # type: ignore[arg-type]
|
|
||||||
assert resp.status == 503
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_path_idempotent_when_routes_exist(hass_with_http):
|
async def test_register_path_idempotent_when_routes_exist(hass_with_http):
|
||||||
"""A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""
|
"""A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
@ -21,8 +21,11 @@ from custom_components.sws12500.const import (
|
||||||
POCASI_INVALID_KEY,
|
POCASI_INVALID_KEY,
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
from custom_components.sws12500.pocasti_cz import PocasiApiKeyError, PocasiPush, PocasiSuccess
|
from custom_components.sws12500.pocasti_cz import (
|
||||||
from homeassistant.util import dt as dt_util
|
PocasiApiKeyError,
|
||||||
|
PocasiPush,
|
||||||
|
PocasiSuccess,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -120,7 +123,7 @@ async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
|
|
||||||
# Ensure "next_update > now" so it returns early before doing HTTP.
|
# Ensure "next_update > now" so it returns early before doing HTTP.
|
||||||
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
|
pp.next_update = datetime.now() + timedelta(seconds=999)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse("OK"))
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -143,7 +146,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
|
|
||||||
# Force send now.
|
# Force send now.
|
||||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse("OK"))
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -173,7 +176,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
|
||||||
async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
|
async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse("OK"))
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -193,7 +196,7 @@ async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
|
||||||
async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, hass):
|
async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, hass):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse("OK"))
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -229,7 +232,7 @@ async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, h
|
||||||
async def test_push_data_to_server_success_logs_when_logger_enabled(monkeypatch, hass):
|
async def test_push_data_to_server_success_logs_when_logger_enabled(monkeypatch, hass):
|
||||||
entry = _make_entry(logger=True)
|
entry = _make_entry(logger=True)
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse("OK"))
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -273,7 +276,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
|
||||||
|
|
||||||
# Force request attempts and exceed invalid count threshold.
|
# Force request attempts and exceed invalid count threshold.
|
||||||
for _i in range(4):
|
for _i in range(4):
|
||||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
await pp.push_data_to_server({"x": 1}, "WU")
|
await pp.push_data_to_server({"x": 1}, "WU")
|
||||||
|
|
||||||
assert pp.invalid_response_count == 4
|
assert pp.invalid_response_count == 4
|
||||||
|
|
|
||||||
|
|
@ -509,25 +509,3 @@ async def test_register_path_switching_logic_is_exercised_via_routes(monkeypatch
|
||||||
"""Sanity: constants exist and are distinct (helps guard tests relying on them)."""
|
"""Sanity: constants exist and are distinct (helps guard tests relying on them)."""
|
||||||
assert DEFAULT_URL != WSLINK_URL
|
assert DEFAULT_URL != WSLINK_URL
|
||||||
assert DOMAIN == "sws12500"
|
assert DOMAIN == "sws12500"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_received_data_empty_configured_credentials_raises_incorrect_data(hass, monkeypatch):
|
|
||||||
"""Empty configured API ID/KEY is treated as missing config (defense-in-depth)."""
|
|
||||||
entry = _make_entry(wslink=False, api_id="", api_key="key")
|
|
||||||
entry.runtime_data.health_coordinator = SimpleNamespace(update_ingress_result=MagicMock())
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
|
||||||
with pytest.raises(IncorrectDataError):
|
|
||||||
await coordinator.received_data(_RequestStub(query={"ID": "id", "PASSWORD": "key"})) # type: ignore[arg-type]
|
|
||||||
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_received_data_empty_incoming_credentials_raises_unauthorized(hass, monkeypatch):
|
|
||||||
"""Present-but-empty incoming credentials are rejected before the digest compare."""
|
|
||||||
entry = _make_entry(wslink=False, api_id="id", api_key="key")
|
|
||||||
entry.runtime_data.health_coordinator = SimpleNamespace(update_ingress_result=MagicMock())
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
|
||||||
with pytest.raises(HTTPUnauthorized):
|
|
||||||
await coordinator.received_data(_RequestStub(query={"ID": "", "PASSWORD": ""})) # type: ignore[arg-type]
|
|
||||||
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()
|
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
request = _EcowittRequestStub(
|
request = _EcowittRequestStub(
|
||||||
match_info={"webhook_id": "hook"}, post_data={"tempf": "68", "model": "GW2000A"}
|
match_info={"webhook_id": "hook"}, post_data={"tempf": "68"}
|
||||||
)
|
)
|
||||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
@ -248,9 +248,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
||||||
|
|
||||||
coordinator.ecowitt_bridge.process_payload.assert_awaited_once()
|
coordinator.ecowitt_bridge.process_payload.assert_awaited_once()
|
||||||
|
|
||||||
# Station model is learned from the payload and stored for the shared device model.
|
|
||||||
assert entry.runtime_data.ecowitt_model == "GW2000A"
|
|
||||||
|
|
||||||
# Autodiscovery side-effects.
|
# Autodiscovery side-effects.
|
||||||
update_options.assert_awaited_once()
|
update_options.assert_awaited_once()
|
||||||
args, _kwargs = update_options.await_args
|
args, _kwargs = update_options.await_args
|
||||||
|
|
@ -579,19 +576,3 @@ async def test_received_data_success_with_health_autodiscovery_and_binary(hass,
|
||||||
assert kw["accepted"] is True
|
assert kw["accepted"] is True
|
||||||
assert kw["reason"] == "accepted"
|
assert kw["reason"] == "accepted"
|
||||||
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
|
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_received_ecowitt_returns_503_when_runtime_data_missing(hass):
|
|
||||||
"""An Ecowitt payload during the unload window is rejected with 503, not 500."""
|
|
||||||
entry = SimpleNamespace(
|
|
||||||
entry_id="x",
|
|
||||||
options={ECOWITT_ENABLED: True},
|
|
||||||
async_on_unload=lambda *_a, **_k: None,
|
|
||||||
) # no runtime_data attribute -> guard triggers
|
|
||||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
|
||||||
|
|
||||||
resp = await coordinator.received_ecowitt_data(
|
|
||||||
_EcowittRequestStub(match_info={"webhook_id": "x"})
|
|
||||||
) # type: ignore[arg-type]
|
|
||||||
assert resp.status == 503
|
|
||||||
|
|
|
||||||
|
|
@ -212,9 +212,3 @@ def test_add_new_sensors_adds_known_keys(hass):
|
||||||
assert len(entities_arg) == 1
|
assert len(entities_arg) == 1
|
||||||
assert isinstance(entities_arg[0], WeatherSensor)
|
assert isinstance(entities_arg[0], WeatherSensor)
|
||||||
assert entities_arg[0].entity_description.key == known_desc.key
|
assert entities_arg[0].entity_description.key == known_desc.key
|
||||||
|
|
||||||
|
|
||||||
def test_add_new_sensors_noop_when_runtime_data_missing():
|
|
||||||
"""add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
|
|
||||||
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
|
|
||||||
add_new_sensors(None, entry, keys=["anything"]) # must not raise
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_sensor_types_wslink_structure():
|
def test_sensor_types_wslink_structure():
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,10 @@
|
||||||
"""Coverage for to_int / to_float edge cases and anonymize() masking."""
|
"""Coverage for to_int / to_float edge cases."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.sws12500.utils import anonymize, to_float, to_int
|
from custom_components.sws12500.utils import to_float, to_int
|
||||||
|
|
||||||
|
|
||||||
def test_anonymize_masks_all_known_secrets() -> None:
|
|
||||||
raw = {
|
|
||||||
"ID": "id",
|
|
||||||
"PASSWORD": "pw",
|
|
||||||
"wsid": "ws",
|
|
||||||
"wspw": "wp",
|
|
||||||
"passkey": "ecowitt-secret",
|
|
||||||
"PASSKEY": "ecowitt-secret",
|
|
||||||
"tempf": "68",
|
|
||||||
}
|
|
||||||
out = anonymize(raw)
|
|
||||||
for key in ("ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"):
|
|
||||||
assert out[key] == "***"
|
|
||||||
# Non-secret values pass through unchanged.
|
|
||||||
assert out["tempf"] == "68"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ def test_device_info_contains_expected_identifiers_and_domain():
|
||||||
# DeviceInfo is mapping-like; access defensively.
|
# DeviceInfo is mapping-like; access defensively.
|
||||||
assert info.get("name") == "Weather Station SWS 12500"
|
assert info.get("name") == "Weather Station SWS 12500"
|
||||||
assert info.get("manufacturer") == "Schizza"
|
assert info.get("manufacturer") == "Schizza"
|
||||||
assert info.get("model") == "PWS" # no ecowitt/wslink in stub options -> PWS
|
assert info.get("model") == "Weather Station SWS 12500"
|
||||||
|
|
||||||
identifiers = info.get("identifiers")
|
identifiers = info.get("identifiers")
|
||||||
assert isinstance(identifiers, set)
|
assert isinstance(identifiers, set)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
@ -12,7 +12,6 @@ import pytest
|
||||||
|
|
||||||
from custom_components.sws12500.const import WINDY_ENABLED, WINDY_LOGGER_ENABLED, WINDY_STATION_ID, WINDY_STATION_PW
|
from custom_components.sws12500.const import WINDY_ENABLED, WINDY_LOGGER_ENABLED, WINDY_STATION_ID, WINDY_STATION_PW
|
||||||
from custom_components.sws12500.windy_func import WindyDuplicatePayloadDetected, WindyPush, WindyRateLimitExceeded
|
from custom_components.sws12500.windy_func import WindyDuplicatePayloadDetected, WindyPush, WindyRateLimitExceeded
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -65,7 +64,7 @@ def test_verify_response_rate_limit_raises(hass):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
|
async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
|
||||||
wp = WindyPush(hass, _make_entry())
|
wp = WindyPush(hass, _make_entry())
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||||
|
|
@ -81,14 +80,14 @@ async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
|
async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
|
||||||
wp = WindyPush(hass, _make_entry())
|
wp = WindyPush(hass, _make_entry())
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||||
lambda _h: _FakeSession(_FakeResponse(status=429)),
|
lambda _h: _FakeSession(_FakeResponse(status=429)),
|
||||||
)
|
)
|
||||||
|
|
||||||
before = dt_util.utcnow()
|
before = datetime.now()
|
||||||
ok = await wp.push_data_to_windy({"a": "b"})
|
ok = await wp.push_data_to_windy({"a": "b"})
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert wp.last_status == "rate_limited_remote"
|
assert wp.last_status == "rate_limited_remote"
|
||||||
|
|
@ -100,7 +99,7 @@ async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
|
||||||
async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
|
async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
|
||||||
wp = WindyPush(hass, _make_entry())
|
wp = WindyPush(hass, _make_entry())
|
||||||
wp.invalid_response_count = 2 # next duplicate makes it 3 -> finally disables
|
wp.invalid_response_count = 2 # next duplicate makes it 3 -> finally disables
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=True)
|
update_options = AsyncMock(return_value=True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
@ -12,14 +12,21 @@ import pytest
|
||||||
from custom_components.sws12500.const import (
|
from custom_components.sws12500.const import (
|
||||||
PURGE_DATA,
|
PURGE_DATA,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
|
WINDY_INVALID_KEY,
|
||||||
WINDY_LOGGER_ENABLED,
|
WINDY_LOGGER_ENABLED,
|
||||||
|
WINDY_NOT_INSERTED,
|
||||||
WINDY_STATION_ID,
|
WINDY_STATION_ID,
|
||||||
WINDY_STATION_PW,
|
WINDY_STATION_PW,
|
||||||
|
WINDY_SUCCESS,
|
||||||
WINDY_UNEXPECTED,
|
WINDY_UNEXPECTED,
|
||||||
WINDY_URL,
|
WINDY_URL,
|
||||||
)
|
)
|
||||||
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
|
from custom_components.sws12500.windy_func import (
|
||||||
from homeassistant.util import dt as dt_util
|
WindyNotInserted,
|
||||||
|
WindyPasswordMissing,
|
||||||
|
WindyPush,
|
||||||
|
WindySuccess,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -144,7 +151,7 @@ async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
|
|
||||||
# Ensure "next_update > now" is true
|
# Ensure "next_update > now" is true
|
||||||
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
|
wp.next_update = datetime.now() + timedelta(minutes=10)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||||
|
|
@ -160,7 +167,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
|
|
||||||
# Force it to send now
|
# Force it to send now
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -191,7 +198,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
|
||||||
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
|
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -212,7 +219,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
entry.options.pop(WINDY_STATION_ID)
|
entry.options.pop(WINDY_STATION_ID)
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -239,7 +246,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
entry.options.pop(WINDY_STATION_PW)
|
entry.options.pop(WINDY_STATION_PW)
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -265,7 +272,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
|
||||||
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
|
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
# Response triggers WindyPasswordMissing (401)
|
# Response triggers WindyPasswordMissing (401)
|
||||||
session = _FakeSession(
|
session = _FakeSession(
|
||||||
|
|
@ -296,7 +303,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
|
||||||
):
|
):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(
|
session = _FakeSession(
|
||||||
response=_FakeResponse(status=401, text_value="Unauthorized")
|
response=_FakeResponse(status=401, text_value="Unauthorized")
|
||||||
|
|
@ -328,7 +335,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
|
||||||
async def test_push_data_to_windy_notice_logs_not_inserted(monkeypatch, hass):
|
async def test_push_data_to_windy_notice_logs_not_inserted(monkeypatch, hass):
|
||||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
|
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -351,7 +358,7 @@ async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
|
||||||
):
|
):
|
||||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -383,7 +390,7 @@ async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_l
|
||||||
"""
|
"""
|
||||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
|
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
|
||||||
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
|
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
|
||||||
|
|
@ -406,7 +413,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
|
||||||
):
|
):
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=True)
|
update_options = AsyncMock(return_value=True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -429,7 +436,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
|
||||||
|
|
||||||
# First 3 calls should not disable; 4th should
|
# First 3 calls should not disable; 4th should
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
ok = await wp.push_data_to_windy({"a": "b"})
|
ok = await wp.push_data_to_windy({"a": "b"})
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
|
|
@ -452,7 +459,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
wp = WindyPush(hass, entry)
|
wp = WindyPush(hass, entry)
|
||||||
wp.invalid_response_count = 3 # next error will push it over the threshold
|
wp.invalid_response_count = 3 # next error will push it over the threshold
|
||||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=False)
|
update_options = AsyncMock(return_value=False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue