Compare commits

...

9 Commits

Author SHA1 Message Date
SchiZzA 80c944fc5a
feat(device): merge all entities into one shared device with station-type model
All entities (weather, battery, health, native Ecowitt) now report through a
single device under the existing {(DOMAIN,)} identifier, removing the separate
"Ecowitt {model}" device that appeared whenever Ecowitt was enabled. This
collapses the previous two-device layout (and the duplicate/untranslated native
Ecowitt sensors living on the second device) into one.

The device model reflects the running station type via _station_model():
"Ecowitt <model>" when Ecowitt is active (model learned from the payload's
`model` field and stored on runtime_data.ecowitt_model), else "WSLink", else
"PWS". Device identity is centralised in data.build_device_info() and reused by
every platform.

No device-registry migration is required: the identifier is unchanged; only the
model/grouping change. Full device unification under a renamed hub is deferred
to the rename round.

Tests: device-info assertions updated to the shared device across the entity
suites; new _station_model/build_device_info branch coverage in test_data.py;
received_ecowitt now asserts the learned model. 320 passed, 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:37:11 +02:00
SchiZzA 3fdebc6f20
feat(ecowitt): dedupe unit-variant sensors and translate common native ones
Two long-standing rough edges with Ecowitt running alongside the SWS device:

- Dedup (#1): aioecowitt emits both metric and imperial sensors per quantity
  (tempc/tempf, rainratemm/rainratein, ...). Only the imperial twin is mapped to the
  SWS sensors, so the metric twin (and other unmapped extras) showed up as duplicate
  native entities. _on_new_sensor now skips a key whose unit-variant twin is already
  mapped or already created (twin groups derived from aioecowitt's SENSOR_MAP). HA
  converts units via device_class, so a single variant suffices.
- Translation (#2): native sensors used the raw English aioecowitt name. Common
  single-instance families (rain rate / hourly / event / 24h / weekly / monthly /
  yearly / total rain, absolute pressure, feels-like, indoor dew point, CO2) now use
  curated translation_keys (added to strings/en/cs); long-tail / multi-channel
  sensors keep the English name fallback.
- Also add the imperial RAIN_RATE_INCHES / RAIN_COUNT_INCHES to STYPE_TO_HA so those
  native rain sensors get a device class and unit.

Device unification (one "WeatherHub" device + station-type sensor) stays for the
rename round, as agreed.

314 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:55:58 +02:00
SchiZzA 72c383ffba
fix(health): reflect Ecowitt as a protocol in the diagnostics summary
The health model only knew "wu"/"wslink", so with an Ecowitt setup the
active_protocol / integration_status fell back to "PWS/WU" even while Ecowitt data
was flowing.

- Replace _protocol_name() with _configured_protocol() which returns
  wu / wslink / ecowitt (legacy endpoint takes precedence; ecowitt-only -> ecowitt).
- _refresh_summary: treat "ecowitt" as a real accepted protocol (active_protocol and
  online_<proto> now track it); the WU-vs-WSLink mismatch -> degraded check is scoped
  to the legacy pair so coexisting Ecowitt is never falsely degraded.
- Add the "ecowitt" / "online_ecowitt" entity states to strings/en/cs.

310 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:47:05 +02:00
SchiZzA 514ab823d6
feat(config-flow): mask secret fields with password selectors (Q7)
API_KEY, WINDY_STATION_PWD and POCASI_CZ_API_KEY now use a password TextSelector so
the values are masked in the UI. Field keys are unchanged, so stored options and the
flow contract are unaffected.

308 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:10:31 +02:00
SchiZzA 6a72ce0f51
i18n(cs): add missing CH5-CH8 temp/humidity and CH3-CH8 battery translations
Every entity translation_key used in code now resolves in cs.json (Czech users with
channels 5-8 or the deprecated enum battery sensors no longer see raw keys).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:04:17 +02:00
SchiZzA df8e503d2c
chore(quality): manifest keys, ConfigEntryNotReady, remove dead sqlite code
- manifest: add single_config_entry, integration_type "device", loggers
  ["aioecowitt"]; drop empty homekit/ssdp/zeroconf placeholders.
- Q10: raise ConfigEntryNotReady (not PlatformNotReady) when route registration
  fails in async_setup_entry; drop the now-unused import.
- Q11: delete the ~105-line commented-out sqlite3 statistics-migration block in
  utils.py (would have been blocking I/O in the event loop) and its dead import.

Deferred (with reason): quality_scale (needs a rule audit + quality_scale.yaml),
device-identifier 2-tuple and suggested_entity_id (entity-naming/identity changes,
to land with the planned integration rename), CoordinatorEntity generics in
sensor.py (would introduce a circular import - intentionally untyped).
Note: the health-probe protocol gate was reverted - the proxy add-on can front
WU/WSLink/Ecowitt, so polling must stay unconditional.

308 passing, 100% coverage; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:01:32 +02:00
SchiZzA 418d0c7546
fix(security): harden forwarders, cap native ecowitt entities, reject empty creds
- S8: received_data now rejects empty configured credentials (IncorrectDataError)
  and present-but-empty incoming credentials (HTTPUnauthorized) instead of relying
  solely on the config flow. Credential validation extracted into
  _validate_credentials() (also resolves the C901 complexity warning and unifies
  the WU/WSLink branches).
- S6: cap auto-created native Ecowitt entities (MAX_NATIVE_ECOWITT_SENSORS) to bound
  entity-registry growth from a fabricated multi-key payload.
- S5: store only the exception class name in Windy/Pocasi last_error (surfaced via
  entity attributes; str(ex) could embed the request URL).
- S7: verified safe - the native-sensor INFO log is parametrized (%s), so no
  format-string injection; left as-is.

308 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:48:50 +02:00
SchiZzA 038d2459b9
fix(correctness): reload-window guards, rate-limit races, ecowitt flush, dt_util
- C1: received_data / received_ecowitt_data return 503 (not 500) when the entry is
  mid-reload (runtime_data gone); add_new_sensors / add_new_binary_sensors are
  defensive no-ops in that window too.
- C2/C3: Windy and Pocasi reserve their next send window *before* the await, so
  concurrent webhooks can no longer both pass the rate-limit check and double-send
  (which could falsely trip the auto-disable threshold).
- C5: EcowittBridge.set_add_entities flushes unmapped sensors parsed before the
  platform was ready (aioecowitt fires new_sensor_cb only once per key).
- C6: a forwarder auto-disabling itself from the hot path no longer forces a full
  config-entry reload (update_listener now skips reload for live-read flags
  SENSORS_TO_LOAD / WINDY_ENABLED / POCASI_CZ_ENABLED).
- C7: to_int accepts decimal-formatted integers ("180.0").
- C8: forwarders use dt_util.utcnow() (UTC-aware) instead of naive datetime.now().

Tests updated; 305 passing, 100% coverage; ruff + basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:32:00 +02:00
SchiZzA 1fe4d6da45
fix(security)+i18n: stop health-attribute leak, mask secrets in logs, sync translations
Security (from the deep audit):
- S1: health "integration_health" sensor no longer dumps the full snapshot in
  extra_state_attributes (readable by any HA user). public_health_snapshot()
  strips internal network details (source IP, internal URLs, raw add-on status);
  full data stays on the authenticated endpoint and admin-only diagnostics.
- S2: anonymize() now masks the Ecowitt passkey/PASSKEY.
- S3: Windy station id masked before logging the dataset.
- S4: diagnostics redacts internal network fields (source IP, URLs, raw_status).

i18n (Q1/Q2):
- Sync strings.json + en.json with the actual flow: add wslink_port_setup step,
  the ecowitt/pocasi/wslink_port_setup menu entries, the pocasi_* and
  windy_key_required error keys, the stale_sensors_detected issue; fix wrong data
  keys (WSLINK->wslink + legacy_enabled, POCASI_CZ_SEND_INTERVAL->POCASI_SEND_INTERVAL,
  pocasi_enabled_checkbox->pocasi_enabled_chcekbox); translate the English ecowitt
  options step (was Czech); de-duplicate the legacy-battery issue description.
- Q2: rename wbgt_index -> wbgt_temp to match the entity translation_key.
- cs.json: add windy_key_required and fix the pocasi data/data_description keys.

Tests: 300 passing, 100% coverage maintained; basedpyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:08:34 +02:00
32 changed files with 1539 additions and 870 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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,
}

View File

@ -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)

View File

@ -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),
)

View File

@ -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",
}

View File

@ -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]

View File

@ -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),

View File

@ -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)

View File

@ -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"
}

View File

@ -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))

View File

@ -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)

View File

@ -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": {

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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)."""

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,5 @@
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
import pytest
def test_sensor_types_wslink_structure():

View File

@ -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(

View File

@ -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)

View File

@ -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(

View File

@ -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(