Compare commits
9 Commits
bf6a02c5b3
...
80c944fc5a
| Author | SHA1 | Date |
|---|---|---|
|
|
80c944fc5a | |
|
|
3fdebc6f20 | |
|
|
72c383ffba | |
|
|
514ab823d6 | |
|
|
6a72ce0f51 | |
|
|
df8e503d2c | |
|
|
418d0c7546 | |
|
|
038d2459b9 | |
|
|
1fe4d6da45 |
|
|
@ -36,7 +36,7 @@ from py_typecheck import checked, checked_or
|
|||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, PlatformNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import (
|
||||
|
|
@ -46,7 +46,9 @@ from .const import (
|
|||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
LEGACY_ENABLED,
|
||||
POCASI_CZ_ENABLED,
|
||||
SENSORS_TO_LOAD,
|
||||
WINDY_ENABLED,
|
||||
WSLINK,
|
||||
WSLINK_URL,
|
||||
)
|
||||
|
|
@ -176,7 +178,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
|
|||
else:
|
||||
if not register_path(hass, coordinator, coordinator_health, entry):
|
||||
_LOGGER.error("Fatal: path not registered!")
|
||||
raise PlatformNotReady
|
||||
raise ConfigEntryNotReady("Webhook routes could not be registered")
|
||||
|
||||
routes = hass.data[DOMAIN].get("routes")
|
||||
if routes is not None:
|
||||
|
|
@ -202,10 +204,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
|
|||
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
||||
"""Handle config entry option updates.
|
||||
|
||||
We skip reloading when only `SENSORS_TO_LOAD` changes.
|
||||
We skip reloading when only live-read options change:
|
||||
- `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:
|
||||
- Auto-discovery updates `SENSORS_TO_LOAD` as new payload fields appear.
|
||||
- Reloading a push-based integration temporarily unloads platforms and removes
|
||||
coordinator listeners, which can make the UI appear "stuck" until restart.
|
||||
"""
|
||||
|
|
@ -219,8 +224,8 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
|||
|
||||
runtime.last_options = new_options
|
||||
|
||||
if changed_keys == {SENSORS_TO_LOAD}:
|
||||
_LOGGER.debug("Options updated (%s); skipping reload.", SENSORS_TO_LOAD)
|
||||
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
|
||||
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
|
||||
return
|
||||
|
||||
update_legacy_battery_issue(hass, entry)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ from typing import Any
|
|||
from py_typecheck import checked_or
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import build_device_info
|
||||
|
||||
|
||||
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
|
@ -62,12 +61,5 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride
|
|||
|
||||
@cached_property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
name="Weather Station SWS 12500",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
|
||||
manufacturer="Schizza",
|
||||
model="Weather Station SWS 12500",
|
||||
)
|
||||
"""Device info (single shared device for the whole integration)."""
|
||||
return build_device_info(self.coordinator.config)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ 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
|
||||
|
||||
runtime = entry.runtime_data
|
||||
runtime = getattr(entry, "runtime_data", None)
|
||||
if runtime is None:
|
||||
return
|
||||
add_entities = runtime.add_binary_entities
|
||||
if add_entities is None:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from yarl import URL
|
|||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .const import (
|
||||
|
|
@ -37,6 +38,9 @@ from .const import (
|
|||
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):
|
||||
"""We can not connect. - not used in push mechanism."""
|
||||
|
|
@ -79,7 +83,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
|
||||
self.user_data_schema = {
|
||||
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
|
||||
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): str,
|
||||
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): _PASSWORD_SELECTOR,
|
||||
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(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
|
||||
|
|
@ -105,7 +109,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
vol.Optional(
|
||||
WINDY_STATION_PW,
|
||||
default=self.windy_data.get(WINDY_STATION_PW, ""),
|
||||
): str,
|
||||
): _PASSWORD_SELECTOR,
|
||||
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
|
||||
vol.Optional(
|
||||
WINDY_LOGGER_ENABLED,
|
||||
|
|
@ -123,7 +127,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
|
||||
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_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): str,
|
||||
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): _PASSWORD_SELECTOR,
|
||||
vol.Required(
|
||||
POCASI_CZ_SEND_INTERVAL,
|
||||
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
|
||||
|
|
@ -330,7 +334,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
|
||||
pws_schema = {
|
||||
vol.Required(API_ID): str,
|
||||
vol.Required(API_KEY): str,
|
||||
vol.Required(API_KEY): _PASSWORD_SELECTOR,
|
||||
vol.Optional(WSLINK): bool,
|
||||
vol.Optional(DEV_DBG): bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,68 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
except AttributeError:
|
||||
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:
|
||||
"""Handle incoming Ecowitt webhook payload.
|
||||
|
||||
|
|
@ -100,6 +162,10 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
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()
|
||||
|
||||
if not checked_or(self.config.options.get(ECOWITT_ENABLED), bool, False):
|
||||
|
|
@ -132,6 +198,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
post_data = await webdata.post()
|
||||
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)
|
||||
|
||||
if mapped_data:
|
||||
|
|
@ -184,6 +255,12 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
- 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: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
||||
|
||||
|
|
@ -193,72 +270,7 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
|
||||
health = self._health_coordinator()
|
||||
|
||||
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
|
||||
self._validate_credentials(data, webdata, wslink=_wslink, health=health)
|
||||
|
||||
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@ from dataclasses import dataclass, field
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from py_typecheck import checked_or
|
||||
|
||||
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.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, ECOWITT_ENABLED, WSLINK
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
|
||||
|
||||
|
|
@ -51,6 +57,38 @@ class SWSRuntimeData:
|
|||
started_at: datetime = field(default_factory=dt_util.utcnow)
|
||||
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 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,6 +31,13 @@ TO_REDACT = {
|
|||
"PASSWORD",
|
||||
"wsid",
|
||||
"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,19 +13,18 @@ from __future__ import annotations
|
|||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
||||
from aioecowitt.sensor import SENSOR_MAP
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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.typing import StateType
|
||||
|
||||
from .const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||
from .const import REMAP_ECOWITT_COMPAT
|
||||
from .data import SWSConfigEntry, build_device_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -33,6 +32,64 @@ _LOGGER = logging.getLogger(__name__)
|
|||
# we need to know which key is internally covered.
|
||||
_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
|
||||
# We cover most common types, additional will be covered later.
|
||||
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
|
||||
|
|
@ -111,6 +168,16 @@ STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None
|
|||
"mm",
|
||||
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: (
|
||||
None,
|
||||
"strikes",
|
||||
|
|
@ -147,7 +214,7 @@ class EcowittBridge:
|
|||
and we are just using parsing/discovery logic.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
|
||||
"""Initialize bridge."""
|
||||
|
||||
self.hass = hass
|
||||
|
|
@ -166,10 +233,18 @@ class EcowittBridge:
|
|||
self._add_entities_cb: AddEntitiesCallback | None = 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
|
||||
|
||||
for sensor in self.unmapped_sensor.values():
|
||||
self._on_new_sensor(sensor)
|
||||
|
||||
async def process_payload(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Process raw Ecowitt POST payload.
|
||||
|
||||
|
|
@ -210,12 +285,28 @@ class EcowittBridge:
|
|||
if sensor.key in self._know_native_keys:
|
||||
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:
|
||||
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
||||
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)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = EcoWittNativeSensor(sensor, self.config)
|
||||
self._add_entities_cb([entity])
|
||||
|
||||
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
|
||||
|
|
@ -243,13 +334,18 @@ class EcoWittNativeSensor(SensorEntity):
|
|||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, sensor: EcoWittSensor) -> None:
|
||||
def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
|
||||
"""Initialize native EcoWittSensor."""
|
||||
|
||||
self._ecowitt_sensor = sensor
|
||||
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
||||
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||
self._attr_name = sensor.name # default name, can be overridden by translation_key if we had one
|
||||
|
||||
# Use a curated translation_key for common native sensors; otherwise fall back
|
||||
# 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.
|
||||
# Unknown types still get a usable entity (raw value, no device class/unit).
|
||||
|
|
@ -260,17 +356,9 @@ class EcoWittNativeSensor(SensorEntity):
|
|||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_state_class = state_class
|
||||
|
||||
# Always attach device info so the entity is grouped under the Ecowitt
|
||||
# station device even when its sensor type has no HA mapping.
|
||||
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,
|
||||
)
|
||||
# Share the single integration device (see data.build_device_info); the station
|
||||
# type is reflected in the device model rather than a separate Ecowitt device.
|
||||
self._attr_device_info = build_device_info(config)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ from homeassistant.util import dt as dt_util
|
|||
from .const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
LEGACY_ENABLED,
|
||||
POCASI_CZ_ENABLED,
|
||||
WINDY_ENABLED,
|
||||
WSLINK,
|
||||
|
|
@ -53,9 +55,23 @@ from .windy_func import WindyPush
|
|||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _protocol_name(wslink_enabled: bool) -> str:
|
||||
"""Return the configured protocol name."""
|
||||
return "wslink" if wslink_enabled else "wu"
|
||||
# Protocols that represent a real, accepted ingress (not health / unknown).
|
||||
_REAL_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink", "ecowitt"})
|
||||
_LEGACY_PROTOCOLS: frozenset[str] = frozenset({"wu", "wslink"})
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -83,6 +99,28 @@ def _sanitize_path(path: str) -> str:
|
|||
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]:
|
||||
"""Build the default forwarding status payload."""
|
||||
return {
|
||||
|
|
@ -95,7 +133,7 @@ def _empty_forwarding_state(enabled: bool) -> dict[str, Any]:
|
|||
|
||||
def _default_health_data(config: SWSConfigEntry) -> dict[str, Any]:
|
||||
"""Build the default health/debug payload for this config entry."""
|
||||
configured_protocol = _protocol_name(checked_or(config.options.get(WSLINK), bool, False))
|
||||
configured_protocol = _configured_protocol(config)
|
||||
return {
|
||||
"integration_status": f"online_{configured_protocol}",
|
||||
"configured_protocol": configured_protocol,
|
||||
|
|
@ -189,22 +227,33 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
accepted = bool(ingress.get("accepted"))
|
||||
reason = ingress.get("reason")
|
||||
|
||||
if (reason in {"route_disabled", "route_not_registered", "unauthorized"}) or (
|
||||
last_protocol in {"wu", "wslink"} and last_protocol != configured_protocol
|
||||
):
|
||||
# A WU vs WSLink mismatch means the station is misconfigured for the legacy
|
||||
# endpoint. Ecowitt coexists with the legacy endpoint, so it never counts as a
|
||||
# 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"
|
||||
elif accepted and last_protocol in {"wu", "wslink"}:
|
||||
elif accepted and last_protocol in _REAL_PROTOCOLS:
|
||||
integration_status = f"online_{last_protocol}"
|
||||
else:
|
||||
integration_status = "online_idle"
|
||||
|
||||
data["integration_status"] = integration_status
|
||||
data["active_protocol"] = (
|
||||
last_protocol if accepted and last_protocol in {"wu", "wslink"} else configured_protocol
|
||||
last_protocol if accepted and last_protocol in _REAL_PROTOCOLS else configured_protocol
|
||||
)
|
||||
|
||||
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)
|
||||
url = get_url(self.hass)
|
||||
ip = await async_get_source_ip(self.hass)
|
||||
|
|
@ -255,7 +304,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
|||
def update_routing(self, routes: Routes | None) -> None:
|
||||
"""Store the currently enabled routes for diagnostics."""
|
||||
data = deepcopy(self.data)
|
||||
data["configured_protocol"] = _protocol_name(checked_or(self.config.options.get(WSLINK), bool, False))
|
||||
data["configured_protocol"] = _configured_protocol(self.config)
|
||||
if routes is not None:
|
||||
data["routes"] = {
|
||||
"wu_enabled": routes.path_enabled(DEFAULT_URL),
|
||||
|
|
|
|||
|
|
@ -12,15 +12,13 @@ from py_typecheck import checked, checked_or
|
|||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import SWSConfigEntry
|
||||
from .health_coordinator import HealthCoordinator
|
||||
from .data import SWSConfigEntry, build_device_info
|
||||
from .health_coordinator import HealthCoordinator, public_health_snapshot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .health_coordinator import HealthCoordinator
|
||||
|
|
@ -248,20 +246,22 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
|
|||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
"""Expose the full health JSON on the main health sensor for debugging."""
|
||||
"""Expose the 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":
|
||||
return None
|
||||
|
||||
return checked_or(self.coordinator.data, dict[str, Any], None)
|
||||
data = checked_or(self.coordinator.data, dict[str, Any], None)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
return public_health_snapshot(data)
|
||||
|
||||
@cached_property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
name="Weather Station SWS 12500",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
|
||||
manufacturer="Schizza",
|
||||
model="Weather Station SWS 12500",
|
||||
)
|
||||
"""Device info (single shared device for the whole integration)."""
|
||||
return build_device_info(self.coordinator.config)
|
||||
|
|
|
|||
|
|
@ -9,14 +9,16 @@
|
|||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/schizza/SWS-12500-custom-component",
|
||||
"homekit": {},
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
||||
"loggers": [
|
||||
"aioecowitt"
|
||||
],
|
||||
"requirements": [
|
||||
"typecheck-runtime==0.2.0",
|
||||
"aioecowitt==2025.9.2"
|
||||
],
|
||||
"ssdp": [],
|
||||
"version": "2.0.0-pre1",
|
||||
"zeroconf": []
|
||||
"single_config_entry": true,
|
||||
"version": "2.0.0-pre1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ from py_typecheck.core import checked
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
DEFAULT_URL,
|
||||
|
|
@ -56,8 +57,8 @@ class PocasiPush:
|
|||
self.last_attempt_at: str | None = None
|
||||
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
|
||||
|
||||
self.last_update = datetime.now()
|
||||
self.next_update = datetime.now() + timedelta(seconds=self._interval)
|
||||
self.last_update = dt_util.utcnow()
|
||||
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
|
||||
|
||||
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
|
||||
self.invalid_response_count = 0
|
||||
|
|
@ -81,7 +82,7 @@ class PocasiPush:
|
|||
|
||||
_data = data.copy()
|
||||
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
|
||||
self.last_attempt_at = datetime.now().isoformat()
|
||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
||||
self.last_error = None
|
||||
|
||||
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
||||
|
|
@ -103,7 +104,7 @@ class PocasiPush:
|
|||
str(self.next_update),
|
||||
)
|
||||
|
||||
if self.next_update > datetime.now():
|
||||
if self.next_update > dt_util.utcnow():
|
||||
self.last_status = "rate_limited_local"
|
||||
_LOGGER.debug(
|
||||
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s",
|
||||
|
|
@ -112,6 +113,9 @@ class PocasiPush:
|
|||
)
|
||||
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 = ""
|
||||
if mode == "WSLINK":
|
||||
_data["wsid"] = _api_id
|
||||
|
|
@ -153,7 +157,9 @@ class PocasiPush:
|
|||
|
||||
except ClientError as ex:
|
||||
self.last_status = "client_error"
|
||||
self.last_error = str(ex)
|
||||
# Store only the exception class - last_error is surfaced via entity
|
||||
# attributes; str(ex) could embed the request URL.
|
||||
self.last_error = type(ex).__name__
|
||||
_LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex))
|
||||
self.invalid_response_count += 1
|
||||
if self.invalid_response_count >= 3:
|
||||
|
|
@ -161,8 +167,8 @@ class PocasiPush:
|
|||
self.enabled = False
|
||||
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
|
||||
|
||||
self.last_update = datetime.now()
|
||||
self.next_update = datetime.now() + timedelta(seconds=self._interval)
|
||||
self.last_update = dt_util.utcnow()
|
||||
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
|
||||
|
||||
if self.log:
|
||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ from py_typecheck import checked_or
|
|||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo, generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
|
@ -33,7 +32,6 @@ from . import health_sensor
|
|||
from .const import (
|
||||
CHILL_INDEX,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
HEAT_INDEX,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
|
|
@ -43,7 +41,7 @@ from .const import (
|
|||
WIND_SPEED,
|
||||
WSLINK,
|
||||
)
|
||||
from .data import SWSConfigEntry
|
||||
from .data import SWSConfigEntry, build_device_info
|
||||
from .sensors_common import WeatherSensorEntityDescription
|
||||
from .sensors_weather import SENSOR_TYPES_WEATHER_API
|
||||
from .sensors_wslink import SENSOR_TYPES_WSLINK
|
||||
|
|
@ -132,7 +130,9 @@ 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
|
||||
|
||||
runtime = config_entry.runtime_data
|
||||
runtime = getattr(config_entry, "runtime_data", None)
|
||||
if runtime is None:
|
||||
return
|
||||
add_entities = runtime.add_sensor_entities
|
||||
if add_entities is None:
|
||||
return
|
||||
|
|
@ -223,12 +223,5 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
|
||||
@cached_property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info."""
|
||||
return DeviceInfo(
|
||||
connections=set(),
|
||||
name="Weather Station SWS 12500",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
|
||||
manufacturer="Schizza",
|
||||
model="Weather Station SWS 12500",
|
||||
)
|
||||
"""Device info (single shared device for the whole integration)."""
|
||||
return build_device_info(self.coordinator.config)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@
|
|||
"valid_credentials_key": "Provide valid API KEY.",
|
||||
"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_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": {
|
||||
"init": {
|
||||
|
|
@ -58,7 +62,10 @@
|
|||
"description": "Choose what do you want to configure. If basic access or resending data for Windy site",
|
||||
"menu_options": {
|
||||
"basic": "Basic - configure credentials for Weather Station",
|
||||
"windy": "Windy configuration"
|
||||
"wslink_port_setup": "WSLink add-on port",
|
||||
"ecowitt": "Ecowitt configuration",
|
||||
"windy": "Windy configuration",
|
||||
"pocasi": "Pocasi Meteo CZ configuration"
|
||||
}
|
||||
},
|
||||
"basic": {
|
||||
|
|
@ -67,14 +74,16 @@
|
|||
"data": {
|
||||
"API_ID": "API ID / Station ID",
|
||||
"API_KEY": "API KEY / Password",
|
||||
"WSLINK": "WSLink API",
|
||||
"wslink": "WSLink API",
|
||||
"legacy_enabled": "Enable PWS/WSLink endpoint",
|
||||
"dev_debug_checkbox": "Developer log"
|
||||
},
|
||||
"data_description": {
|
||||
"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_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": {
|
||||
|
|
@ -98,28 +107,38 @@
|
|||
"data": {
|
||||
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
|
||||
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
|
||||
"POCASI_CZ_SEND_INTERVAL": "Resend interval in seconds",
|
||||
"pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo",
|
||||
"POCASI_SEND_INTERVAL": "Resend interval in seconds",
|
||||
"pocasi_enabled_chcekbox": "Enable resending data to Pocasi Meteo",
|
||||
"pocasi_logger_checkbox": "Log data and responses"
|
||||
},
|
||||
"data_description": {
|
||||
"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_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
|
||||
"pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo",
|
||||
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)",
|
||||
"pocasi_enabled_chcekbox": "Enables resending data to Pocasi Meteo",
|
||||
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
||||
}
|
||||
},
|
||||
"ecowitt": {
|
||||
"description": "Nastavení pro Ecowitt",
|
||||
"title": "Konfigurace pro stanice Ecowitt",
|
||||
"description": "Ecowitt configuration.",
|
||||
"title": "Ecowitt station configuration",
|
||||
"data": {
|
||||
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
|
||||
"ecowitt_webhook_id": "Unique webhook ID",
|
||||
"ecowitt_enabled": "Enable Ecowitt station data"
|
||||
},
|
||||
"data_description": {
|
||||
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
|
||||
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
|
||||
"ecowitt_webhook_id": "Set your Ecowitt station to send data to the endpoint: {url}:{port}/weatherhub/{webhook_id}",
|
||||
"ecowitt_enabled": "Enable receiving data from Ecowitt stations"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -167,11 +186,57 @@
|
|||
}
|
||||
},
|
||||
"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": {
|
||||
"name": "Integration status",
|
||||
"state": {
|
||||
"online_wu": "Online PWS/WU",
|
||||
"online_wslink": "Online WSLink",
|
||||
"online_ecowitt": "Online Ecowitt",
|
||||
"online_idle": "Waiting for data",
|
||||
"degraded": "Degraded",
|
||||
"error": "Error"
|
||||
|
|
@ -181,7 +246,8 @@
|
|||
"name": "Active protocol",
|
||||
"state": {
|
||||
"wu": "PWS/WU",
|
||||
"wslink": "WSLink API"
|
||||
"wslink": "WSLink API",
|
||||
"ecowitt": "Ecowitt"
|
||||
}
|
||||
},
|
||||
"wslink_addon_status": {
|
||||
|
|
@ -216,7 +282,8 @@
|
|||
"name": "Last access protocol",
|
||||
"state": {
|
||||
"wu": "PWS/WU",
|
||||
"wslink": "WSLink API"
|
||||
"wslink": "WSLink API",
|
||||
"ecowitt": "Ecowitt"
|
||||
}
|
||||
},
|
||||
"last_ingress_route_enabled": {
|
||||
|
|
@ -361,7 +428,7 @@
|
|||
"yearly_rain": {
|
||||
"name": "Yearly precipitation"
|
||||
},
|
||||
"wbgt_index": {
|
||||
"wbgt_temp": {
|
||||
"name": "WBGT index"
|
||||
},
|
||||
"hcho": {
|
||||
|
|
@ -478,7 +545,11 @@
|
|||
"issues": {
|
||||
"legacy_battery_sensor_deprecated": {
|
||||
"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 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 -> 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": {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"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_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_key_required": "Klíč k účtu Počasí Meteo je povinný.",
|
||||
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
|
||||
|
|
@ -107,15 +108,15 @@
|
|||
"data": {
|
||||
"POCASI_CZ_API_ID": "ID účtu na Počasí Meteo",
|
||||
"POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo",
|
||||
"POCASI_CZ_SEND_INTERVAL": "Interval v sekundách",
|
||||
"POCASI_SEND_INTERVAL": "Interval v sekundách",
|
||||
"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"
|
||||
},
|
||||
"data_description": {
|
||||
"POCASI_API_ID": "ID získáte ve své aplikaci Počasí Meteo",
|
||||
"POCASI_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo",
|
||||
"POCASI_CZ_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_SEND_INTERVAL": "Interval v jakém se mají data na server přeposílat (minimum 12s, defaultně 30s)",
|
||||
"pocasi_enabled_checkbox": "Zapne přeposílání data na server Počasí Meteo",
|
||||
"pocasi_enabled_chcekbox": "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."
|
||||
}
|
||||
},
|
||||
|
|
@ -186,11 +187,57 @@
|
|||
}
|
||||
},
|
||||
"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": {
|
||||
"name": "Stav integrace",
|
||||
"state": {
|
||||
"online_wu": "Online PWS/WU",
|
||||
"online_wslink": "Online WSLink",
|
||||
"online_ecowitt": "Online Ecowitt",
|
||||
"online_idle": "Čekám na data",
|
||||
"degraded": "Degradovaný",
|
||||
"error": "Nefunkční"
|
||||
|
|
@ -200,7 +247,8 @@
|
|||
"name": "Aktivní protokol",
|
||||
"state": {
|
||||
"wu": "PWS/WU",
|
||||
"wslink": "WSLink API"
|
||||
"wslink": "WSLink API",
|
||||
"ecowitt": "Ecowitt"
|
||||
}
|
||||
},
|
||||
"wslink_addon_status": {
|
||||
|
|
@ -235,7 +283,8 @@
|
|||
"name": "Protokol posledního přístupu",
|
||||
"state": {
|
||||
"wu": "PWS/WU",
|
||||
"wslink": "WSLink API"
|
||||
"wslink": "WSLink API",
|
||||
"ecowitt": "Ecowitt"
|
||||
}
|
||||
},
|
||||
"last_ingress_route_enabled": {
|
||||
|
|
@ -338,6 +387,30 @@
|
|||
"ch4_humidity": {
|
||||
"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": {
|
||||
"name": "Tepelný index"
|
||||
},
|
||||
|
|
@ -419,6 +492,54 @@
|
|||
"normal": "Normální",
|
||||
"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,7 +27,6 @@ from homeassistant.helpers.translation import async_get_translations
|
|||
from .const import (
|
||||
AZIMUT,
|
||||
CONNECTION_GATED_SENSORS,
|
||||
# DATABASE_PATH,
|
||||
DEV_DBG,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
|
|
@ -112,7 +111,7 @@ def anonymize(
|
|||
- Keep all keys, but mask sensitive values.
|
||||
- Do not raise on unexpected/missing keys.
|
||||
"""
|
||||
secrets = {"ID", "PASSWORD", "wsid", "wspw"}
|
||||
secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"}
|
||||
|
||||
return {k: ("***" if k in secrets else v) for k, v in data.items()}
|
||||
|
||||
|
|
@ -261,7 +260,7 @@ def celsius_to_fahrenheit(celsius: float) -> float:
|
|||
|
||||
|
||||
def to_int(val: Any) -> int | None:
|
||||
"""Convert int or string to int."""
|
||||
"""Convert int or string (including decimal-formatted, e.g. "180.0") to int."""
|
||||
|
||||
if val is None:
|
||||
return None
|
||||
|
|
@ -270,11 +269,15 @@ def to_int(val: Any) -> int | None:
|
|||
return None
|
||||
|
||||
try:
|
||||
v = int(val)
|
||||
return 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):
|
||||
return None
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
def to_float(val: Any) -> float | None:
|
||||
|
|
@ -393,110 +396,3 @@ def battery_5step_to_pct(value: str) -> int | None:
|
|||
return None
|
||||
|
||||
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,6 +13,7 @@ from homeassistant.components import persistent_notification
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
PURGE_DATA,
|
||||
|
|
@ -88,8 +89,8 @@ class WindyPush:
|
|||
""" lets wait for 1 minute to get initial data from station
|
||||
and then try to push first data to Windy
|
||||
"""
|
||||
self.last_update: datetime = datetime.now()
|
||||
self.next_update: datetime = datetime.now() + timed(minutes=1)
|
||||
self.last_update: datetime = dt_util.utcnow()
|
||||
self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
|
||||
|
||||
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
||||
|
||||
|
|
@ -170,7 +171,7 @@ class WindyPush:
|
|||
|
||||
# First check if we have valid credentials, before any data manipulation.
|
||||
self.enabled = self.config.options.get(WINDY_ENABLED, False)
|
||||
self.last_attempt_at = datetime.now().isoformat()
|
||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
||||
self.last_error = None
|
||||
|
||||
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
|
||||
|
|
@ -196,10 +197,14 @@ class WindyPush:
|
|||
str(self.next_update),
|
||||
)
|
||||
|
||||
if self.next_update > datetime.now():
|
||||
if self.next_update > dt_util.utcnow():
|
||||
self.last_status = "rate_limited_local"
|
||||
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()
|
||||
|
||||
for purge in PURGE_DATA:
|
||||
|
|
@ -219,7 +224,8 @@ class WindyPush:
|
|||
headers = {"Authorization": f"Bearer {windy_station_pw}"}
|
||||
|
||||
if self.log:
|
||||
_LOGGER.info("Dataset for windy: %s", purged_data)
|
||||
# Mask the station id (a credential) before logging the dataset.
|
||||
_LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"})
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with session.get(request_url, params=purged_data, headers=headers) as resp:
|
||||
|
|
@ -260,7 +266,7 @@ class WindyPush:
|
|||
_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."
|
||||
)
|
||||
self.next_update = datetime.now() + timedelta(minutes=5)
|
||||
self.next_update = dt_util.utcnow() + timedelta(minutes=5)
|
||||
|
||||
except WindySuccess:
|
||||
# reset invalid_response_count
|
||||
|
|
@ -289,7 +295,9 @@ class WindyPush:
|
|||
|
||||
except ClientError as ex:
|
||||
self.last_status = "client_error"
|
||||
self.last_error = str(ex)
|
||||
# Store only the exception class - last_error is surfaced via entity
|
||||
# attributes; str(ex) could embed the request URL.
|
||||
self.last_error = type(ex).__name__
|
||||
_LOGGER.critical(
|
||||
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s",
|
||||
str(ex),
|
||||
|
|
@ -299,7 +307,7 @@ class WindyPush:
|
|||
if self.invalid_response_count >= WINDY_MAX_RETRIES:
|
||||
_LOGGER.critical(WINDY_UNEXPECTED)
|
||||
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.")
|
||||
self.last_update = datetime.now()
|
||||
self.last_update = dt_util.utcnow()
|
||||
self.next_update = self.last_update + timed(minutes=5)
|
||||
|
||||
if self.log:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class _CoordinatorStub:
|
|||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
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(
|
||||
|
|
@ -213,5 +215,11 @@ def test_device_info():
|
|||
info = sensor.device_info
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["manufacturer"] == "Schizza"
|
||||
assert info["model"] == "Weather Station SWS 12500"
|
||||
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
|
||||
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,7 +2,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from custom_components.sws12500.data import SWSRuntimeData
|
||||
from types import SimpleNamespace
|
||||
|
||||
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():
|
||||
|
|
@ -44,3 +51,64 @@ def test_runtime_data_collections_are_independent_per_instance():
|
|||
assert b.sensor_descriptions == {}
|
||||
assert b.added_binary_keys == set()
|
||||
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,9 +20,24 @@ from aioecowitt import EcoWittSensor, EcoWittSensorTypes
|
|||
from aioecowitt.station import EcoWittStation
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, REMAP_ECOWITT_COMPAT
|
||||
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
|
||||
# station extraction. Contains both internally mapped fields (tempf, humidity,
|
||||
# windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2).
|
||||
|
|
@ -238,7 +253,7 @@ def test_native_sensor_init_with_mapped_stype() -> None:
|
|||
)
|
||||
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor, _ECOWITT_CONFIG)
|
||||
|
||||
assert entity._attr_unique_id == "ecowitt_pm25_ch1"
|
||||
assert entity._attr_name == "PM2.5 CH1"
|
||||
|
|
@ -249,12 +264,13 @@ def test_native_sensor_init_with_mapped_stype() -> None:
|
|||
assert entity._attr_native_unit_of_measurement == unit
|
||||
assert entity._attr_state_class == state_class
|
||||
|
||||
# Device info groups the entity under the station device.
|
||||
# Native sensors join the single shared integration device; the running station
|
||||
# type is reflected in the model, not in a separate Ecowitt device.
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt GW1000"
|
||||
assert info["model"] == "GW1000"
|
||||
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||
assert info["manufacturer"] == "Ecowitt impl. from Schizza for SWS12500"
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["model"] == "Ecowitt GW1000"
|
||||
assert info["identifiers"] == {(DOMAIN,)}
|
||||
assert info["manufacturer"] == "Schizza"
|
||||
|
||||
|
||||
def test_native_sensor_init_with_unmapped_stype() -> None:
|
||||
|
|
@ -274,37 +290,40 @@ def test_native_sensor_init_with_unmapped_stype() -> None:
|
|||
value="1000",
|
||||
)
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor, _ECOWITT_CONFIG)
|
||||
|
||||
# No HA metadata set for unknown types.
|
||||
assert getattr(entity, "_attr_device_class", None) is None
|
||||
assert getattr(entity, "_attr_native_unit_of_measurement", None) is None
|
||||
assert getattr(entity, "_attr_state_class", None) is None
|
||||
|
||||
# Device info is still present.
|
||||
# Device info is still present and points at the shared device.
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt GW1000"
|
||||
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["identifiers"] == {(DOMAIN,)}
|
||||
|
||||
|
||||
def test_native_sensor_init_without_station() -> None:
|
||||
"""A sensor with no station falls back to generic device info."""
|
||||
def test_native_sensor_device_model_follows_config_not_station() -> None:
|
||||
"""Device model comes from the entry config, independent of the sensor station.
|
||||
|
||||
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)
|
||||
# Force the no-station branch.
|
||||
sensor.station = None # type: ignore[assignment]
|
||||
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor) # default PWS config
|
||||
|
||||
info = entity._attr_device_info
|
||||
assert info["name"] == "Ecowitt station"
|
||||
assert info["model"] is None
|
||||
assert (DOMAIN, "ecowitt") in info["identifiers"]
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["model"] == "PWS"
|
||||
assert info["identifiers"] == {(DOMAIN,)}
|
||||
|
||||
|
||||
def test_native_value_returns_value() -> None:
|
||||
"""native_value returns the underlying sensor value."""
|
||||
sensor = _make_sensor(value=42.0)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor)
|
||||
assert entity.native_value == 42.0
|
||||
|
||||
|
||||
|
|
@ -312,7 +331,7 @@ def test_native_value_returns_value() -> None:
|
|||
def test_native_value_none_or_empty(empty: Any) -> None:
|
||||
"""native_value maps None and "" to None."""
|
||||
sensor = _make_sensor(value=empty)
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor)
|
||||
assert entity.native_value is None
|
||||
|
||||
|
||||
|
|
@ -320,7 +339,7 @@ def test_native_value_none_or_empty(empty: Any) -> None:
|
|||
async def test_added_and_removed_callback_lifecycle() -> None:
|
||||
"""async_added/async_will_remove register and unregister the update cb."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor)
|
||||
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
|
||||
|
|
@ -335,7 +354,7 @@ async def test_added_and_removed_callback_lifecycle() -> None:
|
|||
async def test_will_remove_when_callback_absent() -> None:
|
||||
"""async_will_remove is safe when the callback was never registered."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor)
|
||||
|
||||
# Not added; removal must not raise and must not touch the list.
|
||||
assert entity._handle_update not in sensor.update_cb
|
||||
|
|
@ -346,9 +365,85 @@ async def test_will_remove_when_callback_absent() -> None:
|
|||
def test_handle_update_writes_ha_state() -> None:
|
||||
"""_handle_update forwards to async_write_ha_state."""
|
||||
sensor = _make_sensor()
|
||||
entity = EcoWittNativeSensor(sensor)
|
||||
entity = _native(sensor)
|
||||
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
entity._handle_update()
|
||||
|
||||
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,8 +32,10 @@ from custom_components.sws12500 import health_coordinator as hc, health_sensor a
|
|||
from custom_components.sws12500.const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
LEGACY_ENABLED,
|
||||
POCASI_CZ_ENABLED,
|
||||
WINDY_ENABLED,
|
||||
WSLINK,
|
||||
|
|
@ -122,9 +124,14 @@ def _patch_network(monkeypatch, session: _FakeSession, ip: str = "1.2.3.4") -> N
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_protocol_name() -> None:
|
||||
assert hc._protocol_name(True) == "wslink"
|
||||
assert hc._protocol_name(False) == "wu"
|
||||
def test_configured_protocol() -> None:
|
||||
# Legacy enabled (default) -> wu / wslink based on the WSLINK flag.
|
||||
assert hc._configured_protocol(_make_entry()) == "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:
|
||||
|
|
@ -168,6 +175,13 @@ def test_default_health_data_wu_default() -> None:
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -262,6 +276,20 @@ def test_refresh_summary_online_idle(hass, entry) -> None:
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -647,8 +675,11 @@ def _description(key: str) -> hs.HealthSensorEntityDescription:
|
|||
|
||||
|
||||
def _stub_coordinator(data: dict[str, Any]) -> Any:
|
||||
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices."""
|
||||
return SimpleNamespace(data=data)
|
||||
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.
|
||||
|
||||
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:
|
||||
|
|
@ -663,11 +694,39 @@ def test_sensor_native_value_with_value_fn() -> None:
|
|||
assert sensor.native_value == "online"
|
||||
|
||||
|
||||
def test_sensor_extra_state_attributes_for_integration_health() -> None:
|
||||
data = {"integration_status": "online_wu", "addon": {"online": False}}
|
||||
def test_sensor_extra_state_attributes_strips_internal_fields() -> None:
|
||||
data = {
|
||||
"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)
|
||||
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:
|
||||
|
|
@ -676,13 +735,20 @@ def test_sensor_extra_state_attributes_for_other_keys() -> 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:
|
||||
coordinator = _stub_coordinator({})
|
||||
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
||||
info = sensor.device_info
|
||||
assert info["name"] == "Weather Station SWS 12500"
|
||||
assert info["manufacturer"] == "Schizza"
|
||||
assert info["model"] == "Weather Station SWS 12500"
|
||||
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
|
||||
|
||||
|
||||
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(
|
||||
hass_with_http, monkeypatch
|
||||
):
|
||||
"""Cover the fatal branch when `register_path` returns False -> PlatformNotReady."""
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
"""Cover the fatal branch when `register_path` returns False -> ConfigEntryNotReady."""
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
|
|
@ -223,7 +223,7 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false(
|
|||
AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
with pytest.raises(PlatformNotReady):
|
||||
with pytest.raises(ConfigEntryNotReady):
|
||||
await async_setup_entry(hass_with_http, entry)
|
||||
|
||||
|
||||
|
|
@ -393,6 +393,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
|||
options={API_ID: "id", API_KEY: "key", WSLINK: False},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
|
||||
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||
|
||||
# Missing security params -> unauthorized
|
||||
|
|
@ -408,6 +409,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
|||
# Missing API_ID in options -> IncorrectDataError
|
||||
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
|
||||
entry2.add_to_hass(hass)
|
||||
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
|
||||
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
|
||||
with pytest.raises(IncorrectDataError):
|
||||
await coordinator2.received_data(
|
||||
|
|
@ -415,6 +417,20 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
|
|||
) # 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
|
||||
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)."""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Literal
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
|
@ -21,11 +21,8 @@ from custom_components.sws12500.const import (
|
|||
POCASI_INVALID_KEY,
|
||||
WSLINK_URL,
|
||||
)
|
||||
from custom_components.sws12500.pocasti_cz import (
|
||||
PocasiApiKeyError,
|
||||
PocasiPush,
|
||||
PocasiSuccess,
|
||||
)
|
||||
from custom_components.sws12500.pocasti_cz import PocasiApiKeyError, PocasiPush, PocasiSuccess
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -123,7 +120,7 @@ async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
|
|||
pp = PocasiPush(hass, entry)
|
||||
|
||||
# Ensure "next_update > now" so it returns early before doing HTTP.
|
||||
pp.next_update = datetime.now() + timedelta(seconds=999)
|
||||
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse("OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -146,7 +143,7 @@ async def test_push_data_to_server_injects_auth_and_chooses_url(
|
|||
pp = PocasiPush(hass, entry)
|
||||
|
||||
# Force send now.
|
||||
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse("OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -176,7 +173,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):
|
||||
entry = _make_entry()
|
||||
pp = PocasiPush(hass, entry)
|
||||
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse("OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -196,7 +193,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):
|
||||
entry = _make_entry()
|
||||
pp = PocasiPush(hass, entry)
|
||||
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse("OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -232,7 +229,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):
|
||||
entry = _make_entry(logger=True)
|
||||
pp = PocasiPush(hass, entry)
|
||||
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse("OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -276,7 +273,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
|
|||
|
||||
# Force request attempts and exceed invalid count threshold.
|
||||
for _i in range(4):
|
||||
pp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
await pp.push_data_to_server({"x": 1}, "WU")
|
||||
|
||||
assert pp.invalid_response_count == 4
|
||||
|
|
|
|||
|
|
@ -509,3 +509,25 @@ async def test_register_path_switching_logic_is_exercised_via_routes(monkeypatch
|
|||
"""Sanity: constants exist and are distinct (helps guard tests relying on them)."""
|
||||
assert DEFAULT_URL != WSLINK_URL
|
||||
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()
|
||||
|
||||
request = _EcowittRequestStub(
|
||||
match_info={"webhook_id": "hook"}, post_data={"tempf": "68"}
|
||||
match_info={"webhook_id": "hook"}, post_data={"tempf": "68", "model": "GW2000A"}
|
||||
)
|
||||
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
|
||||
|
||||
|
|
@ -248,6 +248,9 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
|||
|
||||
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.
|
||||
update_options.assert_awaited_once()
|
||||
args, _kwargs = update_options.await_args
|
||||
|
|
@ -576,3 +579,19 @@ async def test_received_data_success_with_health_autodiscovery_and_binary(hass,
|
|||
assert kw["accepted"] is True
|
||||
assert kw["reason"] == "accepted"
|
||||
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,3 +212,9 @@ def test_add_new_sensors_adds_known_keys(hass):
|
|||
assert len(entities_arg) == 1
|
||||
assert isinstance(entities_arg[0], WeatherSensor)
|
||||
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
|
||||
import pytest
|
||||
|
||||
|
||||
def test_sensor_types_wslink_structure():
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
"""Coverage for to_int / to_float edge cases."""
|
||||
"""Coverage for to_int / to_float edge cases and anonymize() masking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.utils import to_float, to_int
|
||||
from custom_components.sws12500.utils import anonymize, 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(
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ def test_device_info_contains_expected_identifiers_and_domain():
|
|||
# DeviceInfo is mapping-like; access defensively.
|
||||
assert info.get("name") == "Weather Station SWS 12500"
|
||||
assert info.get("manufacturer") == "Schizza"
|
||||
assert info.get("model") == "Weather Station SWS 12500"
|
||||
assert info.get("model") == "PWS" # no ecowitt/wslink in stub options -> PWS
|
||||
|
||||
identifiers = info.get("identifiers")
|
||||
assert isinstance(identifiers, set)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
|
@ -12,6 +12,7 @@ import pytest
|
|||
|
||||
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 homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -64,7 +65,7 @@ def test_verify_response_rate_limit_raises(hass):
|
|||
@pytest.mark.asyncio
|
||||
async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
|
||||
wp = WindyPush(hass, _make_entry())
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
|
|
@ -80,14 +81,14 @@ async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
|
|||
@pytest.mark.asyncio
|
||||
async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
|
||||
wp = WindyPush(hass, _make_entry())
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
lambda _h: _FakeSession(_FakeResponse(status=429)),
|
||||
)
|
||||
|
||||
before = datetime.now()
|
||||
before = dt_util.utcnow()
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
assert ok is True
|
||||
assert wp.last_status == "rate_limited_remote"
|
||||
|
|
@ -99,7 +100,7 @@ async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
|
|||
async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
|
||||
wp = WindyPush(hass, _make_entry())
|
||||
wp.invalid_response_count = 2 # next duplicate makes it 3 -> finally disables
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
update_options = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
|
@ -12,21 +12,14 @@ import pytest
|
|||
from custom_components.sws12500.const import (
|
||||
PURGE_DATA,
|
||||
WINDY_ENABLED,
|
||||
WINDY_INVALID_KEY,
|
||||
WINDY_LOGGER_ENABLED,
|
||||
WINDY_NOT_INSERTED,
|
||||
WINDY_STATION_ID,
|
||||
WINDY_STATION_PW,
|
||||
WINDY_SUCCESS,
|
||||
WINDY_UNEXPECTED,
|
||||
WINDY_URL,
|
||||
)
|
||||
from custom_components.sws12500.windy_func import (
|
||||
WindyNotInserted,
|
||||
WindyPasswordMissing,
|
||||
WindyPush,
|
||||
WindySuccess,
|
||||
)
|
||||
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -151,7 +144,7 @@ async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass
|
|||
wp = WindyPush(hass, entry)
|
||||
|
||||
# Ensure "next_update > now" is true
|
||||
wp.next_update = datetime.now() + timedelta(minutes=10)
|
||||
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
|
|
@ -167,7 +160,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
|
|||
wp = WindyPush(hass, entry)
|
||||
|
||||
# Force it to send now
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -198,7 +191,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):
|
||||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -219,7 +212,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
|
|||
entry = _make_entry()
|
||||
entry.options.pop(WINDY_STATION_ID)
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -246,7 +239,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
|
|||
entry = _make_entry()
|
||||
entry.options.pop(WINDY_STATION_PW)
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -272,7 +265,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):
|
||||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
# Response triggers WindyPasswordMissing (401)
|
||||
session = _FakeSession(
|
||||
|
|
@ -303,7 +296,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
|
|||
):
|
||||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(
|
||||
response=_FakeResponse(status=401, text_value="Unauthorized")
|
||||
|
|
@ -335,7 +328,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):
|
||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -358,7 +351,7 @@ async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
|
|||
):
|
||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -390,7 +383,7 @@ async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_l
|
|||
"""
|
||||
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
# Response text that does not contain any of the known markers (NOTICE/SUCCESS/Invalid/Unauthorized)
|
||||
session = _FakeSession(response=_FakeResponse(status=500, text_value="Error"))
|
||||
|
|
@ -413,7 +406,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
|
|||
):
|
||||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
update_options = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -436,7 +429,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
|
|||
|
||||
# First 3 calls should not disable; 4th should
|
||||
for i in range(4):
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
assert ok is True
|
||||
|
||||
|
|
@ -459,7 +452,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
|
|||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.invalid_response_count = 3 # next error will push it over the threshold
|
||||
wp.next_update = datetime.now() - timedelta(seconds=1)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
update_options = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
Loading…
Reference in New Issue