Compare commits

...

69 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
SchiZzA bf6a02c5b3
fix(security): authenticate health endpoint and stop webhook-id leak
The /station/health route is registered directly on the aiohttp router, so HA's
auth middleware only flags requests without blocking them - the endpoint was
reachable unauthenticated and returned the full health snapshot.

- health_status now requires HA authentication (bearer token or signed request)
  via KEY_AUTHENTICATED and returns 401 otherwise.
- Mask the Ecowitt webhook id (the endpoint's only credential) in last_ingress
  paths via _sanitize_path, so it never enters the snapshot exposed by the health
  endpoint or the diagnostics download.
- Redact ECOWITT_WEBHOOK_ID in diagnostics.
- Compare the Ecowitt webhook id in constant time (hmac.compare_digest), matching
  the WU/WSLink credential checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:42:56 +02:00
SchiZzA b3aec61fc2
test: reach 100% coverage of custom_components/sws12500
Add focused test modules and extend existing ones to cover every line of the
integration (1588 stmts, 0 missing; 295 tests):

- test_diagnostics.py: redaction + health_data fallback.
- test_binary_battery.py: binary_sensor platform setup / add_new + BatteryBinarySensor.
- test_staleness_legacy.py: warmup/threshold stale detection + legacy orphan issue.
- test_ecowitt_bridge.py: EcowittBridge parsing/discovery + EcoWittNativeSensor.
- test_health.py: HealthCoordinator (online/offline refresh, summary, ingress,
  forwarding, HTTP endpoint) + HealthDiagnosticSensor.
- test_received_ecowitt.py: received_ecowitt_data paths + received_data health branches.
- test_windy_more.py: duplicate (409) / rate-limit (429) / 3-strike disable.
- test_routes_more.py: RouteInfo.__str__, ingress observer, canonical resolve.
- test_utils_conv.py: to_int/to_float edge cases.
- config_flow: wslink_port_setup step + initial ecowitt flow.
- init: _check_stale time-interval callback.
- lifecycle: register_path idempotency (existing-dispatcher branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:23:34 +02:00
SchiZzA acda94bb03
fix(utils): widen wind_dir_to_text param to float | str | None
The azimut value_from_data_fn now passes dir.get(WIND_DIR) (typed Any | None),
which tripped basedpyright reportArgumentType against the float-only signature.
The body already coerces via to_float() and handles None, so widen the
annotation to match the real inputs (raw str payload, float, or None).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:53:46 +02:00
SchiZzA 50d0e9fae7
test: align suite with v2.0 runtime_data architecture
The suite predated the coordinator extraction and the typed runtime_data
migration: 5 modules failed to import and 18 tests failed.

- Import IncorrectDataError/WeatherDataUpdateCoordinator from .coordinator.
- Point received_data monkeypatch targets at custom_components.sws12500.coordinator.*
- Stub config entries with async_on_unload + runtime_data (SWSRuntimeData).
- Rewrite test_data, test_sensor_platform and test_integration_lifecycle off the
  removed ENTRY_* hass.data keys onto runtime_data (route dispatcher reuse, ecowitt
  POST route, no hass.data pop on unload, health first_refresh mocked).
- Update config_flow tests for the user menu (pws step) and the options menu
  gaining wslink_port_setup.
- Fix VOCLevel.EXCELENT typo and stale t9 expectations (tuple battery list,
  HCHO int coercion, connection-gated subset).

Result: 168 passed, 0 collection errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:51:52 +02:00
SchiZzA 25341f79b3
fix: address review findings across forwarders and helpers
- wind_dir_to_text: treat 0/missing direction as None (calm) so a missing
  wind direction no longer renders as North; azimut lambdas pass None too.
- windy: log response status (was logging an un-awaited coroutine method);
  count "unexpected response" toward the disable threshold even when logging
  is off.
- pocasi: disable threshold >3 -> >=3 to match Windy's 3-strike behavior.
- ecowitt: always attach device_info to native sensors, including unknown
  sensor types (previously left orphaned without a device).
- const: stop remapping WSLink connection flags (t1cn/t234cXcn) into the
  payload; they had no entity and leaked ghost *_connection keys into data
  and persisted SENSORS_TO_LOAD. Gating uses the raw keys and is unaffected.
- config_flow: fix WSLink port fallback typo 433 -> 443; drop mutable default
  argument on async_step_init.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:51:35 +02:00
SchiZzA b72efa5985
fix(coordinator): pass config_entry to DataUpdateCoordinator
Pass config_entry explicitly to super().__init__() in both
WeatherDataUpdateCoordinator and HealthCoordinator instead of relying on the
implicit ContextVar (deprecated, breaks in HA 2026.8 for core and was breaking
async_config_entry_first_refresh outside the setup context).

Also resolve each discovered sensor's display name once in received_data
(the previous comprehension awaited translations() twice per key).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:51:25 +02:00
SchiZzA e20c23a3af
fix(routes): rebind sticky health route on reload; defensive resolve
After a config reload a new HealthCoordinator is created, but the sticky
/station/health route kept calling the previous (stale) instance. Add
Routes.rebind_handler() and call it from async_setup_entry's reload branch
so the endpoint always serves the current coordinator.

Also make _resolve_route() tolerant of requests without match_info/route/
resource instead of raising AttributeError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:51:14 +02:00
SchiZzA b777296b52
fix(ecowitt): Connect Ecowitt bridge to sensor platform earlier 2026-06-21 11:28:24 +02:00
SchiZzA e3d712bd54
fix(wslink): Definiton of HCHO, VOC, T9 battery diplicates. 2026-06-21 11:17:38 +02:00
SchiZzA 2f7d96f8d3
Add stall sensor detection. 2026-06-01 17:47:30 +02:00
SchiZzA 0142c5412b
fix: coordinator reuse, route dispatcher, binary sensor translations & test suite alignment
- Reuse existing `WeatherDataUpdateCoordinator` instance across reloads instead of
  creating a new one; routes keep pointing to the same coordinator, so entities
  stay subscribed and updates never silently drop
- `update_listener` now skips full reload when only `SENSORS_TO_LOAD` changes
  (auto-discovery); avoids the "frozen UI" window caused by temporary platform unload
- Replaced direct `route._handler` mutation with a proper `Routes` dispatcher;
  all aiohttp routes are registered once and an internal dispatcher decides which
  handler is active — safe to switch at runtime without touching the router
- Fixed `self.config_entry` / `self.config` inconsistency in coordinator

- `BatteryBinarySensor` now carries correct `device_info` (same identifiers as
  `WeatherSensor`) → single device card in UI instead of two
- Translations for binary sensors must live under `entity.binary_sensor.*`,
  not `entity.sensor.*`

- Fixed all tests broken by new `register_path(hass, coordinator, coordinator_h, entry)` signature
- Updated `_RouterStub` to accept `name=` kwargs and return objects with `.method`
- Added `async post()` to `_RequestStub` stubs
- Aligned `Routes` tests with new `method:path` dispatch key format and `show_enabled()` output
- Replaced `WindyApiKeyError` with `WindyPasswordMissing` to match actual exceptions
- Updated `_FakeResponse` to use HTTP status codes instead of response text strings
- Patched `persistent_notification.create` in Windy push tests to avoid `SimpleNamespace` errors
2026-06-01 17:01:40 +02:00
SchiZzA 4001ff0ba4
fix: Fixed type errors 2026-05-28 11:30:05 +02:00
SchiZzA 7ea47e8591
fix DEV_DBG as constant not string literal 2026-05-27 21:50:14 +02:00
SchiZzA 463b922e83
fix bool truthy 2026-05-27 21:49:39 +02:00
SchiZzA e570ff0014
hmac compare ID / KEY 2026-05-27 21:41:40 +02:00
SchiZzA 1ad10b4b1e
fixed: Menu options 2026-05-27 21:34:32 +02:00
SchiZzA b9815713a0
fix value in wslink sensors
Fix VOCLevel value
2026-05-27 21:30:11 +02:00
SchiZzA 211965dd52
Ecowitt support
Update setiing (config_flow and translations) for Ecowitt settings.
Also updated utils.py to accept None for value.
2026-05-27 21:27:13 +02:00
SchiZzA 9288ae4a64
Updated config flow
Updated config flow's initial setup.
Ecowitt stations does not require `API_ID` and `API_KEY`, so we need to
separate Ecowitt setup from PWS/WSLink setup.
2026-05-27 17:27:10 +02:00
schizza 7abfedc1ca
```
docs: Update README for Ecowitt pre-release, expanded sensor support, and guidance

Update the README to reflect current development and provide clearer user guidance:
- Announce active Ecowitt support in the v2.0.0pre1 pre-release version.
- Expand the "Supported sensors" section with details on WSLink protocol specific sensors,
  including additional channels (CH2/CH3), WBGT, heat index, wind chill,
  sensor battery levels, and HCHO/VOC air-quality sensors (WH46).
- Clarify WSLink configuration, warnings, and quick recommendations.
- Correct the manual installation path for custom components and improve general readability.
```
2026-05-25 22:24:42 +02:00
SchiZzA cc50f9babb
fix: Corrected RAIN and DAILYRAIN for PWS protocol.
The `RAININ` suppose to be accumulated rainfall in 60 minutes. So the
unit must be in `mm/h` or `in/h`.
In the opposite `DAILYRAIN` is rain in the day do far, represented in
accumulated `mm` or `in`.
2026-05-24 22:19:53 +02:00
SchiZzA 21dfa61cd5
Add HCHO / VOC air-quality sensors (T9 module)
Support the WSLink t9 air-quality module:
  - t9hcho (formaldehyde, ppb),
  - t9voclv (VOC level 1-5 -> ENUM state)
  - t9bat (0-5 battery -> percentage).

  The t9hcho/t9voclv/t9bat values are dropped when
  t9cn reports the module as disconnected,
  so no empty entities are created.

  - const: HCHO/VOC/T9_BATTERY keys, VOCLevel enum +
  VOC_LEVEL_MAP, BATTERY_NON_BINARY, CONNECTION_GATED_SENSORS,
  REMAP_WSLINK_ITEMS entries
  - utils: voc_level_to_text(),
  battery_5step_to_pct(), connection gating
  - sensors_wslink: HCHO / VOC / T9_BATTERY entity
  descriptions
  - translations (en, cs): hcho, voc (+ states),
  t9_battery
  - tests: tests/conftest.py +
  tests/test_t9_air_quality.py
2026-05-24 22:19:30 +02:00
SchiZzA 1d2c1b4be3
Introduce SWSRuntimeData dataclass for typed entry.runtime_data.
Adds the SWSRuntimeData dataclass and SWSConfigEntry type alias so
  per-entry state can move from loosely-typed hass.data[DOMAIN][entry_id]
  dicts to HA 2025+ typed entry.runtime_data. The legacy ENTRY_* string
  keys are kept in place for now so callers can be migrated incrementally.

  This commit is not final update. Just preparing for future complete refactor.
2026-05-11 13:49:40 +02:00
SchiZzA 202447405f
Typos corrected. 2026-04-11 17:37:53 +02:00
schizza fed00b437a
Add test-station-server as a submodule.
Include the test station server tool in the tools directory to facilitate local development and testing of weather station protocols.
2026-04-11 17:24:49 +02:00
schizza 15e9df00ca
Implement Ecowitt protocol support and dynamic binary sensor discovery.
- Integrate aioecowitt to parse incoming weather station payloads and map sensors to the SWS pipeline.
- Add a new webhook endpoint at /weatherhub/{webhook_id} with support for dynamic route resolution.
- Expand battery binary sensor definitions and implement logic for dynamic entity creation.
- Bump version to 2.0.0-pre1.
2026-04-11 16:37:17 +02:00
SchiZzA 50fe9e35fe
Add Ecowitt compatibility and deprecate legacy battery sensors. 2026-04-10 18:35:33 +02:00
SchiZzA 7f72497e9e
Update version to 2.0.pre0 2026-03-23 18:35:29 +01:00
SchiZzA e737fb16d3
Use configured WSLink add-on port for health/status endpoints. 2026-03-23 18:32:02 +01:00
SchiZzA 159d465db5
Improve sensor value parsing and add battery binary sensors.
- Introduce `to_int`/`to_float` helpers to safely handle None/blank payload values.
- Use the converters across weather/wslink sensor descriptions and simplify wind direction handling.
- Add low-battery binary sensor entities and definitions.
2026-03-23 18:21:05 +01:00
SchiZzA 9d5fafa8d0
Add configuration options for WSLink Addon port. 2026-03-23 18:14:46 +01:00
SchiZzA 63660006ea
Add health diagnostics coordinator and routing snapshot
Track ingress/forwarding status, expose detailed health sensors and translations, and include redacted diagnostics data.
2026-03-14 17:39:52 +01:00
SchiZzA 39b16afcbc
Update constants to more readable form. 2026-03-05 11:47:52 +01:00
SchiZzA f0554573ce
Make routes method-aware and update related tests
Include HTTP method in route keys and dispatch, and fix
Routes.show_enabled.
Update register_path to accept a HealthCoordinator and adjust router
stubs in tests. Update WindyPush tests to use response objects
(status/text)
and adapt related exception/notification expectations.
2026-03-04 07:53:26 +01:00
SchiZzA 995f607cf7
Improve Windy error handling and retry logic 2026-03-03 14:17:34 +01:00
SchiZzA 3e573087a2
Add multiple health sensors and device info
Introduce HealthSensorEntityDescription and a tuple of sensor
descriptions for integration status, source IP, base URL and addon
response. Instantiate one HealthDiagnosticSensor per description in
async_setup_entry. Update HealthDiagnosticSensor to accept a
description, derive unique_id from description.key and add a cached
device_info returning a SERVICE-type device. Adjust imports.
2026-03-02 22:08:40 +01:00
SchiZzA 6a4eed2ff9
Validate hass data with py_typecheck.checked
Replace manual isinstance checks and casts with py_typecheck.checked()
to validate hass and entry data and return early on errors. Simplify
add_new_sensors by unwrapping values, renaming vars, and passing the
coordinator to WeatherSensor
2026-03-02 22:08:01 +01:00
SchiZzA b3aae77132
Replace casts with checked type helpers
Use checked and checked_or to validate option and hass.data types,
remove unsafe typing.cast calls, simplify coordinator and entry_data
handling, and cache boolean option flags for Windy and Pocasí checks
2026-03-02 22:06:09 +01:00
SchiZzA 7d1494f29b
Removed extended debugging info from sensors. Added descriptive method on route info. 2026-03-01 17:32:36 +01:00
SchiZzA 01058a07b4
Add WSLink support for additional sensor channels
- Extend constants and WSLink key remapping for channels 3–8 (temp, humidity, battery and connection)
- Add new WSLink sensor entity descriptions for the extra channel readings
- Update English translations for the newly added channel sensors and battery states
2026-03-01 16:56:46 +01:00
SchiZzA f06f8b31ae
Align Windy resend with Stations API response handling
- Add WINDY_MAX_RETRIES constant and use it consistently when deciding to disable resending
- Refactor Windy response verification to rely on HTTP status codes per stations.windy.com API
- Improve error handling for missing password, duplicate payloads and rate limiting
- Enhance retry logging and disable Windy resend via persistent notification on repeated failures
2026-03-01 13:51:17 +01:00
SchiZzA 95663fd78b
Stabilize webhook routing and config updates
- Register aiohttp webhook routes once and switch the active dispatcher handler on option changes
- Make the internal route registry method-aware (GET/POST) and improve enabled-route logging
- Fix OptionsFlow initialization by passing the config entry and using safe defaults for credentials
- Harden Windy resend by validating credentials early, auto-disabling the feature on invalid responses, and notifying the user
- Update translations for Windy credential validation errors
2026-03-01 12:48:23 +01:00
SchiZzA cc1afaa218
Organize imports in `__init__.py` 2026-02-26 17:59:25 +01:00
SchiZzA 9255820a13
Added support for GET and POST endpoints. 2026-02-26 17:58:12 +01:00
SchiZzA 1bbeab1ffe
Add health diagnostic sensor and extensive tests for sws12500
integration

- Introduce HealthDiagnosticSensor for device health status reporting
- Add new constants and data keys for health sensor integration
- Wire health_sensor module into sensor platform setup
- Refactor sensor descriptions to improve derived sensor handling
- Implement pytest fixtures and comprehensive tests covering:
  - Config flows and options validation
  - Data reception and authentication
  - Sensor platform setup and dynamic sensor addition
  - Push integration with Pocasi.cz and Windy API
  - Route dispatching and error handling
  - Utilities, conversions, and translation functions
2026-02-21 11:29:40 +01:00
SchiZzA 214b8581b0
Remove numpy dependency and simplify range checks in heat_index function 2026-02-06 18:32:56 +01:00
SchiZzA c84e112073
Fix typo in fallback handler name from unregistred to unregistered 2026-02-06 18:22:31 +01:00
SchiZzA fa3b5aa523
Anonymize data payload in PocasiPush logging 2026-02-06 18:20:17 +01:00
SchiZzA f608ab4991
Remove verify_ssl=False from async_get_clientsession calls 2026-02-06 18:14:02 +01:00
SchiZzA 0c67b8d2ab
Update Windy integration to use station ID and password authentication
- Replace API key with station ID and password for authentication
- Change Windy API endpoint to v2 observation update
- Adapt data conversion for WSLink to Windy format
- Update config flow and translations accordingly
2026-02-06 15:16:05 +01:00
SchiZzA 176420aa43
Update README.md 2026-02-03 18:03:08 +01:00
schizza c8466fdaa5
Cherry pick README.md 2026-02-03 17:53:13 +01:00
SchiZzA 1fec8313d4
Refactor SWS12500 integration for push-based updates
- Add detailed architecture overview in __init__.py
- Introduce shared runtime keys in data.py to avoid key conflicts
- Implement dual route dispatcher for webhook endpoint selection
- Enhance sensor platform for dynamic sensor addition without reloads
- Rename battery level "unknown" to "drained" with updated icons
- Improve utils.py with centralized helpers for remapping and discovery
- Update translations and strings for consistency
2026-01-27 09:21:27 +01:00
SchiZzA a3dc3d0d53
Improve data validation and error logging in utils.py 2026-01-18 21:48:24 +01:00
SchiZzA 234840e115
Rename battery_level_to_text to battery_level and update docstring 2026-01-18 19:36:33 +01:00
SchiZzA a20369bab3
Fix logging of unregistered route to include path 2026-01-18 19:35:51 +01:00
SchiZzA 08b812e558
Improve type safety, add data validation, and refactor route handling
- Add typing and runtime checks with py_typecheck for config options and
  incoming data
- Enhance authentication validation and error handling in data
  coordinator
- Refactor Routes class with better dispatch logic and route
  enabling/disabling
- Clean up async functions and improve logging consistency
- Add type hints and annotations throughout the integration
- Update manifest to include typecheck-runtime dependency
2026-01-18 17:53:28 +01:00
SchiZzA 39cd852b36
Merge remote-tracking branch 'origin/stable' into ecowitt_support 2025-12-22 16:43:04 +01:00
SchiZzA 466d41f1bb
Config_flow for Ecowitt 2025-12-09 12:01:32 +01:00
SchiZzA e34f73a467
Refactor config_flow, add support for Ecowitt configuration 2025-12-07 17:07:11 +01:00
63 changed files with 11656 additions and 1305 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "tools/test-station-server"]
path = tools/test-station-server
url = https://github.com/schizza/test-station-server.git

154
README.md
View File

@ -1,36 +1,127 @@
![GitHub Downloads](https://img.shields.io/github/downloads/schizza/SWS-12500-custom-component/total?label=downloads%20%28all%20releases%29)
![Latest release downloads](https://img.shields.io/github/downloads/schizza/SWS-12500-custom-component/latest/total?label=downloads%20%28latest%29)
# Integrates your Sencor SWS 12500 or 16600, GARNI, BRESSER weather stations seamlessly into Home Assistant
# Integrates your Sencor SWS 12500, SWS16600, SWS 10500, GARNI, BRESSER weather stations seamlessly into Home Assistant
This integration will listen for data from your station and passes them to respective sensors. It also provides the ability to push data to Windy API.
This integration will listen for data from your station and passes them to respective sensors. It also provides the ability to push data to `Windy API` or `Pocasi Meteo`.
_This custom component replaces [old integration via Node-RED and proxy server](https://github.com/schizza/WeatherStation-SWS12500)._
### Ecowitt support is coming in the next major release
## Warning - WSLink APP (applies also for SWS 12500 with firmware >3.0)
As of April 11, 2026, Ecowitt stations are supported in the pre-release version
[v2.0.0pre1](https://github.com/schizza/SWS-12500-custom-component/releases/tag/v2.0.0pre1).
You can download this pre-release in HACS under `target version`, where you can pick the exact
version of the integration. Please be aware that this pre-release is really for testing
purposes only.
For stations that are using WSLink app to setup station and WSLink API for resending data (SWS 12500 manufactured in 2024 and later). You will need to install [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to your Home Assistant if you are not running your Home Assistant instance in SSL mode or you do not have SSL proxy for your Home Assistant.
---
### Integration rename is planned for the next major release
The current name no longer reflects what the integration does. It was initially developed
primarily for the SWS 12500 station, but it already supports other weather stations as well
(Bresser, Garni and others), and Ecowitt support is on the way — so the current name has
become misleading. The transition will be announced via an update, and I'm also planning to
offer a full data migration from the existing integration to the new one, so you won't lose
any of your historical data.
- The transition date hasn't been set yet, but it's currently expected to happen within the
next ~23 months. At the moment, I'm working on a full refactor and general code cleanup.
Looking further ahead, the goal is to have the integration fully incorporated into Home
Assistant as a native component — meaning it won't need to be installed via HACS but will
become part of the official Home Assistant distribution.
- I'm also looking for someone who owns an Ecowitt weather station and would be willing to
help with testing the integration for these devices.
---
## Warning — WSLink app (applies also to SWS 12500 with firmware > 3.0)
Please read the **IMPORTANT** note below.
For stations that use the WSLink app to set up the station and the WSLink API for resending
data (also SWS 12500 stations manufactured in 2024 or later), you will need to install the
[WSLink SSL proxy add-on](https://github.com/schizza/wslink-addon) into your Home Assistant
— unless you are already running Home Assistant in SSL mode or you have your own SSL proxy
in front of Home Assistant.
---
> [!IMPORTANT]
> The recommendation above does not apply to all stations. As is known by now, some stations
> — even when configured via the `WSLink App` — do not use the `WSLink protocol` to send
> data to custom servers. It can therefore be confusing how to configure your station, and
> for that case I made a simple debugging server (see below).
## Not Sure How Your Station Sends Data?
If you are struggling with configuration and don't know which protocol your station uses (`WSLink` or `PWS/WU`), or whether it sends data over SSL or plain HTTP — use our public test server:
**<https://test-station.schizza.cz>**
1. Open the link above and create a new session.
2. Point your weather station to the generated subdomain address.
3. Within a few seconds the server will show you:
- the **protocol** your station is using (`WSLink` or `PWS/WU`)
- whether the request arrived over **SSL** or **non-SSL**
- the full query string, headers, and payload your station sent
This information tells you exactly how to configure the integration in Home Assistant.
### Quick Recommendations
| Station sends via | Protocol | Recommended Setup |
|---|---|--- |
| **plain HTTP** | PWS/WU | Point the station directly at Home Assistant — no proxy needed. Keep `WSLink API` unchecked (disabled). |
| **plain HTTP** | WSLink | Point the station directly at Home Assistant — no proxy needed. Enable `WSLink API` in settings. |
| **HTTPS (SSL)** | PWS/WU | Install the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon) — it terminates TLS and forwards plain HTTP to Home Assistant. Keep `WSLink API` unchecked (disabled). |
| **HTTPS (SSL)** | WSLink | Install the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon) — it terminates TLS and forwards plain HTTP to Home Assistant, and enable `WSLink API`. |
— If the test server shows your station is sending over SSL, you need the [WSLink Proxy Add-on](https://github.com/schizza/wslink-addon). If it sends plain HTTP, you can connect directly.
Web server repo is reachable here: <https://github.com/schizza/test-station-server>
## Requirements
- Weather station that supports sending data to custom server in their API [(list of supported stations.)](#list-of-supported-stations)
- Configure station to send data directly to Home Assistant.
- If you want to push data to Windy, you have to create an account at [Windy](https://stations.windy.com).
- If you want to resend data to `Pocasi Meteo`, you have to create accout at [Pocasi Meteo](https://pocasimeteo.cz)
## List of supported stations
## Examples of supported stations
- [Sencor SWS 12500 Weather Station](https://www.sencor.cz/profesionalni-meteorologicka-stanice/sws-12500)
- [Sencor SWS 16600 WiFi SH](https://www.sencor.cz/meteorologicka-stanice/sws-16600)
- Bresser stations that support custom server upload. [for example, this is known to work](https://www.bresser.com/p/bresser-wi-fi-clearview-weather-station-with-7-in-1-sensor-7002586)
- Garni stations with WSLink support or custom server support.
- SWS 10500 (newer firmware revisions are also supported via the [WSLink SSL proxy add-on](https://github.com/schizza/wslink-addon))
- Bresser stations that support custom server upload — [this model is known to work](https://www.bresser.com/p/bresser-wi-fi-clearview-weather-station-with-7-in-1-sensor-7002586), for example
- Garni stations with WSLink support or custom server support
- and a bunch of other models that aren't listed here are also supported
## Supported sensors
The integration auto-creates sensors as soon as the station first sends data for them — new
sensors trigger a notification in Home Assistant and are added to the entity list
automatically.
Beyond the standard set (outdoor / indoor temperature and humidity, barometric pressure,
wind speed / direction / gust, rain rate and daily / weekly / monthly / yearly totals, dew
point, UV index, solar irradiance) the WSLink protocol also exposes:
- additional channels **CH2** and **CH3** for temperature and humidity
- **WBGT** index, **heat index**, **wind chill**
- sensor battery levels (outdoor, indoor, CH2)
- **Formaldehyde (HCHO)** in ppb and **VOC level** as a 15 air-quality index
(1 = worst, 5 = best) from the WH46 / 7-in-1 air-quality combo sensor
- **HCHO/VOC sensor battery** reported as a percentage
HCHO / VOC entities are only created when the station reports the air-quality module as
connected (`t9cn = 1`), so they don't clutter the device when no such sensor is attached.
## Installation
### If your SWS12500 station's firmware is 1.0 or your station is configured as described in this README and you still can not see any data incoming to Home Assistant please [read here](https://github.com/schizza/SWS-12500-custom-component/issues/17) and [here](firmware_bug.md)
### For stations that send data through WSLink API
### For stations that send through WSLink API
Make sure you have your Home Assistant cofigured in SSL mode or use [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to bypass SSL configuration of whole Home Assistant.
Make sure you have your Home Assistant configured in SSL mode or use [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) to bypass SSL configuration of whole Home Assistant.
### HACS installation
@ -46,11 +137,11 @@ After adding this repository to HACS:
### Manual installation
For manual installation you must have an access to your Home Assistant's `/config` folder.
For manual installation you must have access to your Home Assistant's `/config` folder.
- Clone this repository or download [latest release here](https://github.com/schizza/SWS-12500-custom-component/releases/latest).
- Clone this repository or download the [latest release here](https://github.com/schizza/SWS-12500-custom-component/releases/latest).
- Copy the `custom_components/sws12500-custom-component` folder to your `config/custom_components` folder in Home Assistant.
- Copy the `custom_components/sws12500` folder to your `config/custom_components` folder in Home Assistant.
- Restart Home Assistant.
- Now go to `Integrations` and add new integration `Sencor SWS 12500 Weather station`
@ -86,6 +177,16 @@ If you change `API ID` or `API KEY` in the station, you have to reconfigure inte
As soon as the integration is added into Home Assistant it will listen for incoming data from the station and starts to fill sensors as soon as data will first arrive.
## Upgrading from PWS to WSLink
If you upgrade your station — which was previously sending data in the PWS protocol — to a
station that uses the WSLink protocol, you have to remove the integration and reinstall it.
The WSLink protocol uses the metric scale instead of the imperial scale used in PWS, and
deleting and reinstalling the integration makes sure the sensors are aware of the change of
measurement scale.
- because sensor unique IDs stay the same, you will not lose any of your historical data
## Resending data to Windy API
- First of all you need to create account at [Windy stations](https://stations.windy.com).
@ -103,6 +204,7 @@ As soon as the integration is added into Home Assistant it will listen for incom
- You are done.
## Resending data to Pocasi Meteo
- If you are willing to use [Pocasi Meteo Application](https://pocasimeteo.cz) you can enable resending your data to their servers
- You must have account at Pocasi Meteo, where you will recieve `ID` and `KEY`, which are needed to connect to server
- In `Settings` -> `Devices & services` find SWS12500 and click `Configure`.
@ -114,20 +216,26 @@ As soon as the integration is added into Home Assistant it will listen for incom
## WSLink notes
While your station is using WSLink you have to have Home Assistant in SSL mode or behind SSL proxy server.
You can bypass whole SSL settings by using [WSLink SSL proxy addon](https://github.com/schizza/wslink-addon) which is made exactly for this integration to support WSLink on unsecured installations of Home Assistant.
If your station sends WSLink data over SSL (see the
[Quick Recommendations](#quick-recommendations) table above), Home Assistant has to be
reachable over SSL too — either by running Home Assistant in SSL mode, or by putting it
behind an SSL proxy. You can bypass whole-system SSL setup by using the
[WSLink SSL proxy add-on](https://github.com/schizza/wslink-addon), which is made exactly
for this integration to support WSLink on non-SSL installations of Home Assistant.
### Configuration
- Set your station as [mentioned above](#configure-your-station-in-ap-mode) while changing `HA port` to be the port number you set in the addon (443 for example) not port of your Home Assistant instance. And that will do the trick!
- Set your station up as [described above](#configure-your-station-in-ap-mode), but for
`HA port` use the port the add-on is listening on (4443 by default) — **not** the port
of your Home Assistant instance.
```plain
HomeAssistant is at 192.0.0.2:8123
WSLink proxy addon listening on port 4443
Home Assistant is at 192.168.0.2:8123
WSLink proxy add-on is listening on port 4443
you will set URL in station to: 192.0.0.2:4443
→ set the station URL to: 192.168.0.2:4443
```
- Your station will be sending data to this SSL proxy and addon will handle the rest.
- Your station will send data to the SSL proxy and the add-on will handle the rest.
_Most of the stations does not care about self-signed certificates on the server side._
_Most stations do not care about self-signed certificates on the server side._

View File

@ -1,253 +1,244 @@
"""The Sencor SWS 12500 Weather Station integration."""
"""Sencor SWS 12500 Weather Station integration (push/webhook based).
Architecture overview
---------------------
This integration is *push-based*: the weather station calls our HTTP endpoint and we
receive a query payload. We do not poll the station.
Key building blocks:
- `WeatherDataUpdateCoordinator` acts as an in-memory "data bus" for the latest payload.
On each webhook request we call `async_set_updated_data(...)` and all `CoordinatorEntity`
sensors get notified and update their states.
- `hass.data[DOMAIN][entry_id]` is a per-entry *dict* that stores runtime state
(coordinator instance, options snapshot, and sensor platform callbacks). Keeping this
structure consistent is critical; mixing different value types under the same key can
break listener wiring and make the UI appear "frozen".
Auto-discovery
--------------
When the station starts sending a new field, we:
1) persist the new sensor key into options (`SENSORS_TO_LOAD`)
2) dynamically add the new entity through the sensor platform (without reloading)
Why avoid reload?
Reloading a config entry unloads platforms temporarily, which removes coordinator listeners.
With a high-frequency push source (webhook), a reload at the wrong moment can lead to a
period where no entities are subscribed, causing stale states until another full reload/restart.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import InvalidStateError, PlatformNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from .const import (
API_ID,
API_KEY,
DEFAULT_URL,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_URL_PREFIX,
HEALTH_URL,
LEGACY_ENABLED,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
WSLINK_URL,
)
from .pocasti_cz import PocasiPush
from .routes import Routes, unregistred
from .utils import (
anonymize,
check_disabled,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
)
from .windy_func import WindyPush
from .coordinator import WeatherDataUpdateCoordinator
from .data import SWSConfigEntry, SWSRuntimeData
from .health_coordinator import HealthCoordinator
from .legacy import update_legacy_battery_issue
from .routes import Routes
from .staleness import update_stale_sensors_issue
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
class IncorrectDataError(InvalidStateError):
"""Invalid exception."""
class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
"""Manage fetched data."""
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
"""Init global updater."""
self.hass = hass
self.config = config
self.windy = WindyPush(hass, config)
self.pocasi: PocasiPush = PocasiPush(hass, config)
super().__init__(hass, _LOGGER, name=DOMAIN)
async def recieved_data(self, webdata):
"""Handle incoming data query."""
_wslink = self.config_entry.options.get(WSLINK)
data = webdata.query
response = None
if not _wslink and ("ID" not in data or "PASSWORD" not in data):
_LOGGER.error("Invalid request. No security data provided!")
raise HTTPUnauthorized
if _wslink and ("wsid" not in data or "wspw" not in data):
_LOGGER.error("Invalid request. No security data provided!")
raise HTTPUnauthorized
if _wslink:
id_data = data["wsid"]
key_data = data["wspw"]
else:
id_data = data["ID"]
key_data = data["PASSWORD"]
_id = self.config_entry.options.get(API_ID)
_key = self.config_entry.options.get(API_KEY)
if id_data != _id or key_data != _key:
_LOGGER.error("Unauthorised access!")
raise HTTPUnauthorized
if self.config_entry.options.get(WINDY_ENABLED):
response = await self.windy.push_data_to_windy(data)
if self.config.options.get(POCASI_CZ_ENABLED):
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
remaped_items = (
remap_wslink_items(data)
if self.config_entry.options.get(WSLINK)
else remap_items(data)
)
if sensors := check_disabled(self.hass, remaped_items, self.config):
translate_sensors = [
await translations(
self.hass, DOMAIN, f"sensor.{t_key}", key="name", category="entity"
)
for t_key in sensors
if await translations(
self.hass, DOMAIN, f"sensor.{t_key}", key="name", category="entity"
)
is not None
]
human_readable = "\n".join(translate_sensors)
await translated_notification(
self.hass,
DOMAIN,
"added",
{"added_sensors": f"{human_readable}\n"},
)
if _loaded_sensors := loaded_sensors(self.config_entry):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config_entry, SENSORS_TO_LOAD, sensors)
# await self.hass.config_entries.async_reload(self.config.entry_id)
self.async_set_updated_data(remaped_items)
if self.config_entry.options.get(DEV_DBG):
_LOGGER.info("Dev log: %s", anonymize(data))
response = response or "OK"
return aiohttp.web.Response(body=f"{response or 'OK'}", status=200)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
def register_path(
hass: HomeAssistant,
url_path: str,
coordinator: WeatherDataUpdateCoordinator,
config: ConfigEntry,
):
"""Register path to handle incoming data."""
coordinator_h: HealthCoordinator,
config: SWSConfigEntry,
) -> bool:
"""Register webhook paths.
hass_data = hass.data.setdefault(DOMAIN, {})
debug = config.options.get(DEV_DBG)
_wslink = config.options.get(WSLINK, False)
We register both possible endpoints and use an internal dispatcher (`Routes`) to
enable exactly one of them. This lets us toggle WSLink mode without re-registering
routes on the aiohttp router.
"""
routes: Routes = hass_data.get("routes", Routes())
hass.data.setdefault(DOMAIN, {})
if (hass_data := checked(hass.data[DOMAIN], dict[str, Any])) is None:
raise ConfigEntryNotReady
if not routes.routes:
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
_legacy: bool = checked_or(config.options.get(LEGACY_ENABLED), bool, True)
# Load registred routes
routes: Routes | None = hass_data.get("routes", None)
if not isinstance(routes, Routes):
routes = Routes()
_LOGGER.info("Routes not found, creating new routes")
if debug:
_LOGGER.debug("Enabled route is: %s, WSLink is %s", url_path, _wslink)
routes.set_ingress_observer(coordinator_h.record_dispatch)
# Register webhooks in HomeAssistant with dispatcher
try:
default_route = hass.http.app.router.add_get(
DEFAULT_URL,
coordinator.recieved_data if not _wslink else unregistred,
name="weather_default_url",
)
if debug:
_LOGGER.debug("Default route: %s", default_route)
_default_route = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch, name="_default_route")
_wslink_post_route = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch, name="_wslink_post_route")
_wslink_get_route = hass.http.app.router.add_get(WSLINK_URL, routes.dispatch, name="_wslink_get_route")
_health_route = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch, name="_health_route")
wslink_route = hass.http.app.router.add_get(
WSLINK_URL,
coordinator.recieved_data if _wslink else unregistred,
name="weather_wslink_url",
)
if debug:
_LOGGER.debug("WSLink route: %s", wslink_route)
# Ecowitt URL contains {webhook_id} as a parameter.
# Station is configured to send data to: http://ha:8123/weatherhub/<webhook_id>
routes.add_route(
DEFAULT_URL,
default_route,
coordinator.recieved_data if not _wslink else unregistred,
not _wslink,
)
routes.add_route(
WSLINK_URL, wslink_route, coordinator.recieved_data, _wslink
)
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
_ecowitt_route = hass.http.app.router.add_post(_ecowitt_path, routes.dispatch, name="_ecowitt_route")
# Save initialised routes
hass_data["routes"] = routes
except RuntimeError as Ex: # pylint: disable=(broad-except)
if (
"Added route will never be executed, method GET is already registered"
in Ex.args
):
_LOGGER.info("Handler to URL (%s) already registred", url_path)
return False
except RuntimeError as Ex:
_LOGGER.critical("Routes cannot be added. Integration will not work as expected. %s", Ex)
raise ConfigEntryNotReady from Ex
_LOGGER.error("Unable to register URL handler! (%s)", Ex.args)
return False
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
routes.add_route(DEFAULT_URL, _default_route, coordinator.received_data, enabled=_legacy and not _wslink)
routes.add_route(WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_legacy and _wslink)
routes.add_route(WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_legacy and _wslink)
_LOGGER.info(
"Registered path to handle weather data: %s",
routes.get_enabled(), # pylint: disable=used-before-assignment
# Make health route `sticky` so it will not change upon updating options.
routes.add_route(
HEALTH_URL,
_health_route,
coordinator_h.health_status,
enabled=True,
sticky=True,
)
routes.add_route(
_ecowitt_path,
_ecowitt_route,
coordinator.received_ecowitt_data,
enabled=_ecowitt_enabled,
sticky=True,
)
if _wslink:
routes.switch_route(coordinator.recieved_data, WSLINK_URL)
else:
routes.switch_route(coordinator.recieved_data, DEFAULT_URL)
return routes
routes.set_ingress_observer(coordinator_h.record_dispatch)
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the config entry for my device."""
async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Set up a config entry.
Per-entry state is held on `entry.runtime_data`. Only the shared aiohttp route dispatcher
lives in `hass.data[DOMAIN]` because it must outlive a single entry reload. This separation is critical
to avoid issues where entities stop receiving updates after a reload because the coordinator instance they
are subscribed to is replaced in `hass.data[DOMAIN]` but the aiohttp route dispatcher still calls the old instance.
"""
hass.data.setdefault(DOMAIN, {})
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator_health = HealthCoordinator(hass, entry)
hass_data = hass.data.setdefault(DOMAIN, {})
hass_data[entry.entry_id] = coordinator
_wslink = entry.options.get(WSLINK)
debug = entry.options.get(DEV_DBG)
if debug:
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
route = register_path(
hass, DEFAULT_URL if not _wslink else WSLINK_URL, coordinator, entry
entry.runtime_data = SWSRuntimeData(
coordinator=coordinator,
health_coordinator=coordinator_health,
last_options=dict(entry.options),
)
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
_legacy = checked_or(entry.options.get(LEGACY_ENABLED), bool, True)
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
if not route:
_LOGGER.debug("WS Link is %s", "enabled" if _wslink else "disabled")
routes: Routes | None = hass.data[DOMAIN].get("routes")
if routes is not None:
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
routes.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL, enabled=_legacy)
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.received_ecowitt_data, _ecowitt_enabled)
# Rebind the sticky health route to the new coordinator so /station/health
# does not keep serving the previous (stale) HealthCoordinator after a reload.
routes.rebind_handler(HEALTH_URL, coordinator_health.health_status)
routes.set_ingress_observer(coordinator_health.record_dispatch)
coordinator_health.update_routing(routes)
_LOGGER.debug("%s", routes.show_enabled())
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")
hass_data["route"] = route
routes = hass.data[DOMAIN].get("routes")
if routes is not None:
coordinator_health.update_routing(routes)
await coordinator_health.async_config_entry_first_refresh()
coordinator_health.update_forwarding(coordinator.windy, coordinator.pocasi)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _check_stale(_now: datetime) -> None:
update_stale_sensors_issue(hass, entry)
entry.async_on_unload(async_track_time_interval(hass, _check_stale, timedelta(hours=1)))
entry.async_on_unload(entry.add_update_listener(update_listener))
update_legacy_battery_issue(hass, entry)
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Update setup listener."""
async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
"""Handle config entry option updates.
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:
- Reloading a push-based integration temporarily unloads platforms and removes
coordinator listeners, which can make the UI appear "stuck" until restart.
"""
runtime = getattr(entry, "runtime_data", None)
if isinstance(runtime, SWSRuntimeData):
old_options = runtime.last_options
new_options = dict(entry.options)
changed_keys = {k for k in set(old_options) | set(new_options) if old_options.get(k) != new_options.get(k)}
runtime.last_options = new_options
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)
await hass.config_entries.async_reload(entry.entry_id)
_LOGGER.info("Settings updated")
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
async def async_unload_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Unload a config entry.
_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if _ok:
hass.data[DOMAIN].pop(entry.entry_id)
`entry.runtime_data` becomes irrelevant once entry is unloaded.
On next `async_setup_entry` we overwrite it. The shared `hass.data[DOMAIN]["routes"]` survives by design.
aiohttp routes stay registered and the dispatcher is re-wired on the next setup.
"""
return _ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,65 @@
"""Battery binary sensor entities for SWS 12500.
Expose low-batter warnings as binary sensors.
"""
from __future__ import annotations
from functools import cached_property
from typing import Any
from py_typecheck import checked_or
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .data import build_device_info
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, BinarySensorEntity
):
"""Represent a low-battery binary sensor.
Station payload uses:
- ``0`` => low battery (binary sensor is ``on``)
- ``1`` => battery OK (binary sensor is ``off``)
"""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: Any,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize the battery binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{description.key}_binary"
@property
def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return low-battery state.
``True`` means low battery for ``BinarySensorDeviceClass.BATTERY``.
"""
data = checked_or(self.coordinator.data, dict[str, Any], {})
raw: Any = data.get(self.entity_description.key)
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
return value == 0
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -0,0 +1,20 @@
"""Battery sensors templates.
We create a sensor tempate here.
Actualy loaded senors are gated in coordinator.
"""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
from .const import BATTERY_LIST
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = tuple(
BinarySensorEntityDescription(
key=key,
translation_key=key,
device_class=BinarySensorDeviceClass.BATTERY,
)
for key in BATTERY_LIST
)

View File

@ -0,0 +1,83 @@
"""Binary sensor platform for SWS12500.
Exposes low-battery warnings as binary sensors.
Auto-discovery adds entities without reloading the entry, using callbacks stored on `runtime_data`.
"""
from __future__ import annotations
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .battery_sensors import BatteryBinarySensor
from .battery_sensors_def import BATTERY_BINARY_SENSORS
from .const import SENSORS_TO_LOAD
from .data import SWSConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: SWSConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up battery binary sensors."""
del hass
runtime = entry.runtime_data
coordinator = runtime.coordinator
# Persist platform callback + description map for dynamic entity creation.
runtime.add_binary_entities = async_add_entities
runtime.binary_descriptions = {desc.key: desc for desc in BATTERY_BINARY_SENSORS}
runtime.added_binary_keys = set()
# Initial entities for battery keys that station already reports.
# `SENSORS_TO_LOAD` accumulates all discovered keys across runs.
loaded = set(entry.options.get(SENSORS_TO_LOAD, []))
entities: list[BatteryBinarySensor] = []
for desc in BATTERY_BINARY_SENSORS:
if desc.key in loaded:
entities.append(BatteryBinarySensor(coordinator, desc))
runtime.added_binary_keys.add(desc.key)
if entities:
async_add_entities(entities)
def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: list[str]) -> None:
"""Dynamic add newly discovered binary sensors without reloading the entry.
Called from webhook handler in __init__.py.
Safe no-op if the platform hasn't finished setting up yet (e.g. callback/description map missing).
Duplicate keys are ignored (only keys with an entity description that haven't been added yet are added).
"""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = getattr(entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_binary_entities
if add_entities is None:
return
descriptions = runtime.binary_descriptions
coordinator = runtime.coordinator
added = runtime.added_binary_keys
new_entities: list[BinarySensorEntity] = []
for key in keys:
if key in added:
continue
if (desc := descriptions.get(key)) is None:
continue
new_entities.append(BatteryBinarySensor(coordinator, desc))
added.add(key)
if new_entities:
add_entities(new_entities)

View File

@ -1,19 +1,28 @@
"""Config flow for Sencor SWS 12500 Weather Station integration."""
from __future__ import annotations
import secrets
from typing import Any
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
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 (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
INVALID_CREDENTIALS,
LEGACY_ENABLED,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
@ -21,12 +30,17 @@ from .const import (
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM,
SENSORS_TO_LOAD,
WINDY_API_KEY,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WSLINK,
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."""
@ -51,26 +65,28 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.migrate_schema = {}
self.pocasi_cz: dict[str, Any] = {}
self.pocasi_cz_schema = {}
@property
def config_entry(self):
return self.hass.config_entries.async_get_entry(self.handler)
self.ecowitt: dict[str, Any] = {}
self.ecowitt_schema = {}
self.wslink_addon_port: dict[str, int] = {}
self.wslink_addod_schema = {}
async def _get_entry_data(self):
"""Get entry data."""
self.user_data = {
API_ID: self.config_entry.options.get(API_ID),
API_KEY: self.config_entry.options.get(API_KEY),
API_ID: self.config_entry.options.get(API_ID, ""),
API_KEY: self.config_entry.options.get(API_KEY, ""),
LEGACY_ENABLED: self.config_entry.options.get(LEGACY_ENABLED, True),
WSLINK: self.config_entry.options.get(WSLINK, False),
DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
}
self.user_data_schema = {
vol.Required(API_ID, default=self.user_data.get(API_ID, "")): str,
vol.Required(API_KEY, default=self.user_data.get(API_KEY, "")): str,
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): _PASSWORD_SELECTOR,
vol.Optional(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,
}
self.sensors = {
@ -82,66 +98,69 @@ class ConfigOptionsFlowHandler(OptionsFlow):
}
self.windy_data = {
WINDY_API_KEY: self.config_entry.options.get(WINDY_API_KEY),
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
WINDY_LOGGER_ENABLED: self.config_entry.options.get(WINDY_LOGGER_ENABLED, False),
WINDY_ENABLED: self.config_entry.options.get(WINDY_ENABLED, False),
WINDY_LOGGER_ENABLED: self.config_entry.options.get(
WINDY_LOGGER_ENABLED, False
),
}
self.windy_data_schema = {
vol.Optional(WINDY_STATION_ID, default=self.windy_data.get(WINDY_STATION_ID, "")): str,
vol.Optional(
WINDY_API_KEY, default=self.windy_data.get(WINDY_API_KEY, "")
): str,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool
or False,
WINDY_STATION_PW,
default=self.windy_data.get(WINDY_STATION_PW, ""),
): _PASSWORD_SELECTOR,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
vol.Optional(
WINDY_LOGGER_ENABLED,
default=self.windy_data[WINDY_LOGGER_ENABLED],
): bool or False,
): bool,
}
self.pocasi_cz = {
POCASI_CZ_API_ID: self.config_entry.options.get(POCASI_CZ_API_ID, ""),
POCASI_CZ_API_KEY: self.config_entry.options.get(POCASI_CZ_API_KEY, ""),
POCASI_CZ_ENABLED: self.config_entry.options.get(POCASI_CZ_ENABLED, False),
POCASI_CZ_LOGGER_ENABLED: self.config_entry.options.get(
POCASI_CZ_LOGGER_ENABLED, False
),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(
POCASI_CZ_SEND_INTERVAL, 30
),
POCASI_CZ_LOGGER_ENABLED: self.config_entry.options.get(POCASI_CZ_LOGGER_ENABLED, False),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(POCASI_CZ_SEND_INTERVAL, 30),
}
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_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): _PASSWORD_SELECTOR,
vol.Required(
POCASI_CZ_SEND_INTERVAL,
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
): int,
vol.Optional(
POCASI_CZ_ENABLED, default=self.pocasi_cz.get(POCASI_CZ_ENABLED)
): bool,
vol.Optional(POCASI_CZ_ENABLED, default=self.pocasi_cz.get(POCASI_CZ_ENABLED)): bool,
vol.Optional(
POCASI_CZ_LOGGER_ENABLED,
default=self.pocasi_cz.get(POCASI_CZ_LOGGER_ENABLED),
): bool,
}
async def async_step_init(self, user_input=None):
self.ecowitt = {
ECOWITT_WEBHOOK_ID: self.config_entry.options.get(ECOWITT_WEBHOOK_ID, ""),
ECOWITT_ENABLED: self.config_entry.options.get(ECOWITT_ENABLED, False),
}
self.wslink_addon_port = {WSLINK_ADDON_PORT: self.config_entry.options.get(WSLINK_ADDON_PORT, 443)}
async def async_step_init(self, user_input: dict[str, Any] | None = None):
"""Manage the options - show menu first."""
_ = user_input
return self.async_show_menu(
step_id="init", menu_options=["basic", "windy", "pocasi"]
step_id="init", menu_options=["basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"]
)
async def async_step_basic(self, user_input=None):
"""Manage basic options - credentials."""
errors = {}
async def async_step_basic(self, user_input: Any = None):
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credantials empty.
"""
errors: dict[str, str] = {}
await self._get_entry_data()
@ -152,22 +171,16 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
if user_input[API_ID] in INVALID_CREDENTIALS:
if user_input.get(LEGACY_ENABLED):
if user_input[API_ID] in INVALID_CREDENTIALS or user_input.get(API_ID, "") == "":
errors[API_ID] = "valid_credentials_api"
elif user_input[API_KEY] in INVALID_CREDENTIALS:
elif user_input[API_KEY] in INVALID_CREDENTIALS or user_input.get(API_KEY, "") == "":
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
else:
# retain windy data
user_input.update(self.windy_data)
# retain sensors
user_input.update(self.sensors)
# retain pocasi data
user_input.update(self.pocasi_cz)
if not errors:
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
self.user_data = user_input
@ -179,9 +192,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
async def async_step_windy(self, user_input=None):
async def async_step_windy(self, user_input: Any = None):
"""Manage windy options."""
errors = {}
errors: dict[str, str] = {}
await self._get_entry_data()
@ -192,30 +205,24 @@ class ConfigOptionsFlowHandler(OptionsFlow):
errors=errors,
)
if (user_input[WINDY_ENABLED] is True) and (user_input[WINDY_API_KEY] == ""):
errors[WINDY_API_KEY] = "windy_key_required"
if (user_input[WINDY_ENABLED] is True) and (
(user_input[WINDY_STATION_ID] == "") or (user_input[WINDY_STATION_PW] == "")
):
errors[WINDY_STATION_ID] = "windy_key_required"
return self.async_show_form(
step_id="windy",
data_schema=vol.Schema(self.windy_data_schema),
errors=errors,
)
# retain user_data
user_input.update(self.user_data)
# retain senors
user_input.update(self.sensors)
# retain pocasi cz
user_input.update(self.pocasi_cz)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_pocasi(self, user_input: Any = None) -> ConfigFlowResult:
"""Handle the pocasi step."""
errors = {}
errors: dict[str, str] = {}
await self._get_entry_data()
@ -241,42 +248,117 @@ class ConfigOptionsFlowHandler(OptionsFlow):
data_schema=vol.Schema(self.pocasi_cz_schema),
errors=errors,
)
# retain user data
user_input.update(self.user_data)
# retain senors
user_input.update(self.sensors)
# retain windy
user_input.update(self.windy_data)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
errors: dict[str, str] = {}
await self._get_entry_data()
if not (webhook := self.ecowitt.get(ECOWITT_WEBHOOK_ID)):
webhook = secrets.token_hex(8)
if user_input is None:
url: URL = URL(get_url(self.hass))
if not url.host:
url.host = "UNKNOWN"
ecowitt_schema = {
vol.Required(
ECOWITT_WEBHOOK_ID,
default=webhook,
): str,
vol.Optional(
ECOWITT_ENABLED,
default=self.ecowitt.get(ECOWITT_ENABLED, False),
): bool,
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": url.host,
"port": str(url.port),
"webhook_id": webhook,
},
errors=errors,
)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_wslink_port_setup(self, user_input: Any = None) -> ConfigFlowResult:
"""WSLink Addon port setup."""
errors: dict[str, str] = {}
await self._get_entry_data()
if not (port := self.wslink_addon_port.get(WSLINK_ADDON_PORT)):
port = 443
wslink_port_schema = {
vol.Required(WSLINK_ADDON_PORT, default=port): int,
}
if user_input is None:
return self.async_show_form(
step_id="wslink_port_setup",
data_schema=vol.Schema(wslink_port_schema),
errors=errors,
)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Retain user_data."""
return {
**self.user_data,
**self.windy_data,
**self.pocasi_cz,
**self.sensors,
**self.ecowitt,
**self.wslink_addon_port,
**dict(data),
}
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
data_schema = {
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,
}
VERSION = 1
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input: Any = None):
"""Handle the initial step."""
if user_input is None:
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_show_form(
return self.async_show_menu(
step_id="user",
data_schema=vol.Schema(self.data_schema),
menu_options=["pws", "ecowitt"],
)
errors = {}
async def async_step_pws(self, user_input: Any = None) -> ConfigFlowResult:
"""PWS/WSLink credentials setup."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(step_id="pws", data_schema=vol.Schema(self.pws_schema), errors=errors)
if user_input[API_ID] in INVALID_CREDENTIALS:
errors[API_ID] = "valid_credentials_api"
@ -285,18 +367,53 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
else:
return self.async_create_entry(
title=DOMAIN, data=user_input, options=user_input
)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: True,
ECOWITT_ENABLED: False,
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(self.data_schema),
step_id="pws",
data_schema=vol.Schema(self.pws_schema),
errors=errors,
)
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
if user_input is None:
webhook = secrets.token_hex(8)
url: URL = URL(get_url(self.hass))
host = url.host or "UNKNOWN"
ecowitt_schema = {
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
vol.Optional(ECOWITT_ENABLED, default=True): bool,
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": str(url.port),
"webhook_id": webhook,
},
)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: False,
WSLINK: False,
API_ID: "",
API_KEY: "",
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
@staticmethod
@callback
def async_get_options_flow(config_entry) -> ConfigOptionsFlowHandler:
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
"""Get the options flow for this handler."""
return ConfigOptionsFlowHandler()

View File

@ -1,52 +1,20 @@
"""Constants."""
from __future__ import annotations
from enum import StrEnum
from typing import Final
# Integration specific constants.
DOMAIN = "sws12500"
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/pws/update/"
DATABASE_PATH = "/config/home-assistant_v2.db"
DEV_DBG: Final = "dev_debug_checkbox"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
ICON = "mdi:weather"
# Common constants
API_KEY = "API_KEY"
API_ID = "API_ID"
SENSORS_TO_LOAD: Final = "sensors_to_load"
SENSOR_TO_MIGRATE: Final = "sensor_to_migrate"
DEV_DBG: Final = "dev_debug_checkbox"
WSLINK: Final = "wslink"
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = (
"Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
)
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = (
"Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
)
WINDY_API_KEY = "WINDY_API_KEY"
WINDY_ENABLED: Final = "windy_enabled_checkbox"
WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox"
WINDY_NOT_INSERTED: Final = "Data was succefuly sent to Windy, but not inserted by Windy API. Does anyone else sent data to Windy?"
WINDY_INVALID_KEY: Final = "Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again."
WINDY_SUCCESS: Final = (
"Windy successfully sent data and data was successfully inserted by Windy API"
)
WINDY_UNEXPECTED: Final = (
"Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
)
INVALID_CREDENTIALS: Final = [
"API",
@ -60,27 +28,8 @@ INVALID_CREDENTIALS: Final = [
"_KEY",
]
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
]
PURGE_DATA_POCAS: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
]
# Sensor constants
BARO_PRESSURE: Final = "baro_pressure"
OUTSIDE_TEMP: Final = "outside_temp"
DEW_POINT: Final = "dew_point"
@ -109,14 +58,88 @@ CH2_BATTERY: Final = "ch2_battery"
CH3_TEMP: Final = "ch3_temp"
CH3_HUMIDITY: Final = "ch3_humidity"
CH3_CONNECTION: Final = "ch3_connection"
CH3_BATTERY: Final = "ch3_battery"
CH4_TEMP: Final = "ch4_temp"
CH4_HUMIDITY: Final = "ch4_humidity"
CH4_CONNECTION: Final = "ch4_connection"
CH4_BATTERY: Final = "ch4_battery"
CH5_TEMP: Final = "ch5_temp"
CH5_HUMIDITY: Final = "ch5_humidity"
CH5_CONNECTION: Final = "ch5_connection"
CH5_BATTERY: Final = "ch5_battery"
CH6_TEMP: Final = "ch6_temp"
CH6_HUMIDITY: Final = "ch6_humidity"
CH6_CONNECTION: Final = "ch6_connection"
CH6_BATTERY: Final = "ch6_battery"
CH7_TEMP: Final = "ch7_temp"
CH7_HUMIDITY: Final = "ch7_humidity"
CH7_CONNECTION: Final = "ch7_connection"
CH7_BATTERY: Final = "ch7_battery"
CH8_TEMP: Final = "ch8_temp"
CH8_HUMIDITY: Final = "ch8_humidity"
CH8_CONNECTION: Final = "ch8_connection"
CH8_BATTERY: Final = "ch8_battery"
HEAT_INDEX: Final = "heat_index"
CHILL_INDEX: Final = "chill_index"
WBGT_TEMP: Final = "wbgt_temp"
HCHO: Final = "hcho"
VOC: Final = "voc"
T9_BATTERY: Final = "t9_battery" # T9 sensors are HCHO and VOC
T9_CONN: Final = "t9_conn"
# Health specific constants
HEALTH_URL = "/station/health"
# PWS specific constants
DEFAULT_URL = "/weatherstation/updateweatherstation.php"
PURGE_DATA: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
"realtime",
"dateutc",
"solarradiation",
"indoortempf",
"indoorhumidity",
"dailyrainin",
]
"""NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
I have no option to test, if it will work correctly. So their implementatnion will be in future releases.
leafwetness - [%]
+ for sensor 2 use leafwetness2
visibility - [nm visibility]
pweather - [text] -- metar style (+RA)
clouds - [text] -- SKC, FEW, SCT, BKN, OVC
Pollution Fields:
AqNO - [ NO (nitric oxide) ppb ]
AqNO2T - (nitrogen dioxide), true measure ppb
AqNO2 - NO2 computed, NOx-NO ppb
AqNO2Y - NO2 computed, NOy-NO ppb
AqNOX - NOx (nitrogen oxides) - ppb
AqNOY - NOy (total reactive nitrogen) - ppb
AqNO3 - NO3 ion (nitrate, not adjusted for ammonium ion) UG/M3
AqSO4 - SO4 ion (sulfate, not adjusted for ammonium ion) UG/M3
AqSO2 - (sulfur dioxide), conventional ppb
AqSO2T - trace levels ppb
AqCO - CO (carbon monoxide), conventional ppm
AqCOT -CO trace levels ppb
AqEC - EC (elemental carbon) PM2.5 UG/M3
AqOC - OC (organic carbon, not adjusted for oxygen and hydrogen) PM2.5 UG/M3
AqBC - BC (black carbon at 880 nm) UG/M3
AqUV-AETH - UV-AETH (second channel of Aethalometer at 370 nm) UG/M3
AqPM2.5 - PM2.5 mass - UG/M3
AqPM10 - PM10 mass - PM10 mass
AqOZONE - Ozone - ppb
"""
REMAP_ITEMS: dict[str, str] = {
"baromin": BARO_PRESSURE,
"tempf": OUTSIDE_TEMP,
@ -137,8 +160,92 @@ REMAP_ITEMS: dict[str, str] = {
"soilmoisture2": CH3_HUMIDITY,
"soiltemp3f": CH4_TEMP,
"soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY,
"soiltemp5f": CH6_TEMP,
"soilmoisture5": CH6_HUMIDITY,
}
WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
WSLINK: Final = "wslink"
LEGACY_ENABLED: Final = "legacy_enabled"
WINDY_MAX_RETRIES: Final = 3
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
ECOWITT: Final = "ecowitt"
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
ECOWITT_ENABLED: Final = "ecowitt_enabled"
ECOWITT_URL: Final = "/weather/ecowitt"
ECOWITT_URL_PREFIX: Final = "/weatherhub"
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
REMAP_ECOWITT_COMPAT: dict[str, str] = {
"tempf": OUTSIDE_TEMP,
"humidity": OUTSIDE_HUMIDITY,
"dewpointf": DEW_POINT,
"windspeedmph": WIND_SPEED,
"windgustmph": WIND_GUST,
"winddir": WIND_DIR,
"dailyrainin": DAILY_RAIN,
"solarradiation": SOLAR_RADIATION,
"tempinf": INDOOR_TEMP,
"humidityin": INDOOR_HUMIDITY,
"uv": UV,
"baromrelin": BARO_PRESSURE,
"temp1f": CH2_TEMP,
"humidity1": CH2_HUMIDITY,
"temp2f": CH3_TEMP,
"humidity2": CH3_HUMIDITY,
"temp3f": CH4_TEMP,
"humidity3": CH4_HUMIDITY,
"temp4f": CH5_TEMP,
"humidity4": CH5_HUMIDITY,
"temp5f": CH6_TEMP,
"humidity5": CH6_HUMIDITY,
"temp6f": CH7_TEMP,
"humidity6": CH7_HUMIDITY,
"temp7f": CH8_TEMP,
"humidity7": CH8_HUMIDITY,
}
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox"
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
WINDY_STATION_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD"
WINDY_ENABLED: Final = "windy_enabled_checkbox"
WINDY_LOGGER_ENABLED: Final = "windy_logger_checkbox"
WINDY_NOT_INSERTED: Final = "Windy responded with 400 error. Invalid ID/password combination?"
WINDY_INVALID_KEY: Final = (
"Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again."
)
WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully inserted by Windy API"
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
PURGE_DATA_POCAS: Final = [
"ID",
"PASSWORD",
"action",
"rtfreq",
]
REMAP_WSLINK_ITEMS: dict[str, str] = {
"intem": INDOOR_TEMP,
"inhum": INDOOR_HUMIDITY,
@ -155,9 +262,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1uvi": UV,
"t234c1tem": CH2_TEMP,
"t234c1hum": CH2_HUMIDITY,
"t1cn": OUTSIDE_CONNECTION,
"t234c1cn": CH2_CONNECTION,
"t234c2cn": CH3_CONNECTION,
# NOTE: connection flags (t1cn / t234cXcn / t9cn) are intentionally NOT remapped.
# They are used only as gating inputs (see CONNECTION_GATED_SENSORS), which read the
# raw payload keys. Remapping them used to leak ghost "*_connection" keys (with no
# entity) into the coordinator data and into persisted SENSORS_TO_LOAD.
"t1chill": CHILL_INDEX,
"t1heat": HEAT_INDEX,
"t1rainhr": HOURLY_RAIN,
@ -166,17 +274,89 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1rainyr": YEARLY_RAIN,
"t234c2tem": CH3_TEMP,
"t234c2hum": CH3_HUMIDITY,
"t234c3tem": CH4_TEMP,
"t234c3hum": CH4_HUMIDITY,
"t234c4tem": CH5_TEMP,
"t234c4hum": CH5_HUMIDITY,
"t234c5tem": CH6_TEMP,
"t234c5hum": CH6_HUMIDITY,
"t234c6tem": CH7_TEMP,
"t234c6hum": CH7_HUMIDITY,
"t234c7tem": CH8_TEMP,
"t234c7hum": CH8_HUMIDITY,
"t1bat": OUTSIDE_BATTERY,
"inbat": INDOOR_BATTERY,
"t234c1bat": CH2_BATTERY,
"t234c2bat": CH3_BATTERY,
"t234c3bat": CH4_BATTERY,
"t234c4bat": CH5_BATTERY,
"t234c5bat": CH6_BATTERY,
"t234c6bat": CH7_BATTERY,
"t234c7bat": CH8_BATTERY,
"t1wbgt": WBGT_TEMP,
"t9hcho": HCHO,
"t9voclv": VOC,
"t9bat": T9_BATTERY, # T9 battery is 0-5, where 5 is full
}
# TODO: Add more sensors
# NOTE: Add more sensors
#
# 'inbat' indoor battery level (1 normal, 0 low)
# 't1bat': outdoor battery level (1 normal, 0 low)
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLin
#
# In the following there are sensors that should be available by WSLink.
# We need to compare them to PWS API to make sure, we have the same intarnal
# representation of same sensors.
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
# &t5lst= Last Lightning strike time integer
# &t5lskm= Lightning distance integer km
# &t5lsf= Lightning strike count last 1 Hours integer
# &t5ls5mtc= Lightning count total of during 5 minutes integer
# &t5ls30mtc= Lightning count total of during 30 minutes integer
# &t5ls1htc= Lightning count total of during 1 Hour integer
# &t5ls1dtc= Lightning count total of during 1 day integer
# &t5lsbat= Lightning Sensor battery (Normal=1, Low battery=0) integer
# &t5lscn= Lightning Sensor connection (Connected=1, No connect=0) integer
# &t6c1wls= Water leak sensor CH1 (Leak=1, No leak=0) integer
# &t6c1bat= Water leak sensor CH1 battery (Normal=1, Low battery=0) integer
# &t6c1cn= Water leak sensor CH1 connection (Connected=1, No connect=0) integer
# &t6c2wls= Water leak sensor CH2 (Leak=1, No leak=0) integer
# &t6c2bat= Water leak sensor CH2 battery (Normal=1, Low battery=0) integer
# &t6c2cn= Water leak sensor CH2 connection (Connected=1, No connect=0) integer
# &t6c3wls= Water leak sensor CH3 (Leak=1, No leak=0) integer
# &t6c3bat= Water leak sensor CH3 battery (Normal=1, Low battery=0) integer
# &t6c3cn= Water leak sensor CH3 connection (Connected=1, No connect=0) integer
# &t6c4wls= Water leak sensor CH4 (Leak=1, No leak=0) integer
# &t6c4bat= Water leak sensor CH4 battery (Normal=1, Low battery=0) integer
# &t6c4cn= Water leak sensor CH4 connection (Connected=1, No connect=0) integer
# &t6c5wls= Water leak sensor CH5 (Leak=1, No leak=0) integer
# &t6c5bat= Water leak sensor CH5 battery (Normal=1, Low battery=0) integer
# &t6c5cn= Water leak sensor CH5 connection (Connected=1, No connect=0) integer
# &t6c6wls= Water leak sensor CH6 (Leak=1, No leak=0) integer
# &t6c6bat= Water leak sensor CH6 battery (Normal=1, Low battery=0) integer
# &t6c6cn= Water leak sensor CH6 connection (Connected=1, No connect=0) integer
# &t6c7wls= Water leak sensor CH7 (Leak=1, No leak=0) integer
# &t6c7bat= Water leak sensor CH7 battery (Normal=1, Low battery=0) integer
# &t6c7cn= Water leak sensor CH7 connection (Connected=1, No connect=0) integer
# &t8pm25= PM2.5 concentration integer ug/m3
# &t8pm10= PM10 concentration integer ug/m3
# &t8pm25ai= PM2.5 AQI integer
# &t8pm10ai = PM10 AQI integer
# &t8bat= PM sensor battery level (0~5) remark: 5 is full integer
# &t8cn= PM sensor connection (Connected=1, No connect=0) integer
# &t9hcho= HCHO concentration integer ppb
# &t9voclv= VOC level (1~5) 1 is the highest level, 5 is the lowest VOC level integer
# &t9bat= HCHO / VOC sensor battery level (0~5) remark: 5 is full integer
# &t9cn= HCHO / VOC sensor connection (Connected=1, No connect=0) integer
# &t10co2= CO2 concentration integer ppm
# &t10bat= CO2 sensor battery level (0~5) remark: 5 is full integer
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
# &t11co= CO concentration integer ppm
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
# &t11cn= CO sensor connection (Connected=1, No connect=0) integero
#
DISABLED_BY_DEFAULT: Final = [
@ -185,17 +365,73 @@ DISABLED_BY_DEFAULT: Final = [
CH2_BATTERY,
CH3_TEMP,
CH3_HUMIDITY,
CH3_BATTERY,
CH4_TEMP,
CH4_HUMIDITY,
CH4_BATTERY,
CH5_TEMP,
CH5_HUMIDITY,
CH5_BATTERY,
CH6_TEMP,
CH6_HUMIDITY,
CH6_BATTERY,
CH7_TEMP,
CH7_HUMIDITY,
CH7_BATTERY,
CH8_TEMP,
CH8_HUMIDITY,
CH8_BATTERY,
OUTSIDE_BATTERY,
WBGT_TEMP,
]
BATTERY_LIST = [
# Station reports batteries as 0/1 (low/normal) for most of sensors.
# Batteries reported as 0-5 level are stored in `BATTERY_NON_BINARY` tuple
BATTERY_LIST: Final[tuple[str, ...]] = (
OUTSIDE_BATTERY,
INDOOR_BATTERY,
CH2_BATTERY,
]
CH3_BATTERY,
CH4_BATTERY,
CH5_BATTERY,
CH6_BATTERY,
CH7_BATTERY,
CH8_BATTERY,
)
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,)
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
# Multi-channel temp/humidity probes (CH2 - CH8)
"t234c1cn": [CH2_TEMP, CH2_HUMIDITY, CH2_BATTERY],
"t234c2cn": [CH3_TEMP, CH3_HUMIDITY, CH3_BATTERY],
"t234c3cn": [CH4_TEMP, CH4_HUMIDITY, CH4_BATTERY],
"t234c4cn": [CH5_TEMP, CH5_HUMIDITY, CH5_BATTERY],
"t234c5cn": [CH6_TEMP, CH6_HUMIDITY, CH6_BATTERY],
"t234c6cn": [CH7_TEMP, CH7_HUMIDITY, CH7_BATTERY],
"t234c7cn": [CH8_TEMP, CH8_HUMIDITY, CH8_BATTERY],
# T9 HCHO/VOC probe
"t9cn": [HCHO, VOC, T9_BATTERY],
}
class VOCLevel(StrEnum):
"""WSLink VOC Level 1-5 (1-worst)."""
UNHEALTHY = "unhealthy"
POOR = "poor"
MODERATE = "moderate"
GOOD = "good"
EXCELLENT = "excellent"
VOC_LEVEL_MAP: dict[int, VOCLevel] = {
1: VOCLevel.UNHEALTHY,
2: VOCLevel.POOR,
3: VOCLevel.MODERATE,
4: VOCLevel.GOOD,
5: VOCLevel.EXCELLENT,
}
class UnitOfDir(StrEnum):
@ -244,7 +480,7 @@ class UnitOfBat(StrEnum):
LOW = "low"
NORMAL = "normal"
UNKNOWN = "unknown"
UNKNOWN = "drained"
BATTERY_LEVEL: list[UnitOfBat] = [

View File

@ -0,0 +1,341 @@
"""Push coordinator for the SWS-12500 weather station integration.
This module is the runtime heart of the integration:
- `WeatherDataUpdateCoordinator` is a fan-out hub for push payloads.
Webhook handlers call `async_set_updated_data(...)` and every CoordinatorEntity
subscribed to the coordinator updates its state.
- `received_data` handles the legacy PWS / WSLink endpoints.
- `received_ecowitt_data` handles the Ecowitt endpoint via the aioecowitt parser.
- `IncorrectDataError` is raised when the integration's auth options are missing.
Kept separate from `__init__.py` so the platforms (sensor / binary_sensor) can
import `WeatherDataUpdateCoordinator` without re-entering the package's
initialization machinery no more local-import `# noqa: PLC0415` shims.
"""
from __future__ import annotations
import hmac
import logging
from typing import Any
import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import InvalidStateError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .binary_sensor import add_new_binary_sensors
from .const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
)
from .data import SWSConfigEntry
from .ecowitt import EcowittBridge
from .health_coordinator import HealthCoordinator
from .pocasti_cz import PocasiPush
from .sensor import add_new_sensors
from .staleness import update_stale_sensors_issue
from .utils import (
anonymize,
check_disabled,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
)
from .windy_func import WindyPush
_LOGGER = logging.getLogger(__name__)
class IncorrectDataError(InvalidStateError):
"""Raised when the integration's auth options are missing or invalid."""
class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator for push updates.
Even though Home Assistant's `DataUpdateCoordinator` is often used for polling,
it also works well as a fan-out mechanism for push integrations:
- webhook handler updates `self.data` via `async_set_updated_data`
- all `CoordinatorEntity` instances subscribed update themselves
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize the coordinator."""
self.hass: HomeAssistant = hass
self.config: SWSConfigEntry = config
self.windy: WindyPush = WindyPush(hass, config)
self.pocasi: PocasiPush = PocasiPush(hass, config)
self.ecowitt_bridge: EcowittBridge = EcowittBridge(hass, config)
super().__init__(hass, _LOGGER, config_entry=config, name=DOMAIN)
def _health_coordinator(self) -> HealthCoordinator | None:
"""Return the health coordinator for this config entry."""
try:
return self.config.runtime_data.health_coordinator
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.
Uses aioecowitt for payload parsing. Sensors with internal mapping
join the SWS pipeline; sensors without mapping create native Ecowitt
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):
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=None,
reason="ecowitt_disabled",
)
return aiohttp.web.Response(text="Ecowitt disabled", status=403)
expected_webhook = self.config.options.get(ECOWITT_WEBHOOK_ID, "")
actual_webhook = webdata.match_info.get("webhook_id", "")
# Constant-time comparison to avoid leaking the webhook id via timing.
if not expected_webhook or not hmac.compare_digest(
actual_webhook.encode("utf-8"), expected_webhook.encode("utf-8")
):
_LOGGER.error("Ecowitt: invalid webhook ID")
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=False,
reason="ecowitt_invalid_webhook_id",
)
raise HTTPUnauthorized
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:
if sensors := check_disabled(mapped_data, self.config):
newly_discovered = list(sensors)
if _loaded_sensors := loaded_sensors(self.config):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
add_new_binary_sensors(self.hass, self.config, newly_discovered)
add_new_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(mapped_data)
now = dt_util.utcnow()
for key in mapped_data:
self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health:
health.update_ingress_result(
webdata,
accepted=True,
authorized=True,
reason="accepted",
)
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, False)
# TODO: create ecowitt protocol to send full payload to Pocasi CZ
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WU")
if health:
health.update_forwarding(self.windy, self.pocasi)
if checked_or(self.config.options.get(DEV_DBG), bool, False):
_LOGGER.info("Dev log (ecowitt): %s", anonymize(data))
return aiohttp.web.Response(body="OK", status=200)
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
"""Handle incoming webhook payload from the station.
- validates authentication (different keys for WU vs WSLink)
- optionally forwards data to third-party services (Windy / Pocasi)
- remaps payload keys to internal sensor keys
- auto-discovers new sensor fields and adds entities dynamically
- 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)
get_data = webdata.query
post_data = await webdata.post()
data: dict[str, Any] = {**dict(get_data), **dict(post_data)}
health = self._health_coordinator()
self._validate_credentials(data, webdata, wslink=_wslink, health=health)
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
if sensors := check_disabled(remaped_items, self.config):
# Resolve each sensor's display name once (the previous comprehension
# awaited translations() twice per key).
translated_sensors: list[str] = []
for t_key in sensors:
name = await translations(
self.hass,
DOMAIN,
f"sensor.{t_key}",
key="name",
category="entity",
)
if name is not None:
translated_sensors.append(name)
human_readable = "\n".join(translated_sensors)
await translated_notification(
self.hass,
DOMAIN,
"added",
{"added_sensors": f"{human_readable}\n"},
)
newly_discovered = list(sensors)
if _loaded_sensors := loaded_sensors(self.config):
sensors.extend(_loaded_sensors)
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
# Dynamic adds avoid the listener-drop window of a full reload.
add_new_sensors(self.hass, self.config, newly_discovered)
add_new_binary_sensors(self.hass, self.config, newly_discovered)
self.async_set_updated_data(remaped_items)
now = dt_util.utcnow()
for key in remaped_items:
self.config.runtime_data.last_seen[key] = now
update_stale_sensors_issue(self.hass, self.config)
if health:
health.update_ingress_result(
webdata,
accepted=True,
authorized=True,
reason="accepted",
)
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, _wslink)
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
if health:
health.update_forwarding(self.windy, self.pocasi)
if checked_or(self.config.options.get(DEV_DBG), bool, False):
_LOGGER.info("Dev log: %s", anonymize(data))
return aiohttp.web.Response(body="OK", status=200)

View File

@ -0,0 +1,94 @@
"""Shared keys for storing integration runtime data.
HA 2025+ pattern: typed `ConfigEntry[SWSRuntimeData]` replaces ad-hoc `hass.data[DOMAIN][entry_id]` dicts.
All per-entry state lives here. Cross-reload shared state (aiohttp route registrations) stays under
hass.data[DOMAIN]["routes"] because it must outlive a single entry reload.
"""
from __future__ import annotations
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
from .coordinator import WeatherDataUpdateCoordinator
from .health_coordinator import HealthCoordinator
from .sensors_common import WeatherSensorEntityDescription
@dataclass
class SWSRuntimeData:
"""Per-entry runtime state for SWS12500 integration.
Stored in entry.runtime_data. Type-safe.
"""
# Core coordinators - required - create during `async_setup_entry`.
coordinator: WeatherDataUpdateCoordinator
health_coordinator: HealthCoordinator
last_options: dict[str, Any]
# Sensor platform callbacks (set by sensor.async_setup_entry)
add_sensor_entities: AddEntitiesCallback | None = None
sensor_descriptions: dict[str, WeatherSensorEntityDescription] = field(default_factory=dict)
# Binary sensor platform callbacks
add_binary_entities: AddEntitiesCallback | None = None
binary_descriptions: dict[str, BinarySensorEntityDescription] = field(default_factory=dict)
added_binary_keys: set[str] = field(default_factory=set)
# Health data cache for diagnostics - refreshed by `HealthCoordinator` on each tick.
health_data: dict[str, Any] | None = None
# Staleness tracking - in-memory, resets on reload.
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

@ -0,0 +1,63 @@
"""Diagnostics support for the SWS12500 integration."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from homeassistant.components.diagnostics import async_redact_data # pyright: ignore[reportUnknownVariableType]
from homeassistant.core import HomeAssistant
from .const import (
API_ID,
API_KEY,
ECOWITT_WEBHOOK_ID,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
WINDY_STATION_ID,
WINDY_STATION_PW,
)
from .data import SWSConfigEntry
TO_REDACT = {
API_ID,
API_KEY,
ECOWITT_WEBHOOK_ID,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
WINDY_STATION_ID,
WINDY_STATION_PW,
"ID",
"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",
}
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: SWSConfigEntry) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
del hass # Unused, but required by the interface
runtime = entry.runtime_data
health_data = runtime.health_data
if health_data is None:
# Fallback to the live coordinator snapshot if no payload has been persisted yet.
health_data = runtime.health_coordinator.data
return {
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
"health_data": async_redact_data(
deepcopy(health_data) if health_data else {},
TO_REDACT,
),
}

View File

@ -0,0 +1,383 @@
"""Ecowitt bridge.
Uses the ecowitt library for payload parsing and sensor discovery,
but does NOT start a separate HTTP server. Instead, the HA webhook handler
feeds raw POST data into EcoWittListener.process_data().
Sensors that have an internal mapping (REMAP_ECOWITT_COMPACT) are unified
with the existing SWS sensor pipeline. Unmapped sensors are exposed as
native Ecowitt entities for forward compatibility.
"""
from __future__ import annotations
from datetime import datetime
import logging
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.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import REMAP_ECOWITT_COMPAT
from .data import SWSConfigEntry, build_device_info
_LOGGER = logging.getLogger(__name__)
# Reverse mapping: internal key to ecowitt field 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]] = {
EcoWittSensorTypes.TEMPERATURE_C: (
SensorDeviceClass.TEMPERATURE,
"°C",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.TEMPERATURE_F: (
SensorDeviceClass.TEMPERATURE,
"°F",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.HUMIDITY: (
SensorDeviceClass.HUMIDITY,
"%",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PRESSURE_HPA: (
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
"hPa",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PRESSURE_INHG: (
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
"inHg",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SPEED_KPH: (
SensorDeviceClass.WIND_SPEED,
"km/h",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SPEED_MPH: (
SensorDeviceClass.WIND_SPEED,
"mph",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.DEGREE: (
None,
"°",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.WATT_METERS_SQUARED: (
SensorDeviceClass.IRRADIANCE,
"W/m²",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.UV_INDEX: (
None,
"UV index",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PM25: (
SensorDeviceClass.PM25,
"µg/m³",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.PM10: (
SensorDeviceClass.PM10,
"µg/m³",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.CO2_PPM: (
SensorDeviceClass.CO2,
"ppm",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.RAIN_RATE_MM: (
SensorDeviceClass.PRECIPITATION_INTENSITY,
"mm/h",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.RAIN_COUNT_MM: (
SensorDeviceClass.PRECIPITATION,
"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",
SensorStateClass.TOTAL_INCREASING,
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: (
SensorDeviceClass.DISTANCE,
"km",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.SOIL_MOISTURE: (
SensorDeviceClass.MOISTURE,
"%",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.BATTERY_VOLTAGE: (
SensorDeviceClass.VOLTAGE,
"V",
SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.BATTERY_PERCENTAGE: (
SensorDeviceClass.BATTERY,
"%",
SensorStateClass.MEASUREMENT,
),
}
class EcowittBridge:
"""Bridge between HA webhook and aioecowitt parsing.
We do not run EcoWittListener.start() - this would start separate HTTP server.
Instead we are calling listener.process_data() manualy from our webhook handler
and we are just using parsing/discovery logic.
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize bridge."""
self.hass = hass
self.config = config
# Listener without start - just parser
self._listener = EcoWittListener()
# Callback for new sensors
self._listener.new_sensor_cb.append(self._on_new_sensor)
# We need to know which native ecowitt sensors have an entity
self._know_native_keys: set[str] = set()
# Callback for new entities
self._add_entities_cb: AddEntitiesCallback | None = None
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
"""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.
Returns:
Dict of internal sensor keys -> values (fro mapped senors).
Unmapped sensors are handeled via _on_new_sensor callback.
"""
# Let aioecowitt parse payload and derive values,
# then call new_sensors_cb for new sensors
self._listener.process_data(data)
# Get values for internally mapped sensors
mapped_result: dict[str, str] = {}
for ecowitt_key, internal_key in REMAP_ECOWITT_COMPAT.items():
if ecowitt_key in data:
mapped_result[internal_key] = data[ecowitt_key]
return mapped_result
def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
"""Call me by aioecowitt when a new sensor is discovered.
If the senosor does not have internal mapping,
create native Ecowitt entity.
"""
# Sensors with internal mapping handle with current pipeline.
if sensor.key in _MAPPED_ECOWITT_KEYS:
_LOGGER.debug(
"Ecowitt sensor %s has internal mapping, skipping native entity",
sensor.key,
)
return
# Is entity created?
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, self.config)
self._add_entities_cb([entity])
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
@property
def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
"""Return al sensors that don't have an internal mapping."""
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
@property
def all_sensors(self) -> dict[str, EcoWittSensor]:
"""Return all discovered sensors."""
return self._listener.sensors
class EcoWittNativeSensor(SensorEntity):
"""Sensor entity for Ecowitt sensors without internal mapping.
These entities are "pass-through" - their values are directly from EcoWittSensor
and maps `stype` to HA device class. They do not have coordinator, because
EcoWittSensor have his own update_cb callback mechanism.
"""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
"""Initialize native EcoWittSensor."""
self._ecowitt_sensor = sensor
self._attr_unique_id = f"ecowitt_{sensor.key}"
# 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).
ha_meta = STYPE_TO_HA.get(sensor.stype)
if ha_meta:
device_class, unit, state_class = ha_meta
self._attr_device_class = device_class
self._attr_native_unit_of_measurement = unit
self._attr_state_class = state_class
# 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]
"""Current value from Ecowitt sensor."""
value = self._ecowitt_sensor.value
if value is None or value == "":
return None
return value
async def async_added_to_hass(self) -> None:
"""Register update callback when entity is added to HA."""
self._ecowitt_sensor.update_cb.append(self._handle_update)
async def async_will_remove_from_hass(self) -> None:
"""Remove update callback when entity is removed."""
if self._handle_update in self._ecowitt_sensor.update_cb:
self._ecowitt_sensor.update_cb.remove(self._handle_update)
@callback
def _handle_update(self) -> None:
"""Handle sensor values update from aioecowitt."""
self.async_write_ha_state()

View File

@ -0,0 +1,411 @@
"""Health and diagnostics coordinator for the SWS12500 integration.
This module owns the integration's runtime health model. The intent is to keep
all support/debug state in one place so it can be surfaced consistently via:
- diagnostic entities (`health_sensor.py`)
- diagnostics download (`diagnostics.py`)
- the `/station/health` HTTP endpoint
The coordinator is intentionally separate from the weather data coordinator.
Weather payload handling is push-based, while health metadata is lightweight
polling plus event-driven updates (route dispatch, ingress result, forwarding).
"""
from __future__ import annotations
from asyncio import timeout
from copy import deepcopy
from datetime import timedelta
import logging
from typing import Any
import aiohttp
from aiohttp import ClientConnectionError
import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or
from homeassistant.components.http import KEY_AUTHENTICATED
from homeassistant.components.network import async_get_source_ip
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.network import get_url
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
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,
WSLINK_ADDON_PORT,
WSLINK_URL,
)
from .data import SWSConfigEntry
from .pocasti_cz import PocasiPush
from .routes import Routes
from .windy_func import WindyPush
_LOGGER = logging.getLogger(__name__)
# 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:
"""Infer an ingress protocol label from a request path."""
if path == WSLINK_URL:
return "wslink"
if path == DEFAULT_URL:
return "wu"
if path == HEALTH_URL:
return "health"
if path.startswith(ECOWITT_URL_PREFIX + "/"):
return "ecowitt"
return "unknown"
def _sanitize_path(path: str) -> str:
"""Strip the secret Ecowitt webhook id from a path before storing/exposing it.
The Ecowitt endpoint is `/weatherhub/<webhook_id>` where the id is the only
credential. Keeping the raw path in the health snapshot would leak it via the
health endpoint and diagnostics, so mask the id segment.
"""
if path.startswith(ECOWITT_URL_PREFIX + "/"):
return ECOWITT_URL_PREFIX + "/***"
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 {
"enabled": enabled,
"last_status": "disabled" if not enabled else "idle",
"last_error": None,
"last_attempt_at": None,
}
def _default_health_data(config: SWSConfigEntry) -> dict[str, Any]:
"""Build the default health/debug payload for this config entry."""
configured_protocol = _configured_protocol(config)
return {
"integration_status": f"online_{configured_protocol}",
"configured_protocol": configured_protocol,
"active_protocol": configured_protocol,
"addon": {
"online": False,
"health_endpoint": "/healthz",
"info_endpoint": "/status/internal",
"name": None,
"version": None,
"listen_port": None,
"tls": None,
"upstream_ha_port": None,
"paths": {
"wslink": WSLINK_URL,
"wu": DEFAULT_URL,
},
"raw_status": None,
},
"routes": {
"wu_enabled": False,
"wslink_enabled": False,
"health_enabled": False,
"snapshot": {},
},
"last_ingress": {
"time": None,
"protocol": "unknown",
"path": None,
"method": None,
"route_enabled": False,
"accepted": False,
"authorized": None,
"reason": "no_data",
},
"forwarding": {
"windy": _empty_forwarding_state(checked_or(config.options.get(WINDY_ENABLED), bool, False)),
"pocasi": _empty_forwarding_state(checked_or(config.options.get(POCASI_CZ_ENABLED), bool, False)),
},
}
class HealthCoordinator(DataUpdateCoordinator):
"""Maintain the integration health snapshot.
The coordinator combines:
- periodic add-on reachability checks
- live ingress observations from the HTTP dispatcher
- ingress processing results from the main webhook handler
- forwarding status from Windy/Pocasi helpers
All of that is stored as one structured JSON-like dict in `self.data`.
"""
def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize the health coordinator."""
self.hass: HomeAssistant = hass
self.config: SWSConfigEntry = config
super().__init__(
hass,
logger=_LOGGER,
config_entry=config,
name=f"{DOMAIN}_health",
update_interval=timedelta(minutes=1),
)
self.data: dict[str, Any] = _default_health_data(config)
def _store_runtime_health(self, data: dict[str, Any]) -> None:
"""Persist the latest health payload into entry runtime storage."""
try:
self.config.runtime_data.health_data = deepcopy(data)
except AttributeError:
# runtime_data may not be set up yet during early initialization; that's fine, we'll populate it on the next tick.
return
def _commit(self, data: dict[str, Any]) -> dict[str, Any]:
"""Publish a new health snapshot."""
self.async_set_updated_data(data)
self._store_runtime_health(data)
return data
def _refresh_summary(self, data: dict[str, Any]) -> None:
"""Derive top-level integration status from the detailed health payload."""
configured_protocol = data.get("configured_protocol", "wu")
ingress = data.get("last_ingress", {})
last_protocol = ingress.get("protocol", "unknown")
accepted = bool(ingress.get("accepted"))
reason = ingress.get("reason")
# 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 _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 _REAL_PROTOCOLS else configured_protocol
)
async def _async_update_data(self) -> dict[str, Any]:
"""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)
port = checked_or(self.config.options.get(WSLINK_ADDON_PORT), int, 443)
health_url = f"https://{ip}:{port}/healthz"
info_url = f"https://{ip}:{port}/status/internal"
data = deepcopy(self.data)
addon = data["addon"]
addon["health_url"] = health_url
addon["info_url"] = info_url
addon["home_assistant_url"] = url
addon["home_assistant_source_ip"] = str(ip)
addon["online"] = False
try:
async with timeout(5), session.get(health_url) as response:
addon["online"] = checked(response.status, int) == 200
except ClientConnectionError:
addon["online"] = False
raw_status: dict[str, Any] | None = None
if addon["online"]:
try:
async with timeout(5), session.get(info_url) as info_response:
if checked(info_response.status, int) == 200:
raw_status = await info_response.json(content_type=None)
except (ClientConnectionError, aiohttp.ContentTypeError, ValueError):
raw_status = None
addon["raw_status"] = raw_status
if raw_status:
addon["name"] = raw_status.get("addon")
addon["version"] = raw_status.get("version")
addon["listen_port"] = raw_status.get("listen", {}).get("port")
addon["tls"] = raw_status.get("listen", {}).get("tls")
addon["upstream_ha_port"] = raw_status.get("upstream", {}).get("ha_port")
addon["paths"] = {
"wslink": raw_status.get("paths", {}).get("wslink", WSLINK_URL),
"wu": raw_status.get("paths", {}).get("wu", DEFAULT_URL),
}
self._refresh_summary(data)
return self._commit(data)
def update_routing(self, routes: Routes | None) -> None:
"""Store the currently enabled routes for diagnostics."""
data = deepcopy(self.data)
data["configured_protocol"] = _configured_protocol(self.config)
if routes is not None:
data["routes"] = {
"wu_enabled": routes.path_enabled(DEFAULT_URL),
"wslink_enabled": routes.path_enabled(WSLINK_URL),
"health_enabled": routes.path_enabled(HEALTH_URL),
"snapshot": routes.snapshot(),
}
self._refresh_summary(data)
self._commit(data)
def record_dispatch(self, request: aiohttp.web.Request, route_enabled: bool, reason: str | None) -> None:
"""Record every ingress observed by the dispatcher.
This runs before the actual webhook handler. It lets diagnostics answer:
- which endpoint the station is calling
- whether the route was enabled
- whether the request was rejected before processing
"""
# We do not want to proccess health requests
if request.path == HEALTH_URL:
return
data = deepcopy(self.data)
data["last_ingress"] = {
"time": dt_util.utcnow().isoformat(),
"protocol": _protocol_from_path(request.path),
"path": _sanitize_path(request.path),
"method": request.method,
"route_enabled": route_enabled,
"accepted": False,
"authorized": None,
"reason": reason or "pending",
}
self._refresh_summary(data)
self._commit(data)
def update_ingress_result(
self,
request: aiohttp.web.Request,
*,
accepted: bool,
authorized: bool | None,
reason: str | None = None,
) -> None:
"""Store the final processing result of a webhook request."""
data = deepcopy(self.data)
ingress = data.get("last_ingress", {})
ingress.update(
{
"time": dt_util.utcnow().isoformat(),
"protocol": _protocol_from_path(request.path),
"path": _sanitize_path(request.path),
"method": request.method,
"accepted": accepted,
"authorized": authorized,
"reason": reason or ("accepted" if accepted else "rejected"),
}
)
data["last_ingress"] = ingress
self._refresh_summary(data)
self._commit(data)
def update_forwarding(self, windy: WindyPush, pocasi: PocasiPush) -> None:
"""Store forwarding subsystem statuses for diagnostics."""
data = deepcopy(self.data)
data["forwarding"] = {
"windy": {
"enabled": windy.enabled,
"last_status": windy.last_status,
"last_error": windy.last_error,
"last_attempt_at": windy.last_attempt_at,
},
"pocasi": {
"enabled": pocasi.enabled,
"last_status": pocasi.last_status,
"last_error": pocasi.last_error,
"last_attempt_at": pocasi.last_attempt_at,
},
}
self._refresh_summary(data)
self._commit(data)
async def health_status(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
"""Serve the current health snapshot over HTTP.
Requires Home Assistant authentication. The route is registered directly on
the aiohttp router (so it can share the dispatcher), which means HA's auth
middleware only *flags* the request - it does not block it. We therefore
enforce auth here so the snapshot (internal URLs/IPs, add-on status, last
ingress) is never exposed to unauthenticated callers.
Auth is satisfied by a valid bearer token or a signed request, the same as
any HomeAssistantView.
The endpoint forces one refresh before returning so that the caller sees
a reasonably fresh add-on status.
"""
if not request.get(KEY_AUTHENTICATED, False):
raise HTTPUnauthorized
await self.async_request_refresh()
return aiohttp.web.json_response(self.data, status=200)

View File

@ -0,0 +1,267 @@
"""Health diagnostic sensors for SWS-12500."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Any
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.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 .data import SWSConfigEntry, build_device_info
from .health_coordinator import HealthCoordinator, public_health_snapshot
if TYPE_CHECKING:
from .health_coordinator import HealthCoordinator
@dataclass(frozen=True, kw_only=True)
class HealthSensorEntityDescription(SensorEntityDescription):
"""Description for health diagnostic sensors."""
data_path: tuple[str, ...]
value_fn: Callable[[Any], Any] | None = None
def _resolve_path(data: dict[str, Any], path: tuple[str, ...]) -> Any:
"""Resolve a nested path from a dictionary."""
current: Any = data
for key in path:
if checked(current, dict[str, Any]) is None:
return None
current = current.get(key)
return current
def _on_off(value: Any) -> str:
"""Render a boolean-ish value as `on` / `off`."""
return "on" if bool(value) else "off"
def _accepted_state(value: Any) -> str:
"""Render ingress acceptance state."""
return "accepted" if bool(value) else "rejected"
def _authorized_state(value: Any) -> str:
"""Render ingress authorization state."""
if value is None:
return "unknown"
return "authorized" if bool(value) else "unauthorized"
def _timestamp_or_none(value: Any) -> Any:
"""Convert ISO timestamp string to datetime for HA rendering."""
if not isinstance(value, str):
return None
return dt_util.parse_datetime(value)
HEALTH_SENSOR_DESCRIPTIONS: tuple[HealthSensorEntityDescription, ...] = (
HealthSensorEntityDescription(
key="integration_health",
translation_key="integration_health",
icon="mdi:heart-pulse",
data_path=("integration_status",),
),
HealthSensorEntityDescription(
key="active_protocol",
translation_key="active_protocol",
icon="mdi:swap-horizontal",
data_path=("active_protocol",),
),
HealthSensorEntityDescription(
key="wslink_addon_status",
translation_key="wslink_addon_status",
icon="mdi:server-network",
data_path=("addon", "online"),
value_fn=lambda value: "online" if value else "offline",
),
HealthSensorEntityDescription(
key="wslink_addon_name",
translation_key="wslink_addon_name",
icon="mdi:package-variant-closed",
data_path=("addon", "name"),
),
HealthSensorEntityDescription(
key="wslink_addon_version",
translation_key="wslink_addon_version",
icon="mdi:label-outline",
data_path=("addon", "version"),
),
HealthSensorEntityDescription(
key="wslink_addon_listen_port",
translation_key="wslink_addon_listen_port",
icon="mdi:lan-connect",
data_path=("addon", "listen_port"),
),
HealthSensorEntityDescription(
key="wslink_upstream_ha_port",
translation_key="wslink_upstream_ha_port",
icon="mdi:transit-connection-variant",
data_path=("addon", "upstream_ha_port"),
),
HealthSensorEntityDescription(
key="route_wu_enabled",
translation_key="route_wu_enabled",
icon="mdi:transit-connection-horizontal",
data_path=("routes", "wu_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="route_wslink_enabled",
translation_key="route_wslink_enabled",
icon="mdi:transit-connection-horizontal",
data_path=("routes", "wslink_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="last_ingress_time",
translation_key="last_ingress_time",
icon="mdi:clock-outline",
device_class=SensorDeviceClass.TIMESTAMP,
data_path=("last_ingress", "time"),
value_fn=_timestamp_or_none,
),
HealthSensorEntityDescription(
key="last_ingress_protocol",
translation_key="last_ingress_protocol",
icon="mdi:download-network",
data_path=("last_ingress", "protocol"),
),
HealthSensorEntityDescription(
key="last_ingress_route_enabled",
translation_key="last_ingress_route_enabled",
icon="mdi:check-network",
data_path=("last_ingress", "route_enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="last_ingress_accepted",
translation_key="last_ingress_accepted",
icon="mdi:check-decagram",
data_path=("last_ingress", "accepted"),
value_fn=_accepted_state,
),
HealthSensorEntityDescription(
key="last_ingress_authorized",
translation_key="last_ingress_authorized",
icon="mdi:key",
data_path=("last_ingress", "authorized"),
value_fn=_authorized_state,
),
HealthSensorEntityDescription(
key="last_ingress_reason",
translation_key="last_ingress_reason",
icon="mdi:message-alert-outline",
data_path=("last_ingress", "reason"),
),
HealthSensorEntityDescription(
key="forward_windy_enabled",
translation_key="forward_windy_enabled",
icon="mdi:weather-windy",
data_path=("forwarding", "windy", "enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="forward_windy_status",
translation_key="forward_windy_status",
icon="mdi:weather-windy",
data_path=("forwarding", "windy", "last_status"),
),
HealthSensorEntityDescription(
key="forward_pocasi_enabled",
translation_key="forward_pocasi_enabled",
icon="mdi:cloud-upload-outline",
data_path=("forwarding", "pocasi", "enabled"),
value_fn=_on_off,
),
HealthSensorEntityDescription(
key="forward_pocasi_status",
translation_key="forward_pocasi_status",
icon="mdi:cloud-upload-outline",
data_path=("forwarding", "pocasi", "last_status"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SWSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up health diagnostic sensors."""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
coordinator = entry.runtime_data.health_coordinator
entities = [
HealthDiagnosticSensor(coordinator=coordinator, description=description)
for description in HEALTH_SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, SensorEntity
):
"""Health diagnostic sensor for SWS-12500."""
entity_description: HealthSensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: HealthCoordinator,
description: HealthSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_unique_id = f"{description.key}_health"
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
@property
def native_value(self) -> Any: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the current diagnostic value."""
data = checked_or(self.coordinator.data, dict[str, Any], {})
value = _resolve_path(data, self.entity_description.data_path)
if self.entity_description.value_fn is not None:
return self.entity_description.value_fn(value)
return value
@property
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""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
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 (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -0,0 +1,14 @@
{
"entity": {
"sensor": {
"indoor_battery": {
"default": "mdi:battery-unknown",
"state": {
"low": "mdi:battery-low",
"normal": "mdi:battery",
"drained": "mdi:battery-alert"
}
}
}
}
}

View File

@ -0,0 +1,78 @@
"""Legacy battery sensor deprecation.
The integration used to expose battery state as regular SensorEntity instance
(unique_id == bettery key), they have been migrated to BinarySensorEntity (uniqui_id == `key`_binary). Old entity-registry entries from
pre-migration installs orphan. This module raises a Repairs issue so user can celan them up.
"""
from __future__ import annotations
from typing import Final
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import DOMAIN
from .data import SWSConfigEntry
LEGACY_REMOVE_VERSION: Final = "2.1.0"
LEGACY_BATTERY_KEYS: Final[frozenset[str]] = frozenset(
{
"outside_battery",
"indoor_battery",
"ch2_battery",
"ch3_battery",
"ch4_battery",
"ch5_battery",
"ch6_battery",
"ch7_battery",
"ch8_battery",
}
)
def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id fpr this config entry."""
return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
@callback
def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]:
"""Return entity_ids of legacy battery sensors still present in entity registry.
Old non-binary battery entities have:
- domian == "sensor"
- unique_id matches a LEGACY_BATTERY_KESY entry (without `_binary` suffix)
"""
ent_reg = er.async_get(hass)
return [
ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
if ent.domain == "sensor" and ent.unique_id in LEGACY_BATTERY_KEYS
]
@callback
def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for orphan legacy battery snesors."""
issue_id = _legacy_battery_issue_id(entry=entry)
orphans = _orphan_legacy_battery_etries(hass, entry)
if orphans:
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="legacy_battery_sensor_deprecated",
translation_placeholders={
"remove_version": LEGACY_REMOVE_VERSION,
"entities": ", ".join(orphans),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -1,15 +1,24 @@
{
"domain": "sws12500",
"name": "Sencor SWS 12500 Weather Station",
"codeowners": ["@schizza"],
"codeowners": [
"@schizza"
],
"config_flow": true,
"dependencies": ["http"],
"dependencies": [
"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",
"requirements": [],
"ssdp": [],
"version": "1.6.9",
"zeroconf": []
"loggers": [
"aioecowitt"
],
"requirements": [
"typecheck-runtime==0.2.0",
"aioecowitt==2025.9.2"
],
"single_config_entry": true,
"version": "2.0.0-pre1"
}

View File

@ -1,14 +1,18 @@
"""Pocasi CZ resend functions."""
from datetime import datetime, timedelta
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any, Literal
from aiohttp import ClientError
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,
@ -23,7 +27,7 @@ from .const import (
POCASI_INVALID_KEY,
WSLINK_URL,
)
from .utils import update_options
from .utils import anonymize, update_options
_LOGGER = logging.getLogger(__name__)
@ -47,10 +51,14 @@ class PocasiPush:
"""Init."""
self.hass = hass
self.config = config
self.enabled: bool = self.config.options.get(POCASI_CZ_ENABLED, False)
self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None
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
@ -69,14 +77,25 @@ class PocasiPush:
return None
async def push_data_to_server(
self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]
):
async def push_data_to_server(self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]):
"""Pushes weather data to server."""
_data = data.copy()
_api_id = self.config.options.get(POCASI_CZ_API_ID)
_api_key = self.config.options.get(POCASI_CZ_API_KEY)
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
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:
_LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error"
self.last_error = "Missing API ID."
return
if (_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)) is None:
_LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.")
self.last_status = "config_error"
self.last_error = "Missing API key."
return
if self.log:
_LOGGER.info(
@ -85,13 +104,17 @@ 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",
self._interval,
self.next_update,
)
return False
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":
@ -104,12 +127,12 @@ class PocasiPush:
_data["PASSWORD"] = _api_key
request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}"
session = async_get_clientsession(self.hass, verify_ssl=False)
session = async_get_clientsession(self.hass)
_LOGGER.debug(
"Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] = %s",
mode,
request_url,
_data,
anonymize(_data),
)
try:
async with session.get(request_url, params=_data) as resp:
@ -119,25 +142,33 @@ class PocasiPush:
except PocasiApiKeyError:
# log despite of settings
self.last_status = "auth_error"
self.last_error = POCASI_INVALID_KEY
self.enabled = False
_LOGGER.critical(POCASI_INVALID_KEY)
await update_options(
self.hass, self.config, POCASI_CZ_ENABLED, False
)
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
except PocasiSuccess:
self.last_status = "ok"
self.last_error = None
if self.log:
_LOGGER.info(POCASI_CZ_SUCCESS)
else:
self.last_status = "ok"
except ClientError as ex:
self.last_status = "client_error"
# 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:
if self.invalid_response_count >= 3:
_LOGGER.critical(POCASI_CZ_UNEXPECTED)
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))
return None

View File

@ -1,77 +1,233 @@
"""Store routes info."""
"""Routes implementation.
from collections.abc import Callable
from dataclasses import dataclass
from logging import getLogger
Why this dispatcher exists
--------------------------
Home Assistant registers aiohttp routes on startup. Re-registering or removing routes at runtime
is awkward and error-prone (and can raise if routes already exist). This integration supports two
different push endpoints (legacy WU-style vs WSLink). To allow switching between them without
touching the aiohttp router, we register both routes once and use this in-process dispatcher to
decide which one is currently enabled.
from aiohttp.web import AbstractRoute, Response
Important note:
- Each route stores a *bound method* handler (e.g. `coordinator.received_data`). That means the
route points to a specific coordinator instance. When the integration reloads, we must keep the
same coordinator instance or update the stored handler accordingly. Otherwise requests may go to
an old coordinator while entities listen to a new one (result: UI appears "frozen").
"""
_LOGGER = getLogger(__name__)
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import logging
from typing import Any
from aiohttp.web import AbstractRoute, Request, Response
_LOGGER = logging.getLogger(__name__)
Handler = Callable[[Request], Awaitable[Response]]
IngressObserver = Callable[[Request, bool, str | None], None]
@dataclass
class Route:
"""Store route info."""
class RouteInfo:
"""Route definition held by the dispatcher.
- `handler` is the real webhook handler (bound method).
- `fallback` is used when the route exists but is currently disabled.
"""
url_path: str
route: AbstractRoute
handler: Callable
handler: Handler
enabled: bool = False
sticky: bool = False
fallback: Handler = field(default_factory=lambda: unregistered)
def __str__(self):
"""Return string representation."""
return f"{self.url_path} -> {self.handler}"
return f"RouteInfo(url_path={self.url_path}, route={self.route}, handler={self.handler}, enabled={self.enabled}, fallback={self.fallback})"
class Routes:
"""Store routes info."""
"""Simple route dispatcher.
We register aiohttp routes once and direct traffic to the currently enabled endpoint
using `switch_route`. This keeps route registration stable while still allowing the
integration to support multiple incoming push formats.
"""
def __init__(self) -> None:
"""Initialize routes."""
self.routes = {}
"""Initialize dispatcher storage."""
self.routes: dict[str, RouteInfo] = {}
self._ingress_observer: IngressObserver | None = None
def switch_route(self, coordinator: Callable, url_path: str):
"""Switch route."""
def _resolve_route(self, request: Request) -> RouteInfo | None:
"""Find the matching RouteInfo for a request.
for url, route in self.routes.items():
if url == url_path:
_LOGGER.info("New coordinator to route: %s", route.url_path)
Two step lookup:
1) Find exact match using method:path (for fix routes)
2) Fallback to aiohttp resource canonical URL
works for routes with path parameter - as {webhook_id}
"""
key = f"{request.method}:{request.path}"
if key in self.routes:
return self.routes[key]
# Fallback to the aiohttp resource canonical URL (for routes with a path
# parameter such as {webhook_id}). Resolve defensively: a request without
# match_info/route/resource simply has no canonical match.
match_info = getattr(request, "match_info", None)
route = getattr(match_info, "route", None)
resource = getattr(route, "resource", None)
if resource is not None:
canonical_key = f"{request.method}:{resource.canonical}"
if canonical_key in self.routes:
return self.routes[canonical_key]
return None
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
"""Enable or disable the Ecowitt sticky route.
switch_route() does not involves sticky routes, so we need another
method for Ecowitt state at reload.
"""
for route in self.routes.values():
if route.url_path == url_path and route.sticky:
route.enabled = enabled
route.handler = handler if enabled else unregistered
_LOGGER.info(
"Ecowitt route %s %s",
route.url_path,
"enabled" if enabled else "disabled",
)
return
def rebind_handler(self, url_path: str, handler: Handler) -> None:
"""Repoint an always-on sticky route to a new handler after a reload.
Sticky routes (e.g. health) stay enabled across reloads, but their stored
handler is a bound method tied to a specific coordinator instance. When the
integration reloads, a new coordinator is created, so the handler must be
repointed - otherwise the route keeps calling the old (stale) instance.
Unlike `set_ecowitt_enabled`, this never changes `enabled`; it only rebinds
currently enabled sticky routes for `url_path`.
"""
for route in self.routes.values():
if route.url_path == url_path and route.sticky and route.enabled:
route.handler = handler
_LOGGER.debug("Rebound sticky route handler for %s", url_path)
return
def set_ingress_observer(self, observer: IngressObserver | None) -> None:
"""Set a callback notified for every incoming dispatcher request."""
self._ingress_observer = observer
async def dispatch(self, request: Request) -> Response:
"""Dispatch incoming request to either the enabled handler or a fallback."""
info = self._resolve_route(request)
if not info:
_LOGGER.debug("Route (%s):%s is not registered!", request.method, request.path)
if self._ingress_observer is not None:
self._ingress_observer(request, False, "route_not_registered")
return await unregistered(request)
if self._ingress_observer is not None:
self._ingress_observer(
request,
info.enabled,
None if info.enabled else "route_disabled",
)
handler = info.handler if info.enabled else info.fallback
return await handler(request)
def switch_route(self, handler: Handler, url_path: str | None, *, enabled: bool = True) -> None:
"""Enable routes based on URL, disable all others. Leave sticky routes enabled.
When `enabled` is False (or url_path is None), all non-sticky (legacy) routes are disabled.
- used when only Ecowitt is active.
Sticky routes (health, ecowitt) are left untouched.
The aiohttp router stays untouched; we only flip which internal handler is active.
"""
for route in self.routes.values():
if route.sticky:
continue
if enabled and route.url_path == url_path:
_LOGGER.info(
"New coordinator to route: (%s):%s",
route.route.method,
route.url_path,
)
route.enabled = True
route.handler = coordinator
route.route._handler = coordinator # noqa: SLF001
route.handler = handler
else:
route.enabled = False
route.handler = unregistred
route.route._handler = unregistred # noqa: SLF001
route.handler = unregistered
def add_route(
self,
url_path: str,
route: AbstractRoute,
handler: Callable,
handler: Handler,
*,
enabled: bool = False,
):
"""Add route."""
self.routes[url_path] = Route(url_path, route, handler, enabled)
sticky: bool = False,
) -> None:
"""Register a route in the dispatcher.
def get_route(self, url_path: str) -> Route:
"""Get route."""
return self.routes.get(url_path, Route)
This does not register anything in aiohttp. It only stores routing metadata that
`dispatch` uses after aiohttp has routed the request by path.
"""
key = f"{route.method}:{url_path}"
self.routes[key] = RouteInfo(url_path, route=route, handler=handler, enabled=enabled, sticky=sticky)
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
def get_enabled(self) -> str:
"""Get enabled routes."""
enabled_routes = [
route.url_path for route in self.routes.values() if route.enabled
]
return "".join(enabled_routes) if enabled_routes else "None"
def show_enabled(self) -> str:
"""Return a human-readable description of the currently enabled route."""
def __str__(self):
"""Return string representation."""
return "\n".join([str(route) for route in self.routes.values()])
enabled_routes = {
f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
for route in self.routes.values()
if route.enabled
}
if not enabled_routes:
return "No routes are enabled."
return ", ".join(sorted(enabled_routes))
def path_enabled(self, url_path: str) -> bool:
"""Return whether any route registered for `url_path` is enabled."""
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
def snapshot(self) -> dict[str, Any]:
"""Return a compact routing snapshot for diagnostics."""
return {
key: {
"path": route.url_path,
"method": route.route.method,
"enabled": route.enabled,
"sticky": route.sticky,
}
for key, route in self.routes.items()
}
async def unregistred(*args, **kwargs):
"""Unregister path to handle incoming data."""
async def unregistered(request: Request) -> Response:
"""Fallback response for unknown/disabled routes.
_LOGGER.error("Recieved data to unregistred webhook. Check your settings")
return Response(body=f"{'Unregistred webhook.'}", status=404)
This should normally never happen for correctly configured stations, but it provides
a clear error message when the station pushes to the wrong endpoint.
"""
_ = request
_LOGGER.debug("Received data to unregistred or disabled webhook.")
return Response(text="Unregistred webhook. Check your settings.", status=400)

View File

@ -1,20 +1,37 @@
"""Sensors definition for SWS12500."""
"""Sensor platform for SWS12500.
This module creates sensor entities based on the config entry options.
The integration is push-based (webhook), so we avoid reloading the entry for
auto-discovered sensors. Instead, we dynamically add new entities at runtime
using the `async_add_entities` callback stored in `entry.runtime_data`.
Why not reload on auto-discovery?
Reloading a config entry unloads platforms temporarily, which removes coordinator
listeners. With frequent webhook pushes, this can create a window where nothing is
subscribed and the frontend appears "frozen" until another full reload/restart.
Per-entry runtime state lives on `entry.runtime_data` (see data.SWSRuntimeData)
"""
from __future__ import annotations
from functools import cached_property
import logging
from typing import TYPE_CHECKING, Any
from py_typecheck import checked_or
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo, generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import WeatherDataUpdateCoordinator
from . import health_sensor
from .const import (
BATTERY_LIST,
CHILL_INDEX,
DOMAIN,
DEV_DBG,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
@ -23,139 +40,188 @@ from .const import (
WIND_DIR,
WIND_SPEED,
WSLINK,
UnitOfBat,
)
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
from .utils import battery_level_to_icon, battery_level_to_text, chill_index, heat_index
if TYPE_CHECKING:
from .coordinator import WeatherDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
def _auto_enable_derived_sensors(requested: set[str]) -> set[str]:
"""Auto-enable derived sensors when their source fields are present.
This does NOT model strict dependencies ("if you want X, we force-add inputs").
Instead, it opportunistically enables derived outputs when the station already
provides the raw fields needed to compute them.
"""
expanded = set(requested)
# Wind azimut depends on wind dir
if WIND_DIR in expanded:
expanded.add(WIND_AZIMUT)
# Heat index depends on temp + humidity
if OUTSIDE_TEMP in expanded and OUTSIDE_HUMIDITY in expanded:
expanded.add(HEAT_INDEX)
# Chill index depends on temp + wind speed
if OUTSIDE_TEMP in expanded and WIND_SPEED in expanded:
expanded.add(CHILL_INDEX)
return expanded
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SWSConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Weather Station sensors."""
"""Set up Weather Station sensors.
coordinator: WeatherDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
Stores `async_add_entities` and the sensor-description map on `entry.runtime_data`
so the webhook handler can add newly discovered entities dynamically without reloading the config entry.
"""
sensors_to_load: list = []
sensors: list = []
_wslink = config_entry.options.get(WSLINK)
runtime = config_entry.runtime_data
coordinator = runtime.coordinator
SENSOR_TYPES = SENSOR_TYPES_WSLINK if _wslink else SENSOR_TYPES_WEATHER_API
# Wire up integration health diagnostic sensor.
await health_sensor.async_setup_entry(hass, config_entry, async_add_entities)
# Check if we have some sensors to load.
if sensors_to_load := config_entry.options.get(SENSORS_TO_LOAD, []):
if WIND_DIR in sensors_to_load:
sensors_to_load.append(WIND_AZIMUT)
if (OUTSIDE_HUMIDITY in sensors_to_load) and (OUTSIDE_TEMP in sensors_to_load):
sensors_to_load.append(HEAT_INDEX)
wslink_enabled = checked_or(config_entry.options.get(WSLINK), bool, False)
sensor_types = SENSOR_TYPES_WSLINK if wslink_enabled else SENSOR_TYPES_WEATHER_API
if (WIND_SPEED in sensors_to_load) and (OUTSIDE_TEMP in sensors_to_load):
sensors_to_load.append(CHILL_INDEX)
sensors = [
WeatherSensor(hass, description, coordinator)
for description in SENSOR_TYPES
if description.key in sensors_to_load
# Persist platform callback + description map for dynamic entity creation.
runtime.add_sensor_entities = async_add_entities
runtime.sensor_descriptions = {desc.key: desc for desc in sensor_types}
# Connect Ecowitt bridge to sensor platform so it can dynamically add
# native Ecowitt entities (sensors without internal SWS mapping).
coordinator.ecowitt_bridge.set_add_entities(async_add_entities)
sensors_to_load = checked_or(config_entry.options.get(SENSORS_TO_LOAD), list[str], [])
if not sensors_to_load:
return
requested = _auto_enable_derived_sensors(set(sensors_to_load))
entities: list[WeatherSensor] = [
WeatherSensor(description, coordinator) for description in sensor_types if description.key in requested
]
async_add_entities(sensors)
async_add_entities(entities)
def add_new_sensors(hass: HomeAssistant, config_entry: SWSConfigEntry, keys: list[str]) -> None:
"""Dynamically add newly discovered sensors without reloading the entry.
Called by the webhook handler when the station starts sending new fields.
Design notes:
- This function is intentionally a safe no-op if the sensor platform hasn't
finished setting up yet (e.g. callback/description map missing).
- Unknown payload keys are ignored (only keys with an entity description are added).
"""
del hass # kept for backwards-compatible call signature; not used after runtime_data migration
runtime = getattr(config_entry, "runtime_data", None)
if runtime is None:
return
add_entities = runtime.add_sensor_entities
if add_entities is None:
return
descriptions = runtime.sensor_descriptions
coordinator = runtime.coordinator
new_entities: list[SensorEntity] = [
WeatherSensor(desc, coordinator) for key in keys if (desc := descriptions.get(key)) is not None
]
if new_entities:
add_entities(new_entities)
class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity[WeatherDataUpdateCoordinator], SensorEntity
CoordinatorEntity, SensorEntity
): # pyright: ignore[reportIncompatibleVariableOverride]
"""Implementation of Weather Sensor entity."""
"""Implementation of Weather Sensor entity.
We intentionally keep the coordinator type unparameterized here to avoid
propagating HA's generic `DataUpdateCoordinator[T]` typing into this module.
"""
entity_description: WeatherSensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride]
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
description: WeatherSensorEntityDescription,
coordinator: WeatherDataUpdateCoordinator,
) -> None:
"""Initialize sensor."""
super().__init__(coordinator)
self.hass = hass
self.coordinator = coordinator
self.entity_description = description
self._attr_unique_id = description.key
self._data = None
async def async_added_to_hass(self) -> None:
"""Handle listeners to reloaded sensors."""
await super().async_added_to_hass()
self.coordinator.async_add_listener(self._handle_coordinator_update)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._data = self.coordinator.data.get(self.entity_description.key)
super()._handle_coordinator_update()
self.async_write_ha_state()
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
self._dev_log = checked_or(coordinator.config.options.get(DEV_DBG), bool, False)
@property
def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride]
"""Return value of entity."""
"""Return the current sensor state.
_wslink = self.coordinator.config.options.get(WSLINK)
Resolution order:
1) If `value_from_data_fn` is provided, it receives the full payload dict and can compute
derived values (e.g. battery enum mapping, azimut text, heat/chill indices).
2) Otherwise we read the raw value for this key from the payload and pass it through `value_fn`.
if self.coordinator.data and (WIND_AZIMUT in self.entity_description.key):
return self.entity_description.value_fn(self.coordinator.data.get(WIND_DIR)) # pyright: ignore[ reportAttributeAccessIssue]
Payload normalization:
- The station sometimes sends empty strings for missing fields; we treat "" as no value (None).
"""
data: dict[str, Any] = checked_or(self.coordinator.data, dict[str, Any], {})
key = self.entity_description.key
if (
self.coordinator.data
and (HEAT_INDEX in self.entity_description.key)
and not _wslink
):
return self.entity_description.value_fn(heat_index(self.coordinator.data)) # pyright: ignore[ reportAttributeAccessIssue]
if self.entity_description.value_from_data_fn is not None:
try:
value = self.entity_description.value_from_data_fn(data)
except Exception: # noqa: BLE001
_LOGGER.exception("native_value compute failed via value_from_data_fn for key=%s", key)
return None
if (
self.coordinator.data
and (CHILL_INDEX in self.entity_description.key)
and not _wslink
):
return self.entity_description.value_fn(chill_index(self.coordinator.data)) # pyright: ignore[ reportAttributeAccessIssue]
return value
return (
None if self._data == "" else self.entity_description.value_fn(self._data) # pyright: ignore[ reportAttributeAccessIssue]
)
raw = data.get(key)
if raw is None or raw == "":
if self._dev_log:
_LOGGER.debug("native_value missing raw: key=%s raw=%s", key, raw)
return None
if self.entity_description.value_fn is None:
if self._dev_log:
_LOGGER.debug("native_value has no value_fn: key=%s raw=%s", key, raw)
return None
try:
value = self.entity_description.value_fn(raw)
except Exception: # noqa: BLE001
_LOGGER.exception("native_value compute failed via value_fn for key=%s raw=%s", key, raw)
return None
return value
@property
def suggested_entity_id(self) -> str:
"""Return name."""
return generate_entity_id("sensor.{}", self.entity_description.key)
@property
def icon(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the dynamic icon for battery representation."""
if self.entity_description.key in BATTERY_LIST:
if self.native_value:
battery_level = battery_level_to_text(self.native_value)
return battery_level_to_icon(battery_level)
return battery_level_to_icon(UnitOfBat.UNKNOWN)
return self.entity_description.icon
@property
def device_info(self) -> DeviceInfo: # pyright: ignore[reportIncompatibleVariableOverride]
"""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",
)
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

@ -1,14 +1,23 @@
"""Common classes for sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntityDescription
from .const import VOCLevel
@dataclass(frozen=True, kw_only=True)
class WeatherSensorEntityDescription(SensorEntityDescription):
"""Describe Weather Sensor entities."""
value_fn: Callable[[Any], int | float | str | None]
value_fn: Callable[[Any], int | float | str | VOCLevel | None] | None = None
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | VOCLevel | None] | None = None
deprecated: bool = False
replacement_entity_domain: str | None = None
replacement_entity_key: str | None = None

View File

@ -1,6 +1,6 @@
"""Sensor entities for the SWS12500 integration for old endpoint."""
from typing import cast
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
@ -41,7 +41,7 @@ from .const import (
UnitOfDir,
)
from .sensors_common import WeatherSensorEntityDescription
from .utils import wind_dir_to_text
from .utils import chill_index, heat_index, to_float, to_int, wind_dir_to_text
SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription(
@ -51,7 +51,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=INDOOR_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=INDOOR_HUMIDITY,
@ -60,7 +60,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=INDOOR_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=OUTSIDE_TEMP,
@ -69,7 +69,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=OUTSIDE_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=OUTSIDE_HUMIDITY,
@ -78,7 +78,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=OUTSIDE_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=DEW_POINT,
@ -87,7 +87,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer-lines",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=DEW_POINT,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=BARO_PRESSURE,
@ -97,7 +97,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
suggested_unit_of_measurement=UnitOfPressure.HPA,
translation_key=BARO_PRESSURE,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_SPEED,
@ -107,7 +107,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:weather-windy",
translation_key=WIND_SPEED,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_GUST,
@ -117,7 +117,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:windsock",
translation_key=WIND_GUST,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_DIR,
@ -127,37 +127,37 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=None,
icon="mdi:sign-direction",
translation_key=WIND_DIR,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=WIND_AZIMUT,
icon="mdi:sign-direction",
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
device_class=SensorDeviceClass.ENUM,
options=list(UnitOfDir),
options=[e.value for e in UnitOfDir],
translation_key=WIND_AZIMUT,
),
WeatherSensorEntityDescription(
key=RAIN,
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL,
suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=DAILY_RAIN,
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_DAY,
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
suggested_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_DAY,
device_class=SensorDeviceClass.PRECIPITATION,
suggested_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=DAILY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=SOLAR_RADIATION,
@ -166,7 +166,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.IRRADIANCE,
icon="mdi:weather-sunny",
translation_key=SOLAR_RADIATION,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=UV,
@ -175,7 +175,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
native_unit_of_measurement=UV_INDEX,
icon="mdi:sunglasses",
translation_key=UV,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_TEMP,
@ -185,7 +185,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH2_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_HUMIDITY,
@ -194,7 +194,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH2_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_TEMP,
@ -204,7 +204,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH3_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH3_HUMIDITY,
@ -213,7 +213,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH3_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH4_TEMP,
@ -223,7 +223,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH4_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH4_HUMIDITY,
@ -232,7 +232,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH4_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=HEAT_INDEX,
@ -243,7 +243,8 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=HEAT_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
value_from_data_fn=heat_index,
),
WeatherSensorEntityDescription(
key=CHILL_INDEX,
@ -254,6 +255,7 @@ SENSOR_TYPES_WEATHER_API: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=CHILL_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
value_from_data_fn=chill_index,
),
)

View File

@ -1,9 +1,10 @@
"""Sensor entities for the SWS12500 integration for old endpoint."""
from typing import cast
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
DEGREE,
PERCENTAGE,
UV_INDEX,
@ -20,11 +21,28 @@ from .const import (
CH2_BATTERY,
CH2_HUMIDITY,
CH2_TEMP,
CH3_BATTERY,
CH3_HUMIDITY,
CH3_TEMP,
CH4_BATTERY,
CH4_HUMIDITY,
CH4_TEMP,
CH5_BATTERY,
CH5_HUMIDITY,
CH5_TEMP,
CH6_BATTERY,
CH6_HUMIDITY,
CH6_TEMP,
CH7_BATTERY,
CH7_HUMIDITY,
CH7_TEMP,
CH8_BATTERY,
CH8_HUMIDITY,
CH8_TEMP,
CHILL_INDEX,
DAILY_RAIN,
DEW_POINT,
HCHO,
HEAT_INDEX,
HOURLY_RAIN,
INDOOR_BATTERY,
@ -36,7 +54,9 @@ from .const import (
OUTSIDE_TEMP,
RAIN,
SOLAR_RADIATION,
T9_BATTERY,
UV,
VOC,
WBGT_TEMP,
WEEKLY_RAIN,
WIND_AZIMUT,
@ -44,10 +64,12 @@ from .const import (
WIND_GUST,
WIND_SPEED,
YEARLY_RAIN,
UnitOfBat,
UnitOfDir,
VOCLevel,
)
from .sensors_common import WeatherSensorEntityDescription
from .utils import wind_dir_to_text
from .utils import battery_5step_to_pct, battery_level, to_float, to_int, voc_level_to_text, wind_dir_to_text
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
WeatherSensorEntityDescription(
@ -57,7 +79,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=INDOOR_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=INDOOR_HUMIDITY,
@ -66,7 +88,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=INDOOR_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=OUTSIDE_TEMP,
@ -75,7 +97,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=OUTSIDE_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=OUTSIDE_HUMIDITY,
@ -84,7 +106,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer",
device_class=SensorDeviceClass.HUMIDITY,
translation_key=OUTSIDE_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=DEW_POINT,
@ -93,7 +115,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:thermometer-lines",
device_class=SensorDeviceClass.TEMPERATURE,
translation_key=DEW_POINT,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=BARO_PRESSURE,
@ -103,7 +125,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
suggested_unit_of_measurement=UnitOfPressure.HPA,
translation_key=BARO_PRESSURE,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_SPEED,
@ -113,7 +135,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:weather-windy",
translation_key=WIND_SPEED,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_GUST,
@ -123,7 +145,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:windsock",
translation_key=WIND_GUST,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_DIR,
@ -133,14 +155,14 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=None,
icon="mdi:sign-direction",
translation_key=WIND_DIR,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=WIND_AZIMUT,
icon="mdi:sign-direction",
value_fn=lambda data: cast("str", wind_dir_to_text(data)),
value_from_data_fn=lambda dir: wind_dir_to_text(dir.get(WIND_DIR)),
device_class=SensorDeviceClass.ENUM,
options=list(UnitOfDir),
options=[e.value for e in UnitOfDir],
translation_key=WIND_AZIMUT,
),
WeatherSensorEntityDescription(
@ -152,7 +174,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=DAILY_RAIN,
@ -163,7 +185,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=DAILY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=HOURLY_RAIN,
@ -174,7 +196,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=HOURLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WEEKLY_RAIN,
@ -185,7 +207,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=WEEKLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=MONTHLY_RAIN,
@ -196,7 +218,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=MONTHLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=YEARLY_RAIN,
@ -207,7 +229,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-pouring",
translation_key=YEARLY_RAIN,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=SOLAR_RADIATION,
@ -216,7 +238,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.IRRADIANCE,
icon="mdi:weather-sunny",
translation_key=SOLAR_RADIATION,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=UV,
@ -225,7 +247,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
native_unit_of_measurement=UV_INDEX,
icon="mdi:sunglasses",
translation_key=UV,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_TEMP,
@ -235,7 +257,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH2_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH2_HUMIDITY,
@ -244,7 +266,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH2_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_TEMP,
@ -254,7 +276,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH3_TEMP,
value_fn=lambda data: cast("float", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH3_HUMIDITY,
@ -263,27 +285,178 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH3_HUMIDITY,
value_fn=lambda data: cast("int", data),
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH4_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH4_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH4_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH4_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH5_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH5_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH5_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH5_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH6_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH6_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH6_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH6_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH7_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH7_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH7_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH7_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH8_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH8_TEMP,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CH8_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH8_HUMIDITY,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=CH3_BATTERY,
translation_key=CH3_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH3_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH3_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH4_BATTERY,
translation_key=CH4_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH4_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH4_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH5_BATTERY,
translation_key=CH5_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(CH5_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH5_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH6_BATTERY,
translation_key=CH6_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH6_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH6_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH7_BATTERY,
translation_key=CH7_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH7_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH7_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH8_BATTERY,
translation_key=CH8_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in UnitOfBat],
value_from_data_fn=lambda data: battery_level(data.get(CH8_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH8_BATTERY,
entity_registry_enabled_default=False,
),
# WeatherSensorEntityDescription(
# key=CH4_TEMP,
# native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
# state_class=SensorStateClass.MEASUREMENT,
# device_class=SensorDeviceClass.TEMPERATURE,
# suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
# icon="mdi:weather-sunny",
# translation_key=CH4_TEMP,
# value_fn=lambda data: cast(float, data),
# ),
# WeatherSensorEntityDescription(
# key=CH4_HUMIDITY,
# native_unit_of_measurement=PERCENTAGE,
# state_class=SensorStateClass.MEASUREMENT,
# device_class=SensorDeviceClass.HUMIDITY,
# icon="mdi:weather-sunny",
# translation_key=CH4_HUMIDITY,
# value_fn=lambda data: cast(int, data),
# ),
WeatherSensorEntityDescription(
key=HEAT_INDEX,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
@ -293,7 +466,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=HEAT_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=CHILL_INDEX,
@ -304,28 +477,43 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
suggested_display_precision=2,
icon="mdi:weather-sunny",
translation_key=CHILL_INDEX,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=OUTSIDE_BATTERY,
translation_key=OUTSIDE_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data),
options=[e.value for e in UnitOfBat],
value_fn=None,
value_from_data_fn=lambda data: battery_level(data.get(OUTSIDE_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=OUTSIDE_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=CH2_BATTERY,
translation_key=CH2_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data),
options=[e.value for e in UnitOfBat],
value_fn=None,
value_from_data_fn=lambda data: battery_level(data.get(CH2_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=CH2_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=INDOOR_BATTERY,
translation_key=INDOOR_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: (data),
options=[e.value for e in UnitOfBat],
value_fn=to_int,
value_from_data_fn=lambda data: battery_level(data.get(INDOOR_BATTERY, None)).value,
deprecated=True,
replacement_entity_domain="binary_sensor",
replacement_entity_key=INDOOR_BATTERY,
entity_registry_enabled_default=False,
),
WeatherSensorEntityDescription(
key=WBGT_TEMP,
@ -335,6 +523,32 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
value_fn=lambda data: cast("int", data),
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=HCHO,
translation_key=HCHO,
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:molecule",
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=VOC,
translation_key=VOC,
device_class=SensorDeviceClass.ENUM,
options=list(VOCLevel),
icon="mdi:air-filter",
value_fn=voc_level_to_text,
),
WeatherSensorEntityDescription(
key=T9_BATTERY,
translation_key=T9_BATTERY,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=battery_5step_to_pct,
),
)

View File

@ -0,0 +1,76 @@
"""Stale sensor detection.
If a sensor key is in SENSORS_TO_LOAD but its station never reported it (or stopped reporting it
for an extended period), the corresponding HA entity becomes Unavailable indefinitely.
This module raises a Repairs issue listing such stale keys so users can decide whether to remove them.
Warmup avoids false-positives at integration startup before any payload arrives.
The check is in-memory only restart/reload resets last_seen, but the 1h warmup covers re-population
from incoming webhooks.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.util import dt as dt_util
from .const import DOMAIN, SENSORS_TO_LOAD
from .data import SWSConfigEntry
STALE_THRESHOLD: Final = timedelta(hours=24)
WARMUP_PERIOD: Final = timedelta(hours=1)
def _stale_sensor_issue_id(entry: SWSConfigEntry) -> str:
"""Return Repairs issue id for this config entry."""
return f"stale_sensors_{entry.entry_id}"
@callback
def _find_stale_keys(entry: SWSConfigEntry) -> list[str]:
"""Return keys in SENSORS_TO_LOAD that haven't been seen recently."""
runtime = entry.runtime_data
now = dt_util.utcnow()
if (now - runtime.started_at) < WARMUP_PERIOD:
return []
loaded = entry.options.get(SENSORS_TO_LOAD, [])
last_seen = runtime.last_seen
stale: list[str] = []
for key in loaded:
seen_at = last_seen.get(key)
if seen_at is None or (now - seen_at) > STALE_THRESHOLD:
stale.append(key)
return stale
@callback
def update_stale_sensors_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Create or clear a Repairs issue for stale sensor keys."""
issue_id = _stale_sensor_issue_id(entry)
stale = _find_stale_keys(entry)
if stale:
ir.async_create_issue(
hass,
DOMAIN,
issue_id=issue_id,
is_persistent=True,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="stale_sensors_detected",
translation_placeholders={
"sensors": ", ".join(sorted(stale)),
},
)
else:
ir.async_delete_issue(hass, DOMAIN, issue_id=issue_id)

View File

@ -7,19 +7,39 @@
},
"step": {
"user": {
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
"title": "Configure access for Weather Station",
"title": "Choose your station type",
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "PWS/WSLink credentials.",
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink Protocol",
"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 Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
}
},
"ecowitt": {
"title": "Ecowitt configuration.",
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"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"
}
}
}
@ -29,7 +49,12 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_key_required": "Windy API key is required if you want to enable this function."
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_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": {
@ -37,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": {
@ -46,26 +74,30 @@
"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": {
"description": "Resend weather data to your Windy stations.",
"title": "Configure Windy",
"data": {
"WINDY_API_KEY": "API KEY provided by Windy",
"WINDY_STATION_ID": "Station ID obtained form Windy",
"WINDY_STATION_PWD": "Station password obtained from Windy",
"windy_enabled_checkbox": "Enable resending data to Windy",
"windy_logger_checkbox": "Log Windy data and responses"
},
"data_description": {
"WINDY_API_KEY": "Windy API KEY obtained from https://https://api.windy.com/keys",
"WINDY_STATION_ID": "Windy station ID obtained from https://stations.windy.com/stations",
"WINDY_STATION_PWD": "Windy station password obtained from https://stations.windy.com/stations",
"windy_logger_checkbox": "Enable only if you want to send debuging data to the developer."
}
},
@ -75,18 +107,40 @@
"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": "Ecowitt configuration.",
"title": "Ecowitt station configuration",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"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": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
@ -102,7 +156,179 @@
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Outside battery"
},
"indoor_battery": {
"name": "Console battery"
},
"ch2_battery": {
"name": "Channel 2 battery"
},
"ch3_battery": {
"name": "Channel 3 battery"
},
"ch4_battery": {
"name": "Channel 4 battery"
},
"ch5_battery": {
"name": "Channel 5 battery"
},
"ch6_battery": {
"name": "Channel 6 battery"
},
"ch7_battery": {
"name": "Channel 7 battery"
},
"ch8_battery": {
"name": "Channel 8 battery"
}
},
"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"
}
},
"active_protocol": {
"name": "Active protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "WSLink Addon Status",
"state": {
"online": "Running",
"offline": "Offline"
}
},
"wslink_addon_name": {
"name": "WSLink Addon Name"
},
"wslink_addon_version": {
"name": "WSLink Addon Version"
},
"wslink_addon_listen_port": {
"name": "WSLink Addon Listen Port"
},
"wslink_upstream_ha_port": {
"name": "WSLink Addon Upstream HA Port"
},
"route_wu_enabled": {
"name": "PWS/WU Protocol"
},
"route_wslink_enabled": {
"name": "WSLink Protocol"
},
"last_ingress_time": {
"name": "Last access time"
},
"last_ingress_protocol": {
"name": "Last access protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Last ingress route enabled"
},
"last_ingress_accepted": {
"name": "Last access",
"state": {
"accepted": "Accepted",
"rejected": "Rejected"
}
},
"last_ingress_authorized": {
"name": "Last access authorization",
"state": {
"authorized": "Authorized",
"unauthorized": "Unauthorized",
"unknown": "Unknown"
}
},
"last_ingress_reason": {
"name": "Last access reason"
},
"forward_windy_enabled": {
"name": "Forwarding to Windy"
},
"forward_windy_status": {
"name": "Forwarding status to Windy",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Forwarding to Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Forwarding status to Počasí Meteo",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Indoor temperature"
},
@ -160,12 +386,67 @@
"ch4_humidity": {
"name": "Channel 4 humidity"
},
"ch5_temp": {
"name": "Channel 5 temperature"
},
"ch5_humidity": {
"name": "Channel 5 humidity"
},
"ch6_temp": {
"name": "Channel 6 temperature"
},
"ch6_humidity": {
"name": "Channel 6 humidity"
},
"ch7_temp": {
"name": "Channel 7 temperature"
},
"ch7_humidity": {
"name": "Channel 7 humidity"
},
"ch8_temp": {
"name": "Channel 8 temperature"
},
"ch8_humidity": {
"name": "Channel 8 humidity"
},
"heat_index": {
"name": "Apparent temperature"
},
"chill_index": {
"name": "Wind chill"
},
"hourly_rain": {
"name": "Hourly precipitation"
},
"weekly_rain": {
"name": "Weekly precipitation"
},
"monthly_rain": {
"name": "Monthly precipitation"
},
"yearly_rain": {
"name": "Yearly precipitation"
},
"wbgt_temp": {
"name": "WBGT index"
},
"hcho": {
"name": "Formaldehyde (HCHO)"
},
"voc": {
"name": "VOC level",
"state": {
"unhealthy": "Unhealthy",
"poor": "Poor",
"moderate": "Moderate",
"good": "Good",
"excellent": "Excellent"
}
},
"t9_battery": {
"name": "HCHO/VOC sensor battery"
},
"wind_azimut": {
"name": "Bearing",
"state": {
@ -185,6 +466,7 @@
"wnw": "WNW",
"nw": "NW",
"nnw": "NNW"
}
},
"outside_battery": {
"name": "Outside battery level",
@ -193,10 +475,83 @@
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch2_battery": {
"name": "Channel 2 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch3_battery": {
"name": "Channel 3 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch4_battery": {
"name": "Channel 4 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch5_battery": {
"name": "Channel 5 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch6_battery": {
"name": "Channel 6 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch7_battery": {
"name": "Channel 7 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch8_battery": {
"name": "Channel 8 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"indoor_battery": {
"name": "Console battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
}
}
},
"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 -> 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": {
"added": {
"title": "New sensors for SWS 12500 found.",

View File

@ -0,0 +1 @@
../dev/custom_components/sws12500

View File

@ -7,19 +7,39 @@
},
"step": {
"user": {
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
"title": "Nastavení přihlášení",
"title": "Vyberte typ stanice",
"description": "Zadejte typ stanice, kterou používáte. Pokude nepoužíváte Ecowitt, vyberte PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, jiné - Weather Underground kompatibilní)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "Přihlašovací údaje PWS/WSLink.",
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem.",
"data": {
"API_ID": "API ID / ID Stanice",
"API_KEY": "API KEY / Heslo",
"wslink": "WSLink API",
"wslink": "WSLink Protorkol",
"dev_debug_checkbox": "Developer log"
},
"data_description": {
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
"wslink": "Zapněte tuto volbu, pokud stanice používá WSLink protokol. Pokud si nejstě jistí, použijte https://test-station.schizza.cz/",
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
}
},
"ecowitt": {
"title": "Nastavení pro Ecowitt stanice",
"description": "Zadejte unikátní webhook ID pro příjem dat ze stanic Ecowitt. Pokud nepoužíváte stanice Ecowitt, tento krok přeskočte.",
"data": {
"ecowitt_webhook_id": "Unikátní webhook ID pro Ecowitt stanice",
"ecowitt_enabled": "Povolit data ze stanic Ecowitt"
},
"data_description": {
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
}
}
}
@ -29,7 +49,9 @@
"valid_credentials_api": "Vyplňte platné API ID",
"valid_credentials_key": "Vyplňte platný API KEY",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"windy_key_required": "Je vyžadován Windy API key, pokud chcete aktivovat přeposílání dat na Windy",
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
"windy_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."
@ -42,35 +64,41 @@
"basic": "Základní - přístupové údaje (přihlášení)",
"windy": "Nastavení pro přeposílání dat na Windy",
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
"ecowitt": "Nastavení pro stanice Ecowitt",
"wslink_port_setup": "Nastavení portu WSLink Addonu",
"migration": "Migrace statistiky senzoru"
}
},
"basic": {
"description": "Zadejte API ID a API KEY, aby meteostanice mohla komunikovat s HomeAssistantem",
"title": "Nastavení přihlášení",
"description": "Nastavení endpointu PWS/WSLink. Pro stanici jen s Ecowwittem vypněte 'Povolit endpoint PWS/WSLink'. API ID/KEY nejsou potřeba",
"title": "Nastavení PWS/WSLink",
"data": {
"API_ID": "API ID / ID Stanice",
"API_KEY": "API KEY / Heslo",
"wslink": "WSLink API",
"dev_debug_checkbox": "Developer log"
"wslink": "WSLink protokol",
"dev_debug_checkbox": "Developer log",
"legacy_enabled": "Povolit endpoint PWS/WSLink"
},
"data_description": {
"dev_debug_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři.",
"API_ID": "API ID je ID stanice, které jste nastavili v meteostanici.",
"API_KEY": "API KEY je heslo, které jste nastavili v meteostanici.",
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink."
"wslink": "WSLink API zapněte, pokud je stanice nastavena na zasílání dat přes WSLink.",
"legacy_enabled": "Vyplněte, pokud vaše stanice používá pouze Ecowitt."
}
},
"windy": {
"description": "Přeposílání dat z metostanice na Windy",
"title": "Konfigurace Windy",
"data": {
"WINDY_API_KEY": "Klíč API KEY získaný z Windy",
"WINDY_STATION_ID": "ID stanice, získaný z Windy",
"WINDY_STATION_PWD": "Heslo stanice, získané z Windy",
"windy_enabled_checkbox": "Povolit přeposílání dat na Windy",
"windy_logger_checkbox": "Logovat data a odpovědi z Windy"
},
"data_description": {
"WINDY_API_KEY": "Klíč API KEY získaný z https://api.windy.com/keys",
"WINDY_STATION_ID": "ID stanice získaný z https://stations.windy.com/station",
"WINDY_STATION_PWD": "Heslo stanice získané z https://stations.windy.com/station",
"windy_logger_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
}
},
@ -80,18 +108,40 @@
"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."
}
},
"ecowitt": {
"description": "Nastavení pro Ecowitt",
"title": "Konfigurace pro stanice Ecowitt",
"data": {
"ecowitt_webhook_id": "Unikátní webhook ID",
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
},
"data_description": {
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
}
},
"wslink_port_setup": {
"description": "Nastavení portu, kde naslouchá WSLink Addon. Slouží pro příjem diagnostik.",
"title": "Port WSLink Addonu",
"data": {
"WSLINK_ADDON_PORT": "Naslouchající port WSLink Addonu"
},
"data_description": {
"WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu."
}
},
"migration": {
"title": "Migrace statistiky senzoru.",
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
@ -107,7 +157,179 @@
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Venkovní baterie"
},
"indoor_battery": {
"name": "Baterie kozole"
},
"ch2_battery": {
"name": "Baterie senzoru 2"
},
"ch3_battery": {
"name": "Baterie senzoru 3"
},
"ch4_battery": {
"name": "Baterie senzoru 4"
},
"ch5_battery": {
"name": "Baterie senzoru 5"
},
"ch6_battery": {
"name": "Baterie senzoru 6"
},
"ch7_battery": {
"name": "Baterie senzoru 7"
},
"ch8_battery": {
"name": "Baterie senzoru 8"
}
},
"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í"
}
},
"active_protocol": {
"name": "Aktivní protokol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "Stav WSLink Addonu",
"state": {
"online": "Běží",
"offline": "Vypnutý"
}
},
"wslink_addon_name": {
"name": "Název WSLink Addonu"
},
"wslink_addon_version": {
"name": "Verze WSLink Addonu"
},
"wslink_addon_listen_port": {
"name": "Port WSLink Addonu"
},
"wslink_upstream_ha_port": {
"name": "Port upstream HA WSLink Addonu"
},
"route_wu_enabled": {
"name": "Protokol PWS/WU"
},
"route_wslink_enabled": {
"name": "Protokol WSLink"
},
"last_ingress_time": {
"name": "Poslední přístup"
},
"last_ingress_protocol": {
"name": "Protokol posledního přístupu",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Trasa posledního přístupu povolena"
},
"last_ingress_accepted": {
"name": "Poslední přístup",
"state": {
"accepted": "Prijat",
"rejected": "Odmítnut"
}
},
"last_ingress_authorized": {
"name": "Autorizace posledního přístupu",
"state": {
"authorized": "Autorizován",
"unauthorized": "Neautorizován",
"unknown": "Neznámý"
}
},
"last_ingress_reason": {
"name": "Zpráva přístupu"
},
"forward_windy_enabled": {
"name": "Přeposílání na Windy"
},
"forward_windy_status": {
"name": "Stav přeposílání na Windy",
"state": {
"disabled": "Vypnuto",
"idle": "Čekám na odeslání",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Přeposílání na Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Stav přeposílání na Počasí Meteo",
"state": {
"disabled": "Vypnuto",
"idle": "Čekám na odeslání",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Vnitřní teplota"
},
@ -165,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"
},
@ -186,6 +432,22 @@
"wbgt_temp": {
"name": "WBGT index"
},
"hcho": {
"name": "Formaldehyd (HCHO)"
},
"voc": {
"name": "Úroveň VOC",
"state": {
"unhealthy": "Nezdravá",
"poor": "Špatná",
"moderate": "Průměrná",
"good": "Dobrá",
"excellent": "Velmi dobrá"
}
},
"t9_battery": {
"name": "Baterie senzoru HCHO/VOC"
},
"wind_azimut": {
"name": "Azimut",
"state": {
@ -220,7 +482,7 @@
"state": {
"low": "Nízká",
"normal": "Normální",
"unknown": "Neznámá / zcela vybitá"
"drained": "Neznámá / zcela vybitá"
}
},
"ch2_battery": {
@ -230,7 +492,65 @@
"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á"
}
}
}
},
"issues": {
"legacy_battery_sensor_deprecated": {
"title": "Detekovány zastaralé senzory baterií.",
"description": "V registru entit byly nalezeny staré senzory baterií ({entities}). Byly nahrazeny binárními senzory baterií a budou odstraněny ve verzi {remove_version}. Smažte prosím staré entity ručně přes Nastavení -> Zařízení a služby -> SWS-12500."
},
"stale_sensors_detected": {
"title": "Detekovány nečinné sensory",
"description": "Tyto senzory jsou nastavené, ale nereportují data: {sensors}. V HA budou viditelné jako Nedostupné. Pokud už nejsou přítomné, můžeš je odstranit přes Nastavení > Zařízení a Služby > SWS 12500."
}
},
"notify": {

View File

@ -7,19 +7,39 @@
},
"step": {
"user": {
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant",
"title": "Configure access for Weather Station",
"title": "Choose your station type",
"description": "Choose the type of your station. If you don't have Eccowit station, choose PWS/WSLink",
"menu_options": {
"pws": "PWS/WSLink (Sencor, Garni, Bresser, other - Weather Underground compatible)",
"ecowitt": "Ecowitt"
}
},
"pws": {
"title": "PWS/WSLink credentials.",
"description": "Provide API ID and API KEY so the Weather Station can access HomeAssistant.",
"data": {
"API_ID": "API ID / Station ID",
"API_KEY": "API KEY / Password",
"WSLINK": "WSLink API",
"wslink": "WSLink Protocol",
"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 Protocol if the station is set to send data via WSLink. If you are unsure, use https://test-station.schizza.cz/",
"dev_debug_checkbox": " Enable only if you want to send debuging data to the developer."
}
},
"ecowitt": {
"title": "Ecowitt configuration.",
"description": "No API ID/KEY needed. Set your Ecowitt station to send data to the enndpoint below.",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"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"
}
}
}
@ -29,7 +49,12 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_key_required": "Windy API key is required if you want to enable this function."
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_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": {
@ -37,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": {
@ -46,26 +74,30 @@
"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": {
"description": "Resend weather data to your Windy stations.",
"title": "Configure Windy",
"data": {
"WINDY_API_KEY": "API KEY provided by Windy",
"WINDY_STATION_ID": "Station ID obtained form Windy",
"WINDY_STATION_PWD": "Station password obtained from Windy",
"windy_enabled_checkbox": "Enable resending data to Windy",
"windy_logger_checkbox": "Log Windy data and responses"
},
"data_description": {
"WINDY_API_KEY": "Windy API KEY obtained from https://https://api.windy.com/keys",
"WINDY_STATION_ID": "Windy station ID obtained from https://stations.windy.com/stations",
"WINDY_STATION_PWD": "Windy station password obtained from https://stations.windy.com/stations",
"windy_logger_checkbox": "Enable only if you want to send debuging data to the developer."
}
},
@ -75,18 +107,40 @@
"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": "Ecowitt configuration.",
"title": "Ecowitt station configuration",
"data": {
"ecowitt_webhook_id": "Unique webhook ID",
"ecowitt_enabled": "Enable Ecowitt station data"
},
"data_description": {
"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": {
"title": "Statistic migration.",
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
@ -102,7 +156,179 @@
}
},
"entity": {
"binary_sensor": {
"outside_battery": {
"name": "Outside battery"
},
"indoor_battery": {
"name": "Console battery"
},
"ch2_battery": {
"name": "Channel 2 battery"
},
"ch3_battery": {
"name": "Channel 3 battery"
},
"ch4_battery": {
"name": "Channel 4 battery"
},
"ch5_battery": {
"name": "Channel 5 battery"
},
"ch6_battery": {
"name": "Channel 6 battery"
},
"ch7_battery": {
"name": "Channel 7 battery"
},
"ch8_battery": {
"name": "Channel 8 battery"
}
},
"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"
}
},
"active_protocol": {
"name": "Active protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"wslink_addon_status": {
"name": "WSLink Addon Status",
"state": {
"online": "Running",
"offline": "Offline"
}
},
"wslink_addon_name": {
"name": "WSLink Addon Name"
},
"wslink_addon_version": {
"name": "WSLink Addon Version"
},
"wslink_addon_listen_port": {
"name": "WSLink Addon Listen Port"
},
"wslink_upstream_ha_port": {
"name": "WSLink Addon Upstream HA Port"
},
"route_wu_enabled": {
"name": "PWS/WU Protocol"
},
"route_wslink_enabled": {
"name": "WSLink Protocol"
},
"last_ingress_time": {
"name": "Last access time"
},
"last_ingress_protocol": {
"name": "Last access protocol",
"state": {
"wu": "PWS/WU",
"wslink": "WSLink API",
"ecowitt": "Ecowitt"
}
},
"last_ingress_route_enabled": {
"name": "Last ingress route enabled"
},
"last_ingress_accepted": {
"name": "Last access",
"state": {
"accepted": "Accepted",
"rejected": "Rejected"
}
},
"last_ingress_authorized": {
"name": "Last access authorization",
"state": {
"authorized": "Authorized",
"unauthorized": "Unauthorized",
"unknown": "Unknown"
}
},
"last_ingress_reason": {
"name": "Last access reason"
},
"forward_windy_enabled": {
"name": "Forwarding to Windy"
},
"forward_windy_status": {
"name": "Forwarding status to Windy",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"forward_pocasi_enabled": {
"name": "Forwarding to Počasí Meteo"
},
"forward_pocasi_status": {
"name": "Forwarding status to Počasí Meteo",
"state": {
"disabled": "Disabled",
"idle": "Waiting to send",
"ok": "Ok"
}
},
"indoor_temp": {
"name": "Indoor temperature"
},
@ -160,6 +386,30 @@
"ch4_humidity": {
"name": "Channel 4 humidity"
},
"ch5_temp": {
"name": "Channel 5 temperature"
},
"ch5_humidity": {
"name": "Channel 5 humidity"
},
"ch6_temp": {
"name": "Channel 6 temperature"
},
"ch6_humidity": {
"name": "Channel 6 humidity"
},
"ch7_temp": {
"name": "Channel 7 temperature"
},
"ch7_humidity": {
"name": "Channel 7 humidity"
},
"ch8_temp": {
"name": "Channel 8 temperature"
},
"ch8_humidity": {
"name": "Channel 8 humidity"
},
"heat_index": {
"name": "Apparent temperature"
},
@ -178,9 +428,25 @@
"yearly_rain": {
"name": "Yearly precipitation"
},
"wbgt_index": {
"wbgt_temp": {
"name": "WBGT index"
},
"hcho": {
"name": "Formaldehyde (HCHO)"
},
"voc": {
"name": "VOC level",
"state": {
"unhealthy": "Unhealthy",
"poor": "Poor",
"moderate": "Moderate",
"good": "Good",
"excellent": "Excellent"
}
},
"t9_battery": {
"name": "HCHO/VOC sensor battery"
},
"wind_azimut": {
"name": "Bearing",
"state": {
@ -218,6 +484,54 @@
"unknown": "Unknown / drained out"
}
},
"ch3_battery": {
"name": "Channel 3 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch4_battery": {
"name": "Channel 4 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch5_battery": {
"name": "Channel 5 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch6_battery": {
"name": "Channel 6 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch7_battery": {
"name": "Channel 7 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch8_battery": {
"name": "Channel 8 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"indoor_battery": {
"name": "Console battery level",
"state": {
@ -228,6 +542,16 @@
}
}
},
"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 -> 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": {
"added": {
"title": "New sensors for SWS 12500 found.",

View File

@ -1,12 +1,23 @@
"""Utils for SWS12500."""
"""Utils for SWS12500.
This module contains small helpers used across the integration.
Notable responsibilities:
- Payload remapping: convert raw station/webhook field names into stable internal keys.
- Auto-discovery helpers: detect new payload fields that are not enabled yet and persist them
to config entry options so sensors can be created dynamically.
- Formatting/conversion helpers (wind direction text, battery mapping, temperature conversions).
Keeping these concerns in one place avoids duplicating logic in the webhook handler and entity code.
"""
from __future__ import annotations
import logging
import math
from pathlib import Path
import sqlite3
from typing import Any
import numpy as np
from py_typecheck.core import checked_or
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
@ -15,16 +26,18 @@ from homeassistant.helpers.translation import async_get_translations
from .const import (
AZIMUT,
DATABASE_PATH,
CONNECTION_GATED_SENSORS,
DEV_DBG,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
REMAP_ITEMS,
REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD,
VOC_LEVEL_MAP,
WIND_SPEED,
UnitOfBat,
UnitOfDir,
VOCLevel,
)
_LOGGER = logging.getLogger(__name__)
@ -37,19 +50,17 @@ async def translations(
*,
key: str = "message",
category: str = "notify",
) -> str:
) -> str | None:
"""Get translated keys for domain."""
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
language = hass.config.language
language: str = hass.config.language
_translations = await async_get_translations(
hass, language, category, [translation_domain]
)
_translations = await async_get_translations(hass, language, category, [translation_domain])
if localize_key in _translations:
return _translations[localize_key]
return ""
return None
async def translated_notification(
@ -66,15 +77,11 @@ async def translated_notification(
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
localize_title = (
f"component.{translation_domain}.{category}.{translation_key}.title"
)
localize_title = f"component.{translation_domain}.{category}.{translation_key}.title"
language = hass.config.language
language: str = hass.config.language
_translations = await async_get_translations(
hass, language, category, [translation_domain]
)
_translations = await async_get_translations(hass, language, category, [translation_domain])
if localize_key in _translations:
if not translation_placeholders:
persistent_notification.async_create(
@ -85,14 +92,10 @@ async def translated_notification(
)
else:
message = _translations[localize_key].format(**translation_placeholders)
persistent_notification.async_create(
hass, message, _translations[localize_title], notification_id
)
persistent_notification.async_create(hass, message, _translations[localize_title], notification_id)
async def update_options(
hass: HomeAssistant, entry: ConfigEntry, update_key, update_value
) -> bool:
async def update_options(hass: HomeAssistant, entry: ConfigEntry, update_key: str, update_value: Any) -> bool:
"""Update config.options entry."""
conf = {**entry.options}
conf[update_key] = update_value
@ -100,57 +103,76 @@ async def update_options(
return hass.config_entries.async_update_entry(entry, options=conf)
def anonymize(data):
"""Anoynimize recieved data."""
def anonymize(
data: dict[str, str | int | float | bool],
) -> dict[str, str | int | float | bool]:
"""Anonymize received data for safe logging.
anonym = {}
for k in data:
if k not in {"ID", "PASSWORD", "wsid", "wspw"}:
anonym[k] = data[k]
- Keep all keys, but mask sensitive values.
- Do not raise on unexpected/missing keys.
"""
secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"}
return anonym
return {k: ("***" if k in secrets else v) for k, v in data.items()}
def remap_items(entities):
"""Remap items in query."""
items = {}
for item in entities:
if item in REMAP_ITEMS:
items[REMAP_ITEMS[item]] = entities[item]
def remap_items(entities: dict[str, str]) -> dict[str, str]:
"""Remap legacy (WU-style) payload field names into internal sensor keys.
return items
The station sends short/legacy field names (e.g. "tempf", "humidity"). Internally we use
stable keys from `const.py` (e.g. "outside_temp", "outside_humidity"). This function produces
a normalized dict that the rest of the integration can work with.
"""
return {REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS}
def remap_wslink_items(entities):
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
"""Remap items in query for WSLink API."""
items = {}
for item in entities:
items: dict[str, str] = {}
for item, value in entities.items():
if item in REMAP_WSLINK_ITEMS:
items[REMAP_WSLINK_ITEMS[item]] = entities[item]
items[REMAP_WSLINK_ITEMS[item]] = value
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
if str(entities.get(conn_key, "0")) != "1":
for key in gated:
items.pop(key, None)
return items
def loaded_sensors(config_entry: ConfigEntry) -> list | None:
"""Get loaded sensors."""
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
"""Return sensor keys currently enabled for this config entry.
Auto-discovery persists new keys into `config_entry.options[SENSORS_TO_LOAD]`. The sensor
platform uses this list to decide which entities to create.
"""
return config_entry.options.get(SENSORS_TO_LOAD) or []
def check_disabled(
hass: HomeAssistant, items, config_entry: ConfigEntry
) -> list | None:
"""Check if we have data for unloaded sensors.
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
"""Detect payload fields that are not enabled yet (auto-discovery).
If so, then add sensor to load queue.
The integration supports "auto-discovery" of sensors: when the station starts sending a new
field, we can automatically enable and create the corresponding entity.
This helper compares the normalized payload keys (`items`) with the currently enabled sensor
keys stored in options (`SENSORS_TO_LOAD`) and returns the missing keys.
Returns:
- list[str] of newly discovered sensor keys (to be added/enabled), or
- None if no new keys were found.
Notes:
- Logging is controlled via `DEV_DBG` because payloads can arrive frequently.
Returns list of found sensors or None
"""
log: bool = config_entry.options.get(DEV_DBG, False)
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
entityFound: bool = False
_loaded_sensors = loaded_sensors(config_entry)
missing_sensors: list = []
_loaded_sensors: list[str] = loaded_sensors(config_entry)
missing_sensors: list[str] = []
for item in items:
if log:
@ -165,20 +187,30 @@ def check_disabled(
return missing_sensors if entityFound else None
def wind_dir_to_text(deg: float) -> UnitOfDir | None:
def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
"""Return wind direction in text representation.
Accepts the raw payload value (str), a float, or None. A direction of 0 - or
a missing/invalid value - is treated as "no reading" (calm) and returns None,
so a missing wind direction does not render as North.
Returns UnitOfDir or None
"""
if deg:
return AZIMUT[int(abs((float(deg) - 11.25) % 360) / 22.5)]
_deg = to_float(deg)
if _deg is None or _deg == 0:
return None
azimut = AZIMUT[int(abs((_deg - 11.25) % 360) / 22.5)]
_LOGGER.debug("wind_dir: %s", azimut)
return azimut
def battery_level_to_text(battery: int) -> UnitOfBat:
"""Return battery level in text representation.
def battery_level(battery: int | str | None) -> UnitOfBat:
"""Return battery level.
WSLink payload values often arrive as strings (e.g. "0"/"1"), so we accept
both ints and strings and coerce to int before mapping.
Returns UnitOfBat
"""
@ -188,10 +220,19 @@ def battery_level_to_text(battery: int) -> UnitOfBat:
1: UnitOfBat.NORMAL,
}
if battery is None:
if (battery is None) or (battery == ""):
return UnitOfBat.UNKNOWN
return level_map.get(int(battery), UnitOfBat.UNKNOWN)
vi: int
if isinstance(battery, int):
vi = battery
else:
try:
vi = int(battery)
except ValueError:
return UnitOfBat.UNKNOWN
return level_map.get(vi, UnitOfBat.UNKNOWN)
def battery_level_to_icon(battery: UnitOfBat) -> str:
@ -218,21 +259,63 @@ def celsius_to_fahrenheit(celsius: float) -> float:
return celsius * 9.0 / 5.0 + 32
def heat_index(data: Any, convert: bool = False) -> float | None:
def to_int(val: Any) -> int | None:
"""Convert int or string (including decimal-formatted, e.g. "180.0") to int."""
if val is None:
return None
if isinstance(val, str) and val.strip() == "":
return None
try:
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
def to_float(val: Any) -> float | None:
"""Convert int or string to float."""
if val is None:
return None
if isinstance(val, str) and val.strip() == "":
return None
try:
v = float(val)
except (TypeError, ValueError):
return None
else:
return v
def heat_index(data: dict[str, int | float | str], convert: bool = False) -> float | None:
"""Calculate heat index from temperature.
data: dict with temperature and humidity
convert: bool, convert recieved data from Celsius to Fahrenheit
convert: bool, convert received data from Celsius to Fahrenheit
"""
temp = data.get(OUTSIDE_TEMP, None)
rh = data.get(OUTSIDE_HUMIDITY, None)
if not temp or not rh:
if (temp := to_float(data.get(OUTSIDE_TEMP))) is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
temp,
)
return None
temp = float(temp)
rh = float(rh)
if (rh := to_float(data.get(OUTSIDE_HUMIDITY))) is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE HUMIDITY (%s), cannot calculate wind chill index.",
rh,
)
return None
adjustment = None
@ -252,10 +335,10 @@ def heat_index(data: Any, convert: bool = False) -> float | None:
+ 0.00085282 * temp * rh * rh
- 0.00000199 * temp * temp * rh * rh
)
if rh < 13 and (temp in np.arange(80, 112, 0.1)):
if rh < 13 and (80 <= temp <= 112):
adjustment = ((13 - rh) / 4) * math.sqrt((17 - abs(temp - 95)) / 17)
if rh > 80 and (temp in np.arange(80, 87, 0.1)):
if rh > 80 and (80 <= temp <= 87):
adjustment = ((rh - 85) / 10) * ((87 - temp) / 5)
return round((full_index + adjustment if adjustment else full_index), 2)
@ -263,32 +346,35 @@ def heat_index(data: Any, convert: bool = False) -> float | None:
return simple
def chill_index(data: Any, convert: bool = False) -> float | None:
def chill_index(data: dict[str, str | float | int], convert: bool = False) -> float | None:
"""Calculate wind chill index from temperature and wind speed.
data: dict with temperature and wind speed
convert: bool, convert recieved data from Celsius to Fahrenheit
convert: bool, convert received data from Celsius to Fahrenheit
"""
temp = to_float(data.get(OUTSIDE_TEMP))
wind = to_float(data.get(WIND_SPEED))
temp = data.get(OUTSIDE_TEMP, None)
wind = data.get(WIND_SPEED, None)
if not temp or not wind:
if temp is None:
_LOGGER.error(
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
temp,
)
return None
temp = float(temp)
wind = float(wind)
if wind is None:
_LOGGER.error(
"We are missing/invalid WIND SPEED (%s), cannot calculate wind chill index.",
wind,
)
return None
if convert:
temp = celsius_to_fahrenheit(temp)
return (
round(
(
(35.7 + (0.6215 * temp))
- (35.75 * (wind**0.16))
+ (0.4275 * (temp * (wind**0.16)))
),
((35.7 + (0.6215 * temp)) - (35.75 * (wind**0.16)) + (0.4275 * (temp * (wind**0.16)))),
2,
)
if temp < 50 and wind > 3
@ -296,107 +382,17 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
)
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
def voc_level_to_text(value: str | None) -> VOCLevel | None:
"""Map 1-5 VOC level to text state."""
if value in (None, ""):
return None
return VOC_LEVEL_MAP.get(int(value))
async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
"""Migrate data from mm/d to mm."""
def battery_5step_to_pct(value: str) -> int | None:
"""Convert 0-5 battery steps to percentage."""
_LOGGER.debug("Sensor %s is required for data migration", sensor_id)
updated_rows = 0
if value in (None, ""):
return None
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
return round(int(value) / 5 * 100)

View File

@ -1,21 +1,29 @@
"""Windy functions."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from aiohttp.client import ClientResponse
from aiohttp.client_exceptions import ClientError
from py_typecheck import checked
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,
WINDY_API_KEY,
WINDY_ENABLED,
WINDY_INVALID_KEY,
WINDY_LOGGER_ENABLED,
WINDY_MAX_RETRIES,
WINDY_NOT_INSERTED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WINDY_SUCCESS,
WINDY_UNEXPECTED,
WINDY_URL,
@ -24,19 +32,38 @@ from .utils import update_options
_LOGGER = logging.getLogger(__name__)
RESPONSE_FOR_TEST = False
class WindyNotInserted(Exception):
"""NotInserted state."""
"""NotInserted state.
Possible variants are:
- station password is invalid
- station password does not match the station
- payload failed validation
"""
class WindySuccess(Exception):
"""WindySucces state."""
class WindyApiKeyError(Exception):
"""Windy API Key error."""
class WindyPasswordMissing(Exception):
"""Windy password is missing in query or Authorization header.
This should not happend, while we are checking if we have password set and do exits early.
"""
class WindyDuplicatePayloadDetected(Exception):
"""Duplicate payload detected."""
class WindyRateLimitExceeded(Exception):
"""Rate limit exceeded. Minimum interval is 5 minutes.
This should not happend in runnig integration.
Might be seen, if restart of HomeAssistant occured and we are not aware of previous update.
"""
def timed(minutes: int):
@ -54,40 +81,86 @@ class WindyPush:
"""Init."""
self.hass = hass
self.config = config
self.enabled: bool = self.config.options.get(WINDY_ENABLED, False)
self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None
self.last_attempt_at: str | None = None
""" lets wait for 1 minute to get initial data from station
and then try to push first data to Windy
"""
self.last_update = datetime.now()
self.next_update = datetime.now() + timed(minutes=1)
self.last_update: datetime = dt_util.utcnow()
self.next_update: datetime = dt_util.utcnow() + timed(minutes=1)
self.log = self.config.options.get(WINDY_LOGGER_ENABLED)
self.invalid_response_count = 0
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
def verify_windy_response( # pylint: disable=useless-return
self,
response: str,
) -> WindyNotInserted | WindySuccess | WindyApiKeyError | None:
# Lets chcek if Windy server is responding right.
# Otherwise, try 3 times and then disable resending.
self.invalid_response_count: int = 0
# Refactored responses verification.
#
# We now comply to API at https://stations.windy.com/api-reference
def verify_windy_response(self, response: ClientResponse):
"""Verify answer form Windy."""
if self.log:
_LOGGER.info("Windy response raw response: %s", response)
if self.log and response:
# response.text is a coroutine; we are in a sync method here, so log the
# status instead of awaiting/logging a bound method object.
_LOGGER.info("Windy raw response status: %s", response.status)
if "NOTICE" in response:
raise WindyNotInserted
if "SUCCESS" in response:
if response.status == 200:
raise WindySuccess
if "Invalid API key" in response:
raise WindyApiKeyError
if response.status == 400:
raise WindyNotInserted
if "Unauthorized" in response:
raise WindyApiKeyError
if response.status == 401:
raise WindyPasswordMissing
return None
if response.status == 409:
raise WindyDuplicatePayloadDetected
async def push_data_to_windy(self, data):
if response.status == 429:
raise WindyRateLimitExceeded
def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]:
"""Convert WSLink API data to Windy API data protocol."""
if "t1ws" in indata:
indata["wind"] = indata.pop("t1ws")
if "t1wgust" in indata:
indata["gust"] = indata.pop("t1wgust")
if "t1wdir" in indata:
indata["winddir"] = indata.pop("t1wdir")
if "t1hum" in indata:
indata["humidity"] = indata.pop("t1hum")
if "t1dew" in indata:
indata["dewpoint"] = indata.pop("t1dew")
if "t1tem" in indata:
indata["temp"] = indata.pop("t1tem")
if "rbar" in indata:
indata["mbar"] = indata.pop("rbar")
if "t1rainhr" in indata:
indata["precip"] = indata.pop("t1rainhr")
if "t1uvi" in indata:
indata["uv"] = indata.pop("t1uvi")
if "t1solrad" in indata:
indata["solarradiation"] = indata.pop("t1solrad")
return indata
async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending."""
self.enabled = False
self.last_status = "disabled"
self.last_error = reason
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to false.")
persistent_notification.create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
"""Pushes weather data do Windy stations.
Interval is 5 minutes, otherwise Windy would not accepts data.
@ -96,7 +169,26 @@ class WindyPush:
from station. But we need to do some clean up.
"""
text_for_test = None
# First check if we have valid credentials, before any data manipulation.
self.enabled = self.config.options.get(WINDY_ENABLED, False)
self.last_attempt_at = dt_util.utcnow().isoformat()
self.last_error = None
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.")
self.last_status = "config_error"
await self._disable_windy(
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if (windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)) is None:
_LOGGER.error("Windy station password is missing! Check your configuration.")
self.last_status = "config_error"
await self._disable_windy(
"Windy password is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if self.log:
_LOGGER.info(
@ -105,62 +197,120 @@ 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:
if purge in purged_data:
purged_data.pop(purge)
_ = purged_data.pop(purge)
if "dewptf" in purged_data:
dewpoint = round(((float(purged_data.pop("dewptf")) - 32) / 1.8), 1)
purged_data["dewpoint"] = str(dewpoint)
if wslink:
# WSLink -> Windy params
purged_data = self._covert_wslink_to_pws(purged_data)
windy_api_key = self.config.options.get(WINDY_API_KEY)
request_url = f"{WINDY_URL}{windy_api_key}"
request_url = f"{WINDY_URL}"
purged_data["id"] = windy_station_id
purged_data["time"] = "now"
headers = {"Authorization": f"Bearer {windy_station_pw}"}
if self.log:
_LOGGER.info("Dataset for windy: %s", purged_data)
session = async_get_clientsession(self.hass, verify_ssl=False)
# 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) as resp:
status = await resp.text()
async with session.get(request_url, params=purged_data, headers=headers) as resp:
try:
self.verify_windy_response(status)
self.verify_windy_response(response=resp)
except WindyNotInserted:
# log despite of settings
_LOGGER.error(WINDY_NOT_INSERTED)
self.last_status = "not_inserted"
self.last_error = WINDY_NOT_INSERTED
self.invalid_response_count += 1
text_for_test = WINDY_NOT_INSERTED
except WindyApiKeyError:
# log despite of settings
_LOGGER.error(
"%s Max retries before disable resend function: %s",
WINDY_NOT_INSERTED,
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
except WindyPasswordMissing:
# log despite of settings
self.last_status = "auth_error"
self.last_error = WINDY_INVALID_KEY
_LOGGER.critical(WINDY_INVALID_KEY)
text_for_test = WINDY_INVALID_KEY
await update_options(self.hass, self.config, WINDY_ENABLED, False)
await self._disable_windy(
reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings."
)
except WindyDuplicatePayloadDetected:
self.last_status = "duplicate"
self.last_error = "Duplicate payload detected by Windy server."
_LOGGER.critical(
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1
except WindyRateLimitExceeded:
# log despite of settings
self.last_status = "rate_limited_remote"
self.last_error = "Windy rate limit exceeded."
_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 = dt_util.utcnow() + timedelta(minutes=5)
except WindySuccess:
# reset invalid_response_count
self.invalid_response_count = 0
self.last_status = "ok"
self.last_error = None
if self.log:
_LOGGER.info(WINDY_SUCCESS)
text_for_test = WINDY_SUCCESS
else:
self.last_status = "unexpected_response"
self.last_error = "Unexpected response from Windy."
# Always count unexpected responses toward the disable threshold,
# regardless of the logging setting.
self.invalid_response_count += 1
if self.log:
_LOGGER.debug(
"Unexpected response from Windy. Max retries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
finally:
if self.invalid_response_count >= 3:
_LOGGER.critical("Invalid response from Windy 3 times. Disabling resend option.")
await self._disable_windy(
reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
)
except ClientError as ex:
_LOGGER.critical("Invalid response from Windy: %s", str(ex))
self.last_status = "client_error"
# 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),
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1
if self.invalid_response_count > 3:
if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED)
text_for_test = WINDY_UNEXPECTED
await update_options(self.hass, self.config, WINDY_ENABLED, False)
self.last_update = datetime.now()
await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.")
self.last_update = dt_util.utcnow()
self.next_update = self.last_update + timed(minutes=5)
if self.log:
_LOGGER.info("Next update: %s", str(self.next_update))
if RESPONSE_FOR_TEST and text_for_test:
return text_for_test
return None
return True

38
tests/conftest.py Normal file
View File

@ -0,0 +1,38 @@
"""Pytest configuration for tests under `dev/tests`.
Goals:
- Make `custom_components.*` importable.
- Keep this file lightweight and avoid global HA test-harness side effects.
Repository layout:
- Root custom components: `SWS-12500/custom_components/...` (symlinked to `dev/custom_components/...`)
- Integration sources: `SWS-12500/dev/custom_components/...`
Note:
Some tests use lightweight `hass` stubs (e.g. SimpleNamespace) that are not compatible with
Home Assistant's full test fixtures. Do NOT enable HA-only fixtures globally here.
Instead, request such fixtures (e.g. `enable_custom_integrations`) explicitly in the specific
tests that need HA's integration loader / flow managers.
"""
from __future__ import annotations
from pathlib import Path
import sys
def pytest_configure() -> None:
"""Adjust sys.path so imports and HA loader discovery work in tests."""
repo_root = Path(__file__).resolve().parents[2] # .../SWS-12500
dev_root = repo_root / "dev"
# Ensure the repo root is importable so HA can find `custom_components/<domain>/manifest.json`.
repo_root_str = str(repo_root)
if repo_root_str not in sys.path:
sys.path.insert(0, repo_root_str)
# Also ensure `dev/` is importable for direct imports from dev tooling/tests.
dev_root_str = str(dev_root)
if dev_root_str not in sys.path:
sys.path.insert(0, dev_root_str)

View File

@ -0,0 +1,225 @@
"""Tests for the binary sensor platform and battery binary sensor entity.
Covers:
- `binary_sensor.async_setup_entry` (with and without battery keys in SENSORS_TO_LOAD)
- `binary_sensor.add_new_binary_sensors` (no-op / dedupe / unknown / new)
- `battery_sensors.BatteryBinarySensor` (`is_on` value mapping + `device_info`)
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from custom_components.sws12500.battery_sensors import BatteryBinarySensor
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS
from custom_components.sws12500.binary_sensor import add_new_binary_sensors, async_setup_entry
from custom_components.sws12500.const import CH2_BATTERY, DOMAIN, INDOOR_BATTERY, OUTSIDE_BATTERY, SENSORS_TO_LOAD
from custom_components.sws12500.data import SWSRuntimeData
class _CoordinatorStub:
"""Minimal coordinator stub: CoordinatorEntity only stores it, and `is_on` reads `.data`."""
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(
*, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None
) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]:
"""Build a config-entry stub carrying typed runtime_data, like the integration does."""
coordinator = coordinator or _CoordinatorStub()
runtime = SWSRuntimeData(
coordinator=coordinator, # type: ignore[arg-type]
health_coordinator=object(), # type: ignore[arg-type]
last_options={},
)
entry = SimpleNamespace(
entry_id="test_entry_id",
options=options if options is not None else {},
runtime_data=runtime,
)
return entry, coordinator, runtime
@pytest.fixture
def hass():
# binary_sensor platform setup deletes hass; add_new_binary_sensors also deletes it.
return object()
def _capture_add_entities():
captured: list[Any] = []
def _add_entities(entities: list[Any]) -> None:
captured.extend(entities)
return captured, _add_entities
def _desc_for(key: str):
return next(d for d in BATTERY_BINARY_SENSORS if d.key == key)
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_setup_creates_entities_for_battery_keys(hass):
entry, coordinator, runtime = _make_entry(
options={SENSORS_TO_LOAD: [OUTSIDE_BATTERY, INDOOR_BATTERY]}
)
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
# Callback + description map persisted for dynamic entity creation.
assert runtime.add_binary_entities is add_entities
assert set(runtime.binary_descriptions.keys()) == {d.key for d in BATTERY_BINARY_SENSORS}
# Entities created for the requested battery keys only.
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
# added_binary_keys tracks them.
assert runtime.added_binary_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
# Entities are bound to our coordinator stub.
assert all(e.coordinator is coordinator for e in captured)
@pytest.mark.asyncio
async def test_setup_no_battery_keys_adds_nothing(hass):
entry, _coordinator, runtime = _make_entry(options={SENSORS_TO_LOAD: ["outside_temp"]})
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
# Callback/descriptions still stored, but no entities created.
assert runtime.add_binary_entities is add_entities
assert runtime.binary_descriptions # populated
assert captured == []
assert runtime.added_binary_keys == set()
@pytest.mark.asyncio
async def test_setup_no_sensors_to_load_option_adds_nothing(hass):
entry, _coordinator, runtime = _make_entry() # no options at all
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert captured == []
assert runtime.added_binary_keys == set()
# --- add_new_binary_sensors ------------------------------------------------
def test_add_new_is_noop_when_callback_missing(hass):
entry, _coordinator, runtime = _make_entry()
# Platform not set up yet -> no stored callback.
assert runtime.add_binary_entities is None
# Must not raise and must not populate anything.
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
assert runtime.added_binary_keys == set()
def test_add_new_ignores_already_added_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
runtime.added_binary_keys = {OUTSIDE_BATTERY}
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
# Already added -> no new entities, callback not invoked.
assert captured == []
assert runtime.added_binary_keys == {OUTSIDE_BATTERY}
def test_add_new_ignores_unknown_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
add_new_binary_sensors(hass, entry, ["totally_unknown_key"])
assert captured == []
assert runtime.added_binary_keys == set()
def test_add_new_adds_new_known_keys(hass):
entry, coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
# Mix of new known, unknown, and a key we'll mark already-added.
runtime.added_binary_keys = {INDOOR_BATTERY}
add_new_binary_sensors(
hass, entry, [OUTSIDE_BATTERY, CH2_BATTERY, INDOOR_BATTERY, "unknown"]
)
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, CH2_BATTERY}
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
assert all(e.coordinator is coordinator for e in captured)
assert runtime.added_binary_keys == {INDOOR_BATTERY, OUTSIDE_BATTERY, CH2_BATTERY}
# --- BatteryBinarySensor ---------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("0", True), # low battery -> on
(0, True),
("1", False), # battery OK -> off
(1, False),
(None, None), # missing
("", None), # empty string
("x", None), # non-int
([], None), # non-int / TypeError path
],
)
def test_is_on_value_mapping(raw, expected):
coordinator = _CoordinatorStub({OUTSIDE_BATTERY: raw})
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is expected
assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary"
def test_is_on_key_absent_returns_none():
coordinator = _CoordinatorStub({}) # key not present at all
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is None
def test_device_info():
coordinator = _CoordinatorStub()
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza"
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

469
tests/test_config_flow.py Normal file
View File

@ -0,0 +1,469 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
INVALID_CREDENTIALS,
LEGACY_ENABLED,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WSLINK,
WSLINK_ADDON_PORT,
)
from homeassistant import config_entries
@pytest.mark.asyncio
async def test_config_flow_user_form_then_create_entry(
hass, enable_custom_integrations
) -> None:
"""Online HA: config flow shows form then creates entry and options."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "menu"
assert result["step_id"] == "user"
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
assert form["type"] == "form"
assert form["step_id"] == "pws"
user_input = {
API_ID: "my_id",
API_KEY: "my_key",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "create_entry"
assert result2["title"] == DOMAIN
# The PWS step augments user input with legacy/ecowitt flags.
assert result2["data"][API_ID] == "my_id"
assert result2["data"][API_KEY] == "my_key"
assert result2["data"][LEGACY_ENABLED] is True
assert result2["data"][ECOWITT_ENABLED] is False
assert result2["options"] == result2["data"]
@pytest.mark.asyncio
async def test_config_flow_user_invalid_credentials_api_id(
hass, enable_custom_integrations
) -> None:
"""API_ID in INVALID_CREDENTIALS -> error on API_ID."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "menu"
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
assert form["type"] == "form"
assert form["step_id"] == "pws"
user_input = {
API_ID: INVALID_CREDENTIALS[0],
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"][API_ID] == "valid_credentials_api"
@pytest.mark.asyncio
async def test_config_flow_user_invalid_credentials_api_key(
hass, enable_custom_integrations
) -> None:
"""API_KEY in INVALID_CREDENTIALS -> error on API_KEY."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "menu"
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
assert form["type"] == "form"
assert form["step_id"] == "pws"
user_input = {
API_ID: "ok_id",
API_KEY: INVALID_CREDENTIALS[0],
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"][API_KEY] == "valid_credentials_key"
@pytest.mark.asyncio
async def test_config_flow_user_invalid_credentials_match(
hass, enable_custom_integrations
) -> None:
"""API_KEY == API_ID -> base error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "menu"
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
assert form["type"] == "form"
assert form["step_id"] == "pws"
user_input = {
API_ID: "same",
API_KEY: "same",
WSLINK: False,
DEV_DBG: False,
}
result2 = await hass.config_entries.flow.async_configure(
form["flow_id"], user_input=user_input
)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"]["base"] == "valid_credentials_match"
@pytest.mark.asyncio
async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
"""Options flow shows menu with expected steps."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == "menu"
assert result["step_id"] == "init"
assert set(result["menu_options"]) == {"basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"}
@pytest.mark.asyncio
async def test_options_flow_basic_validation_and_create_entry(
hass, enable_custom_integrations
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
API_ID: "old",
API_KEY: "oldkey",
WSLINK: False,
DEV_DBG: False,
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
assert init["type"] == "menu"
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "basic"}
)
assert form["type"] == "form"
assert form["step_id"] == "basic"
# Cover invalid API_ID branch in options flow basic step.
bad_api_id = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: INVALID_CREDENTIALS[0],
API_KEY: "ok_key",
WSLINK: False,
DEV_DBG: False,
},
)
assert bad_api_id["type"] == "form"
assert bad_api_id["step_id"] == "basic"
assert bad_api_id["errors"][API_ID] == "valid_credentials_api"
# Cover invalid API_KEY branch in options flow basic step.
bad_api_key = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "ok_id",
API_KEY: INVALID_CREDENTIALS[0],
WSLINK: False,
DEV_DBG: False,
},
)
assert bad_api_key["type"] == "form"
assert bad_api_key["step_id"] == "basic"
assert bad_api_key["errors"][API_KEY] == "valid_credentials_key"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={API_ID: "same", API_KEY: "same", WSLINK: False, DEV_DBG: False},
)
assert bad["type"] == "form"
assert bad["step_id"] == "basic"
assert bad["errors"]["base"] == "valid_credentials_match"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={API_ID: "new", API_KEY: "newkey", WSLINK: True, DEV_DBG: True},
)
assert good["type"] == "create_entry"
assert good["title"] == DOMAIN
assert good["data"][API_ID] == "new"
assert good["data"][API_KEY] == "newkey"
assert good["data"][WSLINK] is True
assert good["data"][DEV_DBG] is True
@pytest.mark.asyncio
async def test_options_flow_windy_requires_keys_when_enabled(
hass, enable_custom_integrations
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
WINDY_ENABLED: False,
WINDY_LOGGER_ENABLED: False,
WINDY_STATION_ID: "",
WINDY_STATION_PW: "",
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "windy"}
)
assert form["type"] == "form"
assert form["step_id"] == "windy"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
WINDY_ENABLED: True,
WINDY_LOGGER_ENABLED: False,
WINDY_STATION_ID: "",
WINDY_STATION_PW: "",
},
)
assert bad["type"] == "form"
assert bad["step_id"] == "windy"
assert bad["errors"][WINDY_STATION_ID] == "windy_key_required"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
WINDY_ENABLED: True,
WINDY_LOGGER_ENABLED: True,
WINDY_STATION_ID: "sid",
WINDY_STATION_PW: "spw",
},
)
assert good["type"] == "create_entry"
assert good["data"][WINDY_ENABLED] is True
@pytest.mark.asyncio
async def test_options_flow_pocasi_validation_minimum_interval_and_required_keys(
hass,
enable_custom_integrations,
) -> None:
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
POCASI_CZ_API_ID: "",
POCASI_CZ_API_KEY: "",
POCASI_CZ_ENABLED: False,
POCASI_CZ_LOGGER_ENABLED: False,
POCASI_CZ_SEND_INTERVAL: 30,
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "pocasi"}
)
assert form["type"] == "form"
assert form["step_id"] == "pocasi"
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
POCASI_CZ_API_ID: "",
POCASI_CZ_API_KEY: "",
POCASI_CZ_ENABLED: True,
POCASI_CZ_LOGGER_ENABLED: False,
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM - 1,
},
)
assert bad["type"] == "form"
assert bad["step_id"] == "pocasi"
assert bad["errors"][POCASI_CZ_SEND_INTERVAL] == "pocasi_send_minimum"
assert bad["errors"][POCASI_CZ_API_ID] == "pocasi_id_required"
assert bad["errors"][POCASI_CZ_API_KEY] == "pocasi_key_required"
good = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
POCASI_CZ_API_ID: "pid",
POCASI_CZ_API_KEY: "pkey",
POCASI_CZ_ENABLED: True,
POCASI_CZ_LOGGER_ENABLED: True,
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM,
},
)
assert good["type"] == "create_entry"
assert good["data"][POCASI_CZ_ENABLED] is True
@pytest.mark.asyncio
async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_default(
hass,
enable_custom_integrations,
) -> None:
"""Online HA: ecowitt step uses get_url() placeholders and secrets token when webhook id missing."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
ECOWITT_WEBHOOK_ID: "",
ECOWITT_ENABLED: False,
},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
assert init["type"] == "menu"
# NOTE:
# The integration currently attempts to mutate `yarl.URL.host` when it is missing:
#
# url: URL = URL(get_url(self.hass))
# if not url.host:
# url.host = "UNKNOWN"
#
# With current yarl versions, `URL.host` is a cached, read-only property, so this
# raises `AttributeError: cached property is read-only`.
#
# We assert that behavior explicitly to keep coverage deterministic and document the
# runtime incompatibility. If the integration code is updated to handle missing hosts
# without mutation (e.g. using `url.raw_host` or building placeholders without setting
# attributes), this assertion should be updated accordingly.
with patch(
"custom_components.sws12500.config_flow.get_url",
return_value="http://",
):
with pytest.raises(AttributeError):
await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
# Second call uses a normal URL and completes the flow.
with patch(
"custom_components.sws12500.config_flow.get_url",
return_value="http://example.local:8123",
):
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
assert form["type"] == "form"
assert form["step_id"] == "ecowitt"
placeholders = form.get("description_placeholders") or {}
assert placeholders["url"] == "example.local"
assert placeholders["port"] == "8123"
assert placeholders["webhook_id"] # generated
done = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
ECOWITT_ENABLED: True,
},
)
assert done["type"] == "create_entry"
assert done["data"][ECOWITT_ENABLED] is True
@pytest.mark.asyncio
async def test_options_flow_wslink_port_setup(hass, enable_custom_integrations) -> None:
"""The WSLink add-on port step shows a form and stores the port."""
# A falsy stored port exercises the 443 default fallback.
entry = MockConfigEntry(domain=DOMAIN, data={}, options={WSLINK_ADDON_PORT: 0})
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
assert init["type"] == "menu"
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "wslink_port_setup"}
)
assert form["type"] == "form"
assert form["step_id"] == "wslink_port_setup"
done = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={WSLINK_ADDON_PORT: 8443}
)
assert done["type"] == "create_entry"
assert done["data"][WSLINK_ADDON_PORT] == 8443
@pytest.mark.asyncio
async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integrations) -> None:
"""Initial config flow: user menu -> ecowitt step creates an Ecowitt-only entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "menu"
with patch(
"custom_components.sws12500.config_flow.get_url",
return_value="http://example.local:8123",
):
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "ecowitt"}
)
assert form["type"] == "form"
assert form["step_id"] == "ecowitt"
placeholders = form.get("description_placeholders") or {}
assert placeholders["url"] == "example.local"
assert placeholders["webhook_id"]
done = await hass.config_entries.flow.async_configure(
form["flow_id"],
user_input={
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
ECOWITT_ENABLED: True,
},
)
assert done["type"] == "create_entry"
assert done["data"][ECOWITT_ENABLED] is True
assert done["data"][LEGACY_ENABLED] is False

8
tests/test_const.py Normal file
View File

@ -0,0 +1,8 @@
from custom_components.sws12500.const import DEFAULT_URL, DOMAIN, WINDY_URL, WSLINK_URL
def test_const_values():
assert DOMAIN == "sws12500"
assert DEFAULT_URL == "/weatherstation/updateweatherstation.php"
assert WSLINK_URL == "/data/upload.php"
assert WINDY_URL == "https://stations.windy.com/api/v2/observation/update"

114
tests/test_data.py Normal file
View File

@ -0,0 +1,114 @@
"""Tests for the typed per-entry runtime data container."""
from __future__ import annotations
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():
"""SWSRuntimeData exposes the expected fields with safe defaults.
The ad-hoc hass.data[DOMAIN][entry_id] string keys (ENTRY_*) were replaced by
this typed dataclass stored on entry.runtime_data in v2.0.
"""
runtime = SWSRuntimeData(
coordinator=object(), # type: ignore[arg-type]
health_coordinator=object(), # type: ignore[arg-type]
last_options={"legacy_enabled": True},
)
assert runtime.last_options == {"legacy_enabled": True}
# Optional platform wiring defaults.
assert runtime.add_sensor_entities is None
assert runtime.sensor_descriptions == {}
assert runtime.add_binary_entities is None
assert runtime.binary_descriptions == {}
assert runtime.added_binary_keys == set()
# Diagnostics / staleness defaults.
assert runtime.health_data is None
assert runtime.last_seen == {}
assert runtime.started_at is not None
def test_runtime_data_collections_are_independent_per_instance():
"""Mutable defaults must not be shared between instances."""
a = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
b = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type]
a.sensor_descriptions["x"] = object() # type: ignore[assignment]
a.added_binary_keys.add("y")
a.last_seen["z"] = object() # type: ignore[assignment]
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"

155
tests/test_diagnostics.py Normal file
View File

@ -0,0 +1,155 @@
"""Tests for the SWS12500 config entry diagnostics.
These cover both branches of `async_get_config_entry_diagnostics`:
- `runtime_data.health_data` is populated and returned directly.
- `runtime_data.health_data` is None, so it falls back to the live
`health_coordinator.data` snapshot.
In both cases secret keys listed in `TO_REDACT` must be replaced by the
Home Assistant `async_redact_data` sentinel, while non-secret values pass
through untouched.
"""
from __future__ import annotations
from types import SimpleNamespace
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DOMAIN,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
WINDY_STATION_ID,
WINDY_STATION_PW,
)
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.diagnostics import async_get_config_entry_diagnostics
REDACTED = "**REDACTED**"
def _make_runtime(*, health_data, health_coordinator) -> SWSRuntimeData:
"""Build a runtime data container with lightweight stub coordinators.
Diagnostics only ever reads `health_data` and `health_coordinator.data`,
so the real coordinators can be replaced with plain stubs.
"""
return SWSRuntimeData(
coordinator=object(), # type: ignore[arg-type]
health_coordinator=health_coordinator, # type: ignore[arg-type]
last_options={},
health_data=health_data,
)
async def test_diagnostics_uses_persisted_health_data(hass) -> None:
"""When `health_data` is present it is returned and secrets are redacted."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
API_ID: "secret-api-id",
API_KEY: "secret-api-key",
"name": "Station",
},
options={
WINDY_STATION_ID: "secret-windy-id",
WINDY_STATION_PW: "secret-windy-pw",
"interval": 60,
},
)
entry.add_to_hass(hass)
health_data = {
"ID": "secret-station-id",
"PASSWORD": "secret-station-pw",
POCASI_CZ_API_ID: "secret-pocasi-id",
POCASI_CZ_API_KEY: "secret-pocasi-key",
"wsid": "secret-wsid",
"wspw": "secret-wspw",
"status": "ok",
}
# A separate coordinator snapshot that must NOT be used in this branch.
health_coordinator = SimpleNamespace(data={"status": "stale-should-not-appear"})
entry.runtime_data = _make_runtime(
health_data=health_data,
health_coordinator=health_coordinator,
)
result = await async_get_config_entry_diagnostics(hass, entry)
# entry_data: secrets redacted, plain value preserved.
assert result["entry_data"][API_ID] == REDACTED
assert result["entry_data"][API_KEY] == REDACTED
assert result["entry_data"]["name"] == "Station"
# entry_options: secrets redacted, plain value preserved.
assert result["entry_options"][WINDY_STATION_ID] == REDACTED
assert result["entry_options"][WINDY_STATION_PW] == REDACTED
assert result["entry_options"]["interval"] == 60
# health_data: the persisted payload is used (not the coordinator snapshot).
assert result["health_data"]["status"] == "ok"
assert result["health_data"]["ID"] == REDACTED
assert result["health_data"]["PASSWORD"] == REDACTED
assert result["health_data"][POCASI_CZ_API_ID] == REDACTED
assert result["health_data"][POCASI_CZ_API_KEY] == REDACTED
assert result["health_data"]["wsid"] == REDACTED
assert result["health_data"]["wspw"] == REDACTED
# The original payload must be untouched (deepcopy is used internally).
assert health_data["ID"] == "secret-station-id"
async def test_diagnostics_falls_back_to_coordinator_data(hass) -> None:
"""When `health_data` is None it falls back to the coordinator snapshot."""
entry = MockConfigEntry(
domain=DOMAIN,
data={API_ID: "secret-api-id", "name": "Station"},
options={},
)
entry.add_to_hass(hass)
coordinator_data = {
"ID": "secret-station-id",
"PASSWORD": "secret-station-pw",
"status": "live",
}
health_coordinator = SimpleNamespace(data=coordinator_data)
entry.runtime_data = _make_runtime(
health_data=None,
health_coordinator=health_coordinator,
)
result = await async_get_config_entry_diagnostics(hass, entry)
assert result["entry_data"][API_ID] == REDACTED
assert result["entry_data"]["name"] == "Station"
assert result["entry_options"] == {}
# Fallback snapshot is used and redacted.
assert result["health_data"]["status"] == "live"
assert result["health_data"]["ID"] == REDACTED
assert result["health_data"]["PASSWORD"] == REDACTED
async def test_diagnostics_empty_health_data(hass) -> None:
"""A falsy fallback snapshot yields an empty health_data dict."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
entry.add_to_hass(hass)
health_coordinator = SimpleNamespace(data=None)
entry.runtime_data = _make_runtime(
health_data=None,
health_coordinator=health_coordinator,
)
result = await async_get_config_entry_diagnostics(hass, entry)
assert result["health_data"] == {}
assert result["entry_data"] == {}
assert result["entry_options"] == {}

View File

@ -0,0 +1,449 @@
"""Tests for the Ecowitt bridge and native passthrough sensor.
Covers `custom_components.sws12500.ecowitt`:
- `EcowittBridge`: set_add_entities, process_payload, _on_new_sensor branches,
and the `unmapped_sensor` / `all_sensors` properties.
- `EcoWittNativeSensor`: __init__ (mapped/unmapped stype, station / no station),
native_value, async_added_to_hass / async_will_remove_from_hass and _handle_update.
The tests drive real `aioecowitt` parsing where practical and construct
`EcoWittSensor` objects directly to exercise deterministic branches.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
from aioecowitt.station import EcoWittStation
import pytest
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).
_PAYLOAD: dict[str, Any] = {
"PASSKEY": "ABC123",
"stationtype": "GW1000",
"model": "GW1000",
"dateutc": "2024-01-01 00:00:00",
"freq": "868M",
"tempf": "68.0",
"humidity": "50",
"windspeedmph": "1.0",
"baromrelin": "29.9",
"pm25_ch1": "12.0",
"co2": "400",
}
def _make_bridge() -> EcowittBridge:
"""Build a bridge with lightweight hass / config stubs.
The bridge only stores hass/config; parsing is delegated to aioecowitt.
"""
hass = SimpleNamespace()
config = SimpleNamespace(options={})
return EcowittBridge(hass, config)
def _make_sensor(
*,
key: str = "pm25_ch1",
name: str = "PM2.5 CH1",
stype: EcoWittSensorTypes = EcoWittSensorTypes.PM25,
station: EcoWittStation | None = None,
value: Any = 12.0,
) -> EcoWittSensor:
"""Construct a real EcoWittSensor for entity-level tests."""
if station is None:
station = EcoWittStation(
station="GW1000",
model="GW1000",
frequence="868M",
key="ABC123",
)
sensor = EcoWittSensor(name, key, stype, station)
sensor.value = value
return sensor
# --------------------------------------------------------------------------- #
# EcowittBridge
# --------------------------------------------------------------------------- #
def test_bridge_init_registers_new_sensor_cb() -> None:
"""The bridge wires its own _on_new_sensor into the listener."""
bridge = _make_bridge()
assert bridge._on_new_sensor in bridge._listener.new_sensor_cb
assert bridge._add_entities_cb is None
def test_set_add_entities_stores_callback() -> None:
"""set_add_entities stores the platform callback."""
bridge = _make_bridge()
cb = MagicMock()
bridge.set_add_entities(cb)
assert bridge._add_entities_cb is cb
@pytest.mark.asyncio
async def test_process_payload_returns_mapped_result() -> None:
"""process_payload parses payload and returns only internally mapped keys."""
bridge = _make_bridge()
result = await bridge.process_payload(dict(_PAYLOAD))
# Mapped fields present in the payload are remapped to internal keys.
assert result[REMAP_ECOWITT_COMPAT["tempf"]] == "68.0"
assert result[REMAP_ECOWITT_COMPAT["humidity"]] == "50"
assert result[REMAP_ECOWITT_COMPAT["windspeedmph"]] == "1.0"
assert result[REMAP_ECOWITT_COMPAT["baromrelin"]] == "29.9"
# Unmapped fields never appear in mapped_result.
assert all(k in REMAP_ECOWITT_COMPAT.values() for k in result)
# aioecowitt populated the listener with sensors.
assert bridge.all_sensors
assert "ABC123.tempf" in bridge.all_sensors
@pytest.mark.asyncio
async def test_process_payload_no_mapped_fields() -> None:
"""A payload without mapped fields yields an empty mapped_result."""
bridge = _make_bridge()
data = {
"PASSKEY": "ABC123",
"stationtype": "GW1000",
"model": "GW1000",
"dateutc": "2024-01-01 00:00:00",
"co2": "400",
}
result = await bridge.process_payload(data)
assert result == {}
@pytest.mark.asyncio
async def test_process_payload_creates_native_entities_for_unmapped() -> None:
"""With a callback set, unmapped sensors become native entities."""
bridge = _make_bridge()
created: list[EcoWittNativeSensor] = []
bridge.set_add_entities(lambda entities: created.extend(entities))
await bridge.process_payload(dict(_PAYLOAD))
created_keys = {e._ecowitt_sensor.key for e in created}
# Unmapped sensors got native entities ...
assert "pm25_ch1" in created_keys
assert "co2" in created_keys
# ... but mapped sensors did NOT.
assert "tempf" not in created_keys
assert "humidity" not in created_keys
assert all(isinstance(e, EcoWittNativeSensor) for e in created)
def test_on_new_sensor_skips_mapped_key() -> None:
"""A sensor whose key has an internal mapping creates no entity."""
bridge = _make_bridge()
bridge.set_add_entities(MagicMock())
sensor = _make_sensor(key="tempf", stype=EcoWittSensorTypes.TEMPERATURE_F)
bridge._on_new_sensor(sensor)
bridge._add_entities_cb.assert_not_called()
assert "tempf" not in bridge._know_native_keys
def test_on_new_sensor_skips_already_known() -> None:
"""A sensor whose key is already tracked creates no new entity."""
bridge = _make_bridge()
cb = MagicMock()
bridge.set_add_entities(cb)
bridge._know_native_keys.add("pm25_ch1")
sensor = _make_sensor(key="pm25_ch1")
bridge._on_new_sensor(sensor)
cb.assert_not_called()
def test_on_new_sensor_no_callback_is_noop() -> None:
"""Without a platform callback the discovery is a no-op (not tracked)."""
bridge = _make_bridge()
sensor = _make_sensor(key="pm25_ch1")
bridge._on_new_sensor(sensor) # _add_entities_cb is None
assert "pm25_ch1" not in bridge._know_native_keys
def test_on_new_sensor_creates_entity() -> None:
"""An unmapped, unknown sensor creates and registers a native entity."""
bridge = _make_bridge()
cb = MagicMock()
bridge.set_add_entities(cb)
sensor = _make_sensor(key="pm25_ch1")
bridge._on_new_sensor(sensor)
assert "pm25_ch1" in bridge._know_native_keys
cb.assert_called_once()
(entities,) = cb.call_args.args
assert len(entities) == 1
assert isinstance(entities[0], EcoWittNativeSensor)
assert entities[0]._ecowitt_sensor is sensor
@pytest.mark.asyncio
async def test_unmapped_sensor_property() -> None:
"""unmapped_sensor returns only sensors without an internal mapping."""
bridge = _make_bridge()
await bridge.process_payload(dict(_PAYLOAD))
unmapped = bridge.unmapped_sensor
keys = {s.key for s in unmapped.values()}
assert "pm25_ch1" in keys
assert "co2" in keys
# Mapped sensors are excluded.
assert "tempf" not in keys
assert "humidity" not in keys
@pytest.mark.asyncio
async def test_all_sensors_property() -> None:
"""all_sensors returns the listener's full sensor dict."""
bridge = _make_bridge()
await bridge.process_payload(dict(_PAYLOAD))
assert bridge.all_sensors is bridge._listener.sensors
assert "ABC123.tempf" in bridge.all_sensors
assert "ABC123.pm25_ch1" in bridge.all_sensors
# --------------------------------------------------------------------------- #
# EcoWittNativeSensor
# --------------------------------------------------------------------------- #
def test_native_sensor_init_with_mapped_stype() -> None:
"""A known stype sets device class / unit / state class and device info."""
station = EcoWittStation(
station="GW1000", model="GW1000", frequence="868M", key="ABC123"
)
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
entity = _native(sensor, _ECOWITT_CONFIG)
assert entity._attr_unique_id == "ecowitt_pm25_ch1"
assert entity._attr_name == "PM2.5 CH1"
assert entity._attr_translation_key is None
device_class, unit, state_class = STYPE_TO_HA[EcoWittSensorTypes.PM25]
assert entity._attr_device_class == device_class
assert entity._attr_native_unit_of_measurement == unit
assert entity._attr_state_class == state_class
# 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"] == "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:
"""An unknown stype leaves device class / unit / state class unset.
Device info is still attached (the recent change).
"""
assert EcoWittSensorTypes.INTERNAL not in STYPE_TO_HA
station = EcoWittStation(
station="GW1000", model="GW1000", frequence="868M", key="ABC123"
)
sensor = _make_sensor(
key="runtime",
name="Runtime",
stype=EcoWittSensorTypes.INTERNAL,
station=station,
value="1000",
)
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 and points at the shared device.
info = entity._attr_device_info
assert info["name"] == "Weather Station SWS 12500"
assert info["identifiers"] == {(DOMAIN,)}
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)
sensor.station = None # type: ignore[assignment]
entity = _native(sensor) # default PWS config
info = entity._attr_device_info
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 = _native(sensor)
assert entity.native_value == 42.0
@pytest.mark.parametrize("empty", [None, ""])
def test_native_value_none_or_empty(empty: Any) -> None:
"""native_value maps None and "" to None."""
sensor = _make_sensor(value=empty)
entity = _native(sensor)
assert entity.native_value is None
@pytest.mark.asyncio
async def test_added_and_removed_callback_lifecycle() -> None:
"""async_added/async_will_remove register and unregister the update cb."""
sensor = _make_sensor()
entity = _native(sensor)
assert entity._handle_update not in sensor.update_cb
await entity.async_added_to_hass()
assert entity._handle_update in sensor.update_cb
await entity.async_will_remove_from_hass()
assert entity._handle_update not in sensor.update_cb
@pytest.mark.asyncio
async def test_will_remove_when_callback_absent() -> None:
"""async_will_remove is safe when the callback was never registered."""
sensor = _make_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
await entity.async_will_remove_from_hass()
assert entity._handle_update not in sensor.update_cb
def test_handle_update_writes_ha_state() -> None:
"""_handle_update forwards to async_write_ha_state."""
sensor = _make_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

758
tests/test_health.py Normal file
View File

@ -0,0 +1,758 @@
"""Coverage tests for the health coordinator and health diagnostic sensors.
These tests exercise the runtime health model end to end without requiring real
network access. The add-on reachability check in `_async_update_data` is the only
network-bound path; it is covered by monkeypatching the aiohttp session factory
and the network helpers (`async_get_source_ip`, `get_url`).
Architecture notes (see CRITICAL ARCHITECTURE NOTES in the task brief):
- `HealthCoordinator(hass, config)` forwards `config_entry=config` to
`DataUpdateCoordinator.__init__`, which calls `config.async_on_unload(...)`.
A `MockConfigEntry` supports that, so we always pass one.
- Health persistence writes `config.runtime_data.health_data`; we set
`entry.runtime_data` to a real `SWSRuntimeData` to cover the success path and
also test the `AttributeError` branch when it is missing.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import aiohttp
from aiohttp import ClientConnectionError
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import health_coordinator as hc, health_sensor as hs
from custom_components.sws12500.const import (
DEFAULT_URL,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_URL_PREFIX,
HEALTH_URL,
LEGACY_ENABLED,
POCASI_CZ_ENABLED,
WINDY_ENABLED,
WSLINK,
WSLINK_ADDON_PORT,
WSLINK_URL,
)
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.health_coordinator import HealthCoordinator
from custom_components.sws12500.routes import Routes
from homeassistant.components.http import KEY_AUTHENTICATED
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _make_entry(options: dict[str, Any] | None = None) -> MockConfigEntry:
"""Create a config entry usable by the coordinator constructor."""
return MockConfigEntry(domain=DOMAIN, data={}, options=options or {})
@pytest.fixture
def entry() -> MockConfigEntry:
"""A config entry with empty options."""
return _make_entry()
def _attach_runtime_data(entry: MockConfigEntry, coordinator: HealthCoordinator) -> None:
"""Attach a real SWSRuntimeData so the persistence success path is exercised."""
entry.runtime_data = SWSRuntimeData(
coordinator=MagicMock(),
health_coordinator=coordinator,
last_options={},
)
class _FakeResponse:
"""Minimal aiohttp-like response."""
def __init__(self, status: int, json_data: Any = None, json_exc: Exception | None = None) -> None:
self.status = status
self._json_data = json_data
self._json_exc = json_exc
async def json(self, content_type: Any = None) -> Any: # noqa: ARG002 - signature match
if self._json_exc is not None:
raise self._json_exc
return self._json_data
class _FakeSession:
"""Fake aiohttp session whose `.get()` returns an async context manager.
`responses` maps a URL substring to either a `_FakeResponse` or an Exception
that should be raised when entering the context manager.
"""
def __init__(self, responses: dict[str, Any]) -> None:
self._responses = responses
def get(self, url: str): # noqa: D401 - mimic aiohttp
outcome: Any = None
for needle, value in self._responses.items():
if needle in url:
outcome = value
break
@asynccontextmanager
async def _ctx():
if isinstance(outcome, Exception):
raise outcome
yield outcome
return _ctx()
def _patch_network(monkeypatch, session: _FakeSession, ip: str = "1.2.3.4") -> None:
"""Patch the coordinator network helpers to deterministic values."""
monkeypatch.setattr(hc, "async_get_clientsession", lambda _hass, _verify=False: session)
monkeypatch.setattr(hc, "async_get_source_ip", AsyncMock(return_value=ip))
monkeypatch.setattr(hc, "get_url", lambda _hass: "http://ha:8123")
# ---------------------------------------------------------------------------
# Module-level helpers in health_coordinator.py
# ---------------------------------------------------------------------------
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:
assert hc._protocol_from_path(WSLINK_URL) == "wslink"
assert hc._protocol_from_path(DEFAULT_URL) == "wu"
assert hc._protocol_from_path(HEALTH_URL) == "health"
assert hc._protocol_from_path(ECOWITT_URL_PREFIX + "/abc") == "ecowitt"
assert hc._protocol_from_path("/something/else") == "unknown"
def test_empty_forwarding_state() -> None:
enabled = hc._empty_forwarding_state(True)
assert enabled == {
"enabled": True,
"last_status": "idle",
"last_error": None,
"last_attempt_at": None,
}
disabled = hc._empty_forwarding_state(False)
assert disabled["enabled"] is False
assert disabled["last_status"] == "disabled"
def test_default_health_data() -> None:
entry = _make_entry({WSLINK: True, WINDY_ENABLED: True, POCASI_CZ_ENABLED: False})
data = hc._default_health_data(entry)
assert data["configured_protocol"] == "wslink"
assert data["active_protocol"] == "wslink"
assert data["integration_status"] == "online_wslink"
assert data["addon"]["online"] is False
assert data["addon"]["paths"] == {"wslink": WSLINK_URL, "wu": DEFAULT_URL}
assert data["forwarding"]["windy"]["enabled"] is True
assert data["forwarding"]["pocasi"]["enabled"] is False
assert data["last_ingress"]["reason"] == "no_data"
def test_default_health_data_wu_default() -> None:
data = hc._default_health_data(_make_entry())
assert data["configured_protocol"] == "wu"
assert data["integration_status"] == "online_wu"
def test_default_health_data_ecowitt() -> None:
data = hc._default_health_data(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}))
assert data["configured_protocol"] == "ecowitt"
assert data["active_protocol"] == "ecowitt"
assert data["integration_status"] == "online_ecowitt"
# ---------------------------------------------------------------------------
# HealthCoordinator construction & persistence
# ---------------------------------------------------------------------------
def test_store_runtime_health_success(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
payload = {"hello": "world"}
coordinator._store_runtime_health(payload)
assert entry.runtime_data.health_data == payload
# deepcopy: stored value is independent of the source dict
assert entry.runtime_data.health_data is not payload
def test_store_runtime_health_missing_runtime_data(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
# entry.runtime_data is unset -> AttributeError branch must be swallowed.
assert getattr(entry, "runtime_data", None) is None
coordinator._store_runtime_health({"a": 1}) # must not raise
def test_commit_publishes_and_persists(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
new_data = hc._default_health_data(entry)
new_data["integration_status"] = "custom"
result = coordinator._commit(new_data)
assert result is new_data
assert coordinator.data["integration_status"] == "custom"
assert entry.runtime_data.health_data["integration_status"] == "custom"
# ---------------------------------------------------------------------------
# _refresh_summary branches
# ---------------------------------------------------------------------------
def test_refresh_summary_degraded_by_reason(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wu"
data["last_ingress"] = {"protocol": "wu", "accepted": False, "reason": "route_disabled"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "degraded"
# not accepted -> active falls back to configured protocol
assert data["active_protocol"] == "wu"
def test_refresh_summary_degraded_by_protocol_mismatch(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wu"
# last protocol differs from configured -> degraded even when accepted
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "degraded"
# accepted + recognized protocol -> active protocol tracks the ingress
assert data["active_protocol"] == "wslink"
def test_refresh_summary_online_protocol(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wslink"
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "online_wslink"
assert data["active_protocol"] == "wslink"
def test_refresh_summary_online_idle(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wu"
data["last_ingress"] = {"protocol": "unknown", "accepted": False, "reason": "no_data"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "online_idle"
assert data["active_protocol"] == "wu"
def test_refresh_summary_online_ecowitt(hass, entry) -> None:
# Ecowitt ingress is a valid protocol: active_protocol must track it (not fall back
# to the legacy "wu"), and it must not be flagged as a WU/WSLink mismatch.
coordinator = HealthCoordinator(hass, entry)
data = hc._default_health_data(entry)
data["configured_protocol"] = "wu"
data["last_ingress"] = {"protocol": "ecowitt", "accepted": True, "reason": "accepted"}
coordinator._refresh_summary(data)
assert data["integration_status"] == "online_ecowitt"
assert data["active_protocol"] == "ecowitt"
# ---------------------------------------------------------------------------
# _async_update_data: offline and online paths
# ---------------------------------------------------------------------------
async def test_async_update_data_addon_offline(hass, monkeypatch) -> None:
entry = _make_entry({WSLINK_ADDON_PORT: 8443})
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
session = _FakeSession({"/healthz": ClientConnectionError("boom")})
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
addon = data["addon"]
assert addon["online"] is False
assert addon["health_url"] == "https://1.2.3.4:8443/healthz"
assert addon["info_url"] == "https://1.2.3.4:8443/status/internal"
assert addon["home_assistant_url"] == "http://ha:8123"
assert addon["home_assistant_source_ip"] == "1.2.3.4"
assert addon["raw_status"] is None
assert addon["name"] is None
async def test_async_update_data_addon_online(hass, monkeypatch) -> None:
entry = _make_entry() # no WSLINK_ADDON_PORT -> default 443
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
raw_status = {
"addon": "wslink-addon",
"version": "1.2.3",
"listen": {"port": 8443, "tls": True},
"upstream": {"ha_port": 8123},
"paths": {"wslink": "/custom/wslink", "wu": "/custom/wu"},
}
session = _FakeSession(
{
"/healthz": _FakeResponse(200),
"/status/internal": _FakeResponse(200, json_data=raw_status),
}
)
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
addon = data["addon"]
assert addon["online"] is True
assert addon["health_url"] == "https://1.2.3.4:443/healthz"
assert addon["name"] == "wslink-addon"
assert addon["version"] == "1.2.3"
assert addon["listen_port"] == 8443
assert addon["tls"] is True
assert addon["upstream_ha_port"] == 8123
assert addon["paths"] == {"wslink": "/custom/wslink", "wu": "/custom/wu"}
assert addon["raw_status"] == raw_status
async def test_async_update_data_info_endpoint_value_error(hass, monkeypatch) -> None:
"""Online add-on but the info endpoint returns invalid JSON."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
info_resp = _FakeResponse(200, json_exc=ValueError("bad json"))
session = _FakeSession(
{
"/healthz": _FakeResponse(200),
"/status/internal": info_resp,
}
)
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
addon = data["addon"]
assert addon["online"] is True
assert addon["raw_status"] is None
# raw_status falsy -> add-on metadata stays at defaults
assert addon["name"] is None
assert addon["version"] is None
async def test_async_update_data_info_non_200(hass, monkeypatch) -> None:
"""Online add-on but info endpoint replies non-200 -> raw_status stays None."""
entry = _make_entry()
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
session = _FakeSession(
{
"/healthz": _FakeResponse(200),
"/status/internal": _FakeResponse(503),
}
)
_patch_network(monkeypatch, session)
data = await coordinator._async_update_data()
assert data["addon"]["online"] is True
assert data["addon"]["raw_status"] is None
# ---------------------------------------------------------------------------
# update_routing
# ---------------------------------------------------------------------------
def test_update_routing_none(hass) -> None:
entry = _make_entry({WSLINK: True})
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
coordinator.update_routing(None)
assert coordinator.data["configured_protocol"] == "wslink"
# routes block unchanged from default when None passed
assert coordinator.data["routes"]["wu_enabled"] is False
def test_update_routing_with_routes(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
routes = Routes()
coordinator.update_routing(routes)
routes_block = coordinator.data["routes"]
assert routes_block["wu_enabled"] is False
assert routes_block["wslink_enabled"] is False
assert routes_block["health_enabled"] is False
assert routes_block["snapshot"] == {}
# ---------------------------------------------------------------------------
# record_dispatch
# ---------------------------------------------------------------------------
def test_record_dispatch_skips_health(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
before = coordinator.data["last_ingress"].copy()
request = SimpleNamespace(path=HEALTH_URL, method="GET")
coordinator.record_dispatch(request, route_enabled=True, reason=None)
# health path is ignored -> last_ingress untouched
assert coordinator.data["last_ingress"] == before
def test_record_dispatch_records(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=WSLINK_URL, method="POST")
coordinator.record_dispatch(request, route_enabled=True, reason=None)
ingress = coordinator.data["last_ingress"]
assert ingress["protocol"] == "wslink"
assert ingress["path"] == WSLINK_URL
assert ingress["method"] == "POST"
assert ingress["route_enabled"] is True
assert ingress["accepted"] is False
assert ingress["reason"] == "pending"
assert ingress["time"] is not None
def test_record_dispatch_records_with_reason(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
coordinator.record_dispatch(request, route_enabled=False, reason="route_disabled")
ingress = coordinator.data["last_ingress"]
assert ingress["reason"] == "route_disabled"
# route_disabled reason makes the summary degraded
assert coordinator.data["integration_status"] == "degraded"
def test_record_dispatch_masks_ecowitt_webhook_id(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=ECOWITT_URL_PREFIX + "/supersecretid", method="POST")
coordinator.record_dispatch(request, route_enabled=True, reason=None)
ingress = coordinator.data["last_ingress"]
assert ingress["protocol"] == "ecowitt"
# The secret webhook id must never reach the (potentially exposed) snapshot.
assert ingress["path"] == ECOWITT_URL_PREFIX + "/***"
assert "supersecretid" not in ingress["path"]
# ---------------------------------------------------------------------------
# update_ingress_result
# ---------------------------------------------------------------------------
def test_update_ingress_result_accepted(hass) -> None:
entry = _make_entry({WSLINK: True})
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=WSLINK_URL, method="POST")
coordinator.update_ingress_result(request, accepted=True, authorized=True)
ingress = coordinator.data["last_ingress"]
assert ingress["accepted"] is True
assert ingress["authorized"] is True
assert ingress["reason"] == "accepted"
assert ingress["protocol"] == "wslink"
assert coordinator.data["integration_status"] == "online_wslink"
def test_update_ingress_result_rejected_default_reason(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
coordinator.update_ingress_result(request, accepted=False, authorized=False)
ingress = coordinator.data["last_ingress"]
assert ingress["accepted"] is False
assert ingress["reason"] == "rejected"
def test_update_ingress_result_explicit_reason(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
coordinator.update_ingress_result(
request, accepted=False, authorized=None, reason="unauthorized"
)
assert coordinator.data["last_ingress"]["reason"] == "unauthorized"
assert coordinator.data["integration_status"] == "degraded"
# ---------------------------------------------------------------------------
# update_forwarding
# ---------------------------------------------------------------------------
def test_update_forwarding(hass, entry) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
windy = SimpleNamespace(
enabled=True, last_status="ok", last_error=None, last_attempt_at="2026-06-20T10:00:00"
)
pocasi = SimpleNamespace(
enabled=False, last_status="disabled", last_error="oops", last_attempt_at=None
)
coordinator.update_forwarding(windy, pocasi)
forwarding = coordinator.data["forwarding"]
assert forwarding["windy"] == {
"enabled": True,
"last_status": "ok",
"last_error": None,
"last_attempt_at": "2026-06-20T10:00:00",
}
assert forwarding["pocasi"]["last_error"] == "oops"
# ---------------------------------------------------------------------------
# health_status HTTP endpoint
# ---------------------------------------------------------------------------
async def test_health_status_endpoint_authenticated(hass, entry, monkeypatch) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
# Avoid network: stub the refresh that health_status awaits.
monkeypatch.setattr(coordinator, "async_request_refresh", AsyncMock(return_value=None))
# aiohttp Request is dict-like; health_status only reads KEY_AUTHENTICATED.
request = {KEY_AUTHENTICATED: True}
response = await coordinator.health_status(request) # type: ignore[arg-type]
assert isinstance(response, aiohttp.web.Response)
assert response.status == 200
coordinator.async_request_refresh.assert_awaited_once()
async def test_health_status_endpoint_rejects_unauthenticated(hass, entry, monkeypatch) -> None:
coordinator = HealthCoordinator(hass, entry)
_attach_runtime_data(entry, coordinator)
refresh = AsyncMock(return_value=None)
monkeypatch.setattr(coordinator, "async_request_refresh", refresh)
# No KEY_AUTHENTICATED flag -> unauthenticated -> 401, no refresh triggered.
with pytest.raises(HTTPUnauthorized):
await coordinator.health_status({}) # type: ignore[arg-type]
refresh.assert_not_awaited()
# ---------------------------------------------------------------------------
# health_sensor.py module helpers
# ---------------------------------------------------------------------------
def test_resolve_path_nested() -> None:
data = {"addon": {"online": True}}
assert hs._resolve_path(data, ("addon", "online")) is True
def test_resolve_path_missing_key() -> None:
data = {"addon": {}}
assert hs._resolve_path(data, ("addon", "missing")) is None
def test_resolve_path_intermediate_not_dict() -> None:
# Intermediate value is not a dict -> _resolve_path returns None.
data = {"addon": "not-a-dict"}
assert hs._resolve_path(data, ("addon", "online")) is None
def test_on_off() -> None:
assert hs._on_off(True) == "on"
assert hs._on_off(False) == "off"
assert hs._on_off(None) == "off"
def test_accepted_state() -> None:
assert hs._accepted_state(True) == "accepted"
assert hs._accepted_state(False) == "rejected"
def test_authorized_state() -> None:
assert hs._authorized_state(None) == "unknown"
assert hs._authorized_state(True) == "authorized"
assert hs._authorized_state(False) == "unauthorized"
def test_timestamp_or_none() -> None:
assert hs._timestamp_or_none(123) is None
assert hs._timestamp_or_none(None) is None
parsed = hs._timestamp_or_none("2026-06-20T10:00:00+00:00")
assert isinstance(parsed, datetime)
# ---------------------------------------------------------------------------
# health_sensor.py async_setup_entry
# ---------------------------------------------------------------------------
async def test_async_setup_entry_creates_sensors(hass, entry) -> None:
coordinator = MagicMock()
coordinator.data = {}
entry.runtime_data = SWSRuntimeData(
coordinator=MagicMock(),
health_coordinator=coordinator,
last_options={},
)
added: list[Any] = []
def _add_entities(entities: Any) -> None:
added.extend(entities)
await hs.async_setup_entry(hass, entry, _add_entities)
assert len(added) == len(hs.HEALTH_SENSOR_DESCRIPTIONS)
assert all(isinstance(sensor, hs.HealthDiagnosticSensor) for sensor in added)
# ---------------------------------------------------------------------------
# HealthDiagnosticSensor
# ---------------------------------------------------------------------------
def _description(key: str) -> hs.HealthSensorEntityDescription:
"""Return the bundled description for `key`."""
return next(d for d in hs.HEALTH_SENSOR_DESCRIPTIONS if d.key == key)
def _stub_coordinator(data: dict[str, Any]) -> Any:
"""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:
coordinator = _stub_coordinator({"integration_status": "online_wu"})
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
assert sensor.native_value == "online_wu"
def test_sensor_native_value_with_value_fn() -> None:
coordinator = _stub_coordinator({"addon": {"online": True}})
sensor = hs.HealthDiagnosticSensor(coordinator, _description("wslink_addon_status"))
assert sensor.native_value == "online"
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"))
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:
coordinator = _stub_coordinator({"active_protocol": "wu"})
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
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"] == "PWS" # no ecowitt/wslink in stub options -> PWS
def test_sensor_unique_id_and_category() -> None:
coordinator = _stub_coordinator({})
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
assert sensor.unique_id == "active_protocol_health"
assert sensor.entity_category == hs.EntityCategory.DIAGNOSTIC

151
tests/test_init.py Normal file
View File

@ -0,0 +1,151 @@
"""Integration init tests using Home Assistant pytest fixtures.
These tests rely on `pytest-homeassistant-custom-component` to provide:
- `hass` fixture (running Home Assistant instance)
- `MockConfigEntry` helper for config entries
They validate that the integration can set up a config entry and that the
coordinator is created and stored in `hass.data`.
Note:
This integration registers aiohttp routes via `hass.http.app.router`. In this
test environment, `hass.http` may not be set up, so we patch route registration
to keep these tests focused on setup logic.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry
from custom_components.sws12500.const import DOMAIN
from custom_components.sws12500.data import SWSRuntimeData
from homeassistant.util import dt as dt_util
@pytest.fixture
def config_entry() -> MockConfigEntry:
"""Create a minimal config entry for the integration."""
return MockConfigEntry(domain=DOMAIN, data={}, options={})
async def test_async_setup_entry_creates_runtime_state(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""Setting up a config entry should succeed and populate hass.data."""
config_entry.add_to_hass(hass)
# `async_setup_entry` calls `register_path`, which needs `hass.http`.
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: True,
)
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
# network I/O) is mocked out to keep this test focused on setup wiring.
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
# Avoid depending on Home Assistant integration loader in this test.
# This keeps the test focused on our integration's setup behavior.
monkeypatch.setattr(
hass.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
result = await async_setup_entry(hass, config_entry)
assert result is True
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData), not in
# hass.data[DOMAIN][entry_id]. hass.data[DOMAIN] only holds shared route state.
assert DOMAIN in hass.data
assert isinstance(config_entry.runtime_data, SWSRuntimeData)
assert config_entry.runtime_data.coordinator is not None
assert config_entry.runtime_data.health_coordinator is not None
async def test_async_setup_entry_forwards_sensor_platform(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""The integration should forward entry setups to the sensor platform."""
config_entry.add_to_hass(hass)
# `async_setup_entry` calls `register_path`, which needs `hass.http`.
# Patch it out so the test doesn't depend on aiohttp being initialized.
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: True,
)
# Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the
# health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does
# network I/O) is mocked out to keep this test focused on setup wiring.
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
# Patch forwarding so we don't need to load real platforms for this unit/integration test.
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
result = await async_setup_entry(hass, config_entry)
assert result is True
hass.config_entries.async_forward_entry_setups.assert_awaited()
forwarded_entry, forwarded_platforms = (
hass.config_entries.async_forward_entry_setups.await_args.args
)
assert forwarded_entry.entry_id == config_entry.entry_id
assert "sensor" in list(forwarded_platforms)
async def test_weather_data_update_coordinator_can_be_constructed(
hass, config_entry: MockConfigEntry
):
"""Coordinator should be constructible with a real hass fixture."""
coordinator = WeatherDataUpdateCoordinator(hass, config_entry)
assert coordinator.hass is hass
assert coordinator.config is config_entry
async def test_check_stale_callback_runs_update(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""The hourly _check_stale callback registered during setup runs the stale check."""
config_entry.add_to_hass(hass)
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: True,
)
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
# Capture the time-interval callback async_setup_entry registers.
captured: dict = {}
def _capture(_hass, action, _interval):
captured["cb"] = action
return lambda: None
monkeypatch.setattr("custom_components.sws12500.async_track_time_interval", _capture)
stale = MagicMock()
monkeypatch.setattr("custom_components.sws12500.update_stale_sensors_issue", stale)
assert await async_setup_entry(hass, config_entry) is True
assert "cb" in captured
captured["cb"](dt_util.utcnow())
stale.assert_called_once_with(hass, config_entry)

View File

@ -0,0 +1,455 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import async_setup_entry, async_unload_entry, register_path, update_listener
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEFAULT_URL,
DOMAIN,
ECOWITT_URL_PREFIX,
HEALTH_URL,
SENSORS_TO_LOAD,
WSLINK,
WSLINK_URL,
)
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.health_coordinator import HealthCoordinator
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
@dataclass(slots=True)
class _RequestStub:
"""Minimal aiohttp Request stub used by `received_data`."""
query: dict[str, Any]
async def post(self) -> dict[str, Any]:
return {}
class _RouterStub:
"""Router stub that records route registrations."""
def __init__(self) -> None:
self.add_get_calls: list[tuple[str, Any]] = []
self.add_post_calls: list[tuple[str, Any]] = []
self.raise_on_add: Exception | None = None
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_get_calls.append((path, handler))
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_post_calls.append((path, handler))
return SimpleNamespace(method="POST")
@pytest.fixture
def hass_with_http(hass):
"""Provide a real HA hass fixture augmented with a stub http router."""
router = _RouterStub()
hass.http = SimpleNamespace(app=SimpleNamespace(router=router))
return hass
def _mock_health_first_refresh(monkeypatch) -> None:
"""Calling async_setup_entry directly leaves the entry NOT_LOADED.
The health coordinator's first refresh requires SETUP_IN_PROGRESS and does network
I/O, so we mock it out to keep these lifecycle tests focused on wiring.
"""
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
# --- register_path ---------------------------------------------------------
@pytest.mark.asyncio
async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http):
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
assert ok is True
# Router registrations: GET for legacy/wslink/health, POST for wslink + ecowitt.
router: _RouterStub = hass_with_http.http.app.router
assert [p for (p, _h) in router.add_get_calls] == [
DEFAULT_URL,
WSLINK_URL,
HEALTH_URL,
]
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL, ECOWITT_PATH]
# Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN].
assert DOMAIN in hass_with_http.data
routes = hass_with_http.data[DOMAIN].get("routes")
assert routes is not None
assert isinstance(routes.show_enabled(), str)
@pytest.mark.asyncio
async def test_register_path_raises_config_entry_not_ready_on_router_runtime_error(
hass_with_http,
):
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
router: _RouterStub = hass_with_http.http.app.router
router.raise_on_add = RuntimeError("router broken")
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, coordinator_health, entry)
@pytest.mark.asyncio
async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_not_ready(
hass_with_http,
):
"""Cover register_path branch where `checked(hass.data[DOMAIN], dict)` returns None."""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
hass_with_http.data[DOMAIN] = [] # wrong type -> checked(..., dict) fails
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, coordinator_health, entry)
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms(
hass_with_http,
monkeypatch,
):
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData).
assert isinstance(entry.runtime_data, SWSRuntimeData)
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
assert isinstance(entry.runtime_data.last_options, dict)
# Shared dispatcher registered under hass.data[DOMAIN].
assert "routes" in hass_with_http.data[DOMAIN]
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
@pytest.mark.asyncio
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 -> ConfigEntryNotReady."""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# No pre-registered routes -> async_setup_entry calls register_path.
hass_with_http.data.setdefault(DOMAIN, {})
hass_with_http.data[DOMAIN].pop("routes", None)
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: False,
)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
with pytest.raises(ConfigEntryNotReady):
await async_setup_entry(hass_with_http, entry)
@pytest.mark.asyncio
async def test_async_setup_entry_reuses_route_dispatcher_and_switches_protocol(
hass_with_http,
monkeypatch,
):
"""On reload the shared route dispatcher is reused; the coordinator is recreated."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# Pre-register routes once (legacy/WU active).
initial_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
initial_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, initial_coordinator, initial_health, entry)
routes_before = hass_with_http.data[DOMAIN]["routes"]
assert routes_before.path_enabled(DEFAULT_URL) is True
# Switch to WSLink and run setup again.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# Same dispatcher object reused (survives across reloads).
assert hass_with_http.data[DOMAIN]["routes"] is routes_before
# Protocol switched to WSLink.
assert routes_before.path_enabled(WSLINK_URL) is True
assert routes_before.path_enabled(DEFAULT_URL) is False
# A fresh coordinator is wired onto entry.runtime_data.
assert isinstance(entry.runtime_data, SWSRuntimeData)
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
# --- update_listener -------------------------------------------------------
def _entry_with_runtime(hass, *, options: dict[str, Any]) -> MockConfigEntry:
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(
coordinator=object(), # type: ignore[arg-type]
health_coordinator=object(), # type: ignore[arg-type]
last_options=dict(options),
)
return entry
@pytest.mark.asyncio
async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
hass_with_http,
):
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
)
hass_with_http.config_entries.async_reload = AsyncMock()
# Only SENSORS_TO_LOAD changes.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_not_awaited()
# The snapshot on runtime_data is refreshed.
assert entry.runtime_data.last_options == dict(entry.options)
@pytest.mark.asyncio
async def test_update_listener_triggers_reload_when_other_option_changes(
hass_with_http,
monkeypatch,
):
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
)
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500._LOGGER.info", info)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
info.assert_called()
@pytest.mark.asyncio
async def test_update_listener_without_runtime_snapshot_reloads(hass_with_http):
"""When runtime_data is not a valid snapshot, update_listener reloads."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# Not an SWSRuntimeData instance -> the skip-reload fast path is bypassed.
entry.runtime_data = "invalid"
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
# --- async_unload_entry ----------------------------------------------------
@pytest.mark.asyncio
async def test_async_unload_entry_returns_true_on_success(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is True
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_unload_entry_returns_false_on_failure(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is False
# --- coordinator auth (lifecycle-adjacent) ---------------------------------
@pytest.mark.asyncio
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
"""Cover coordinator auth behavior reachable from the webhook entrypoint."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
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
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"x": "y"})) # type: ignore[arg-type]
# Wrong credentials -> unauthorized
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "no"})
) # type: ignore[arg-type]
# 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(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # 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)."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
router: _RouterStub = hass_with_http.http.app.router
get_calls_after_first = list(router.add_get_calls)
post_calls_after_first = list(router.add_post_calls)
# Routes already a Routes instance -> else branch; nothing re-registered on aiohttp.
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
assert router.add_get_calls == get_calls_after_first
assert router.add_post_calls == post_calls_after_first

299
tests/test_pocasi_push.py Normal file
View File

@ -0,0 +1,299 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any, Literal
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientError
import pytest
from custom_components.sws12500.const import (
DEFAULT_URL,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_UNEXPECTED,
POCASI_CZ_URL,
POCASI_INVALID_KEY,
WSLINK_URL,
)
from custom_components.sws12500.pocasti_cz import PocasiApiKeyError, PocasiPush, PocasiSuccess
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _FakeResponse:
text_value: str
async def text(self) -> str:
return self.text_value
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class _FakeSession:
def __init__(
self, *, response: _FakeResponse | None = None, exc: Exception | None = None
):
self._response = response
self._exc = exc
self.calls: list[dict[str, Any]] = []
def get(self, url: str, *, params: dict[str, Any] | None = None):
self.calls.append({"url": url, "params": dict(params or {})})
if self._exc is not None:
raise self._exc
assert self._response is not None
return self._response
def _make_entry(
*,
api_id: str | None = "id",
api_key: str | None = "key",
interval: int = 30,
logger: bool = False,
) -> Any:
options: dict[str, Any] = {
POCASI_CZ_SEND_INTERVAL: interval,
POCASI_CZ_LOGGER_ENABLED: logger,
POCASI_CZ_ENABLED: True,
}
if api_id is not None:
options[POCASI_CZ_API_ID] = api_id
if api_key is not None:
options[POCASI_CZ_API_KEY] = api_key
entry = SimpleNamespace()
entry.options = options
entry.entry_id = "test_entry_id"
return entry
@pytest.fixture
def hass():
# Minimal hass-like object; we patch client session retrieval.
return SimpleNamespace()
@pytest.mark.asyncio
async def test_push_data_to_server_missing_api_id_returns_early(monkeypatch, hass):
entry = _make_entry(api_id=None, api_key="key")
pp = PocasiPush(hass, entry)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_server_missing_api_key_returns_early(monkeypatch, hass):
entry = _make_entry(api_id="id", api_key=None)
pp = PocasiPush(hass, entry)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_server_respects_interval_limit(monkeypatch, hass):
entry = _make_entry(interval=30, logger=True)
pp = PocasiPush(hass, entry)
# Ensure "next_update > now" so it returns early before doing HTTP.
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert session.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode,expected_path", [("WU", DEFAULT_URL), ("WSLINK", WSLINK_URL)]
)
async def test_push_data_to_server_injects_auth_and_chooses_url(
monkeypatch, hass, mode: Literal["WU", "WSLINK"], expected_path: str
):
entry = _make_entry(api_id="id", api_key="key")
pp = PocasiPush(hass, entry)
# Force send now.
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
# Avoid depending on anonymize output; just make it deterministic.
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
await pp.push_data_to_server({"temp": 1}, mode)
assert len(session.calls) == 1
call = session.calls[0]
assert call["url"] == f"{POCASI_CZ_URL}{expected_path}"
params = call["params"]
if mode == "WU":
assert params["ID"] == "id"
assert params["PASSWORD"] == "key"
else:
assert params["wsid"] == "id"
assert params["wspw"] == "key"
@pytest.mark.asyncio
async def test_push_data_to_server_calls_verify_response(monkeypatch, hass):
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
verify = MagicMock(return_value=None)
monkeypatch.setattr(pp, "verify_response", verify)
await pp.push_data_to_server({"x": 1}, "WU")
verify.assert_called_once_with("OK")
@pytest.mark.asyncio
async def test_push_data_to_server_api_key_error_disables_feature(monkeypatch, hass):
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
def _raise(_status: str):
raise PocasiApiKeyError
monkeypatch.setattr(pp, "verify_response", _raise)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", crit)
await pp.push_data_to_server({"x": 1}, "WU")
crit.assert_called()
# Should log invalid key message and disable feature.
assert any(
POCASI_INVALID_KEY in str(c.args[0]) for c in crit.call_args_list if c.args
)
update_options.assert_awaited_once_with(hass, entry, POCASI_CZ_ENABLED, False)
@pytest.mark.asyncio
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 = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
def _raise_success(_status: str):
raise PocasiSuccess
monkeypatch.setattr(pp, "verify_response", _raise_success)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.info", info)
await pp.push_data_to_server({"x": 1}, "WU")
info.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_server_client_error_increments_and_disables_after_three(
monkeypatch, hass
):
entry = _make_entry()
pp = PocasiPush(hass, entry)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", crit)
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
# Force request attempts and exceed invalid count threshold.
for _i in range(4):
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 4
# Should disable after >3
update_options.assert_awaited()
args = update_options.await_args.args
assert args[2] == POCASI_CZ_ENABLED
assert args[3] is False
# Should log unexpected at least once
assert any(
POCASI_CZ_UNEXPECTED in str(c.args[0]) for c in crit.call_args_list if c.args
)
def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.debug", dbg)
assert pp.verify_response("anything") is None
dbg.assert_called()

533
tests/test_received_data.py Normal file
View File

@ -0,0 +1,533 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEFAULT_URL,
DOMAIN,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
WSLINK_URL,
)
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _RequestStub:
"""Minimal aiohttp Request stub.
The coordinator uses `webdata.query` and `await webdata.post()`.
"""
query: dict[str, Any]
post_data: dict[str, Any] | None = None
async def post(self) -> dict[str, Any]:
return self.post_data or {}
def _make_entry(
*,
wslink: bool = False,
api_id: str | None = "id",
api_key: str | None = "key",
windy_enabled: bool = False,
pocasi_enabled: bool = False,
dev_debug: bool = False,
) -> Any:
"""Create a minimal config entry stub with `.options` and `.entry_id`."""
options: dict[str, Any] = {
WSLINK: wslink,
WINDY_ENABLED: windy_enabled,
POCASI_CZ_ENABLED: pocasi_enabled,
"dev_debug_checkbox": dev_debug,
}
if api_id is not None:
options[API_ID] = api_id
if api_key is not None:
options[API_KEY] = api_key
entry = SimpleNamespace()
entry.entry_id = "test_entry_id"
entry.options = options
# DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a
# config_entry is passed (see WeatherDataUpdateCoordinator.__init__).
entry.async_on_unload = lambda *_args, **_kwargs: None
# Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0.
# received_data writes last_seen and reads health_coordinator / add_*_entities.
entry.runtime_data = SimpleNamespace(
health_coordinator=None,
add_sensor_entities=None,
add_binary_entities=None,
last_seen={},
started_at=dt_util.utcnow(),
)
return entry
@pytest.mark.asyncio
async def test_received_data_wu_missing_security_params_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=False)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# No ID/PASSWORD -> unauthorized
request = _RequestStub(query={"foo": "bar"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_wslink_missing_security_params_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# No wsid/wspw -> unauthorized
request = _RequestStub(query={"foo": "bar"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_missing_api_id_in_options_raises_incorrect_data_error(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id=None, api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
with pytest.raises(IncorrectDataError):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_missing_api_key_in_options_raises_incorrect_data_error(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key=None)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
with pytest.raises(IncorrectDataError):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_wrong_credentials_raises_http_unauthorized(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _RequestStub(query={"ID": "id", "PASSWORD": "wrong"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_success_remaps_and_updates_coordinator_data(
hass, monkeypatch
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Patch remapping so this test doesn't depend on mapping tables.
remapped = {"outside_temp": "10"}
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_items",
lambda _data: remapped,
)
# Ensure no autodiscovery triggers
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
# Capture updates
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "tempf": "50"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch):
entry = _make_entry(wslink=True, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"ws_temp": "1"}
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_wslink_items",
lambda _data: remapped,
)
# If the wrong remapper is used, we'd crash because we won't patch it:
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"wsid": "id", "wspw": "key", "t": "1"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch):
entry = _make_entry(wslink=False, api_id="id", api_key="key", windy_enabled=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.windy.push_data_to_windy = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_items",
lambda _data: {"k": "v"},
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.windy.push_data_to_windy.assert_awaited_once()
args, _kwargs = coordinator.windy.push_data_to_windy.await_args
assert isinstance(args[0], dict) # raw data dict
assert args[1] is False # wslink flag
@pytest.mark.asyncio
async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch):
entry = _make_entry(wslink=True, api_id="id", api_key="key", pocasi_enabled=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.pocasi.push_data_to_server = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_wslink_items",
lambda _data: {"k": "v"},
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"wsid": "id", "wspw": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.pocasi.push_data_to_server.assert_awaited_once()
args, _kwargs = coordinator.pocasi.push_data_to_server.await_args
assert isinstance(args[0], dict) # raw data dict
assert args[1] == "WSLINK"
@pytest.mark.asyncio
async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sensors(
hass,
monkeypatch,
):
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Arrange: remapped payload contains keys that are disabled.
remapped = {"a": "1", "b": "2"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
# Autodiscovery finds two sensors to add
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a", "b"],
)
# No previously loaded sensors
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
# translations returns a friendly name for each sensor key
async def _translations(_hass, _domain, _key, **_kwargs):
# return something non-None so it's included in human readable string
return "Name"
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
update_options = AsyncMock()
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
add_new_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
# It should notify
translated_notification.assert_awaited()
# It should persist newly discovered sensors
update_options.assert_awaited()
args, _kwargs = update_options.await_args
assert args[2] == SENSORS_TO_LOAD
assert set(args[3]) >= {"a", "b"}
# It should add new sensors dynamically
add_new_sensors.assert_called_once()
_hass_arg, _entry_arg, keys = add_new_sensors.call_args.args
assert _hass_arg is hass
assert _entry_arg is entry
assert set(keys) == {"a", "b"}
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_autodiscovery_human_readable_empty_branch_via_checked_none(
hass,
monkeypatch,
):
"""Force `checked([...], list[str])` to return None so `human_readable = ""` branch is executed."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"a": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a"],
)
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
# Return a translation so the list comprehension would normally include an item.
async def _translations(_hass, _domain, _key, **_kwargs):
return "Name"
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
# Force checked(...) to return None when the code tries to validate translate_sensors as list[str].
def _checked_override(value, expected_type):
if expected_type == list[str]:
return None
return value
monkeypatch.setattr("custom_components.sws12500.coordinator.checked", _checked_override)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
update_options = AsyncMock()
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
add_new_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
# Ensure it still notifies (with empty human readable list)
translated_notification.assert_awaited()
# And persists sensors
update_options.assert_awaited()
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch(
hass, monkeypatch
):
"""Cover `_loaded_sensors := loaded_sensors(self.config)` branch (extend existing)."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"new": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
# Autodiscovery finds one new sensor
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["new"],
)
# Pretend there are already loaded sensors in options
monkeypatch.setattr(
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
)
async def _translations(_hass, _domain, _key, **_kwargs):
return "Name"
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", AsyncMock()
)
update_options = AsyncMock()
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
)
coordinator.async_set_updated_data = MagicMock()
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 200
# Ensure the persisted list includes both new and existing sensors
update_options.assert_awaited()
args, _kwargs = update_options.await_args
assert args[2] == SENSORS_TO_LOAD
assert set(args[3]) >= {"new", "existing"}
@pytest.mark.asyncio
async def test_received_data_autodiscovery_translations_all_none_still_notifies_and_updates(
hass, monkeypatch
):
"""Cover the branch where translated sensor names cannot be resolved (human_readable becomes empty)."""
entry = _make_entry(wslink=False, api_id="id", api_key="key")
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"a": "1"}
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: ["a"],
)
monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: [])
# Force translations to return None for every lookup -> translate_sensors becomes None and human_readable ""
async def _translations(_hass, _domain, _key, **_kwargs):
return None
monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations)
translated_notification = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", translated_notification
)
update_options = AsyncMock()
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options)
add_new_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
)
coordinator.async_set_updated_data = MagicMock()
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 200
translated_notification.assert_awaited()
update_options.assert_awaited()
add_new_sensors.assert_called_once()
coordinator.async_set_updated_data.assert_called_once_with(remapped)
@pytest.mark.asyncio
async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypatch):
entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: {"k": "v"})
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _remaped_items, _config: [],
)
anonymize = MagicMock(return_value={"safe": True})
monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize)
log_info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info)
coordinator.async_set_updated_data = MagicMock()
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"})
resp = await coordinator.received_data(request) # type: ignore[arg-type]
assert resp.status == 200
anonymize.assert_called_once()
log_info.assert_called_once()
@pytest.mark.asyncio
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

@ -0,0 +1,597 @@
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEV_DBG,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
POCASI_CZ_ENABLED,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WSLINK,
)
from custom_components.sws12500.coordinator import WeatherDataUpdateCoordinator
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _EcowittRequestStub:
"""Minimal aiohttp Request stub for the Ecowitt endpoint.
The coordinator uses `webdata.match_info.get("webhook_id", "")` and
`await webdata.post()`.
"""
match_info: dict[str, Any] = field(default_factory=dict)
post_data: dict[str, Any] | None = None
async def post(self) -> dict[str, Any]:
return self.post_data or {}
@dataclass(slots=True)
class _RequestStub:
"""Minimal aiohttp Request stub for the legacy endpoint.
The coordinator uses `webdata.query` and `await webdata.post()`.
"""
query: dict[str, Any]
post_data: dict[str, Any] | None = None
async def post(self) -> dict[str, Any]:
return self.post_data or {}
def _make_entry(
*,
wslink: bool = False,
api_id: str | None = "id",
api_key: str | None = "key",
windy_enabled: bool = False,
pocasi_enabled: bool = False,
dev_debug: bool = False,
ecowitt_enabled: bool = False,
ecowitt_webhook_id: str | None = None,
health: Any = None,
) -> Any:
"""Create a minimal config entry stub with `.options` and `.entry_id`."""
options: dict[str, Any] = {
WSLINK: wslink,
WINDY_ENABLED: windy_enabled,
POCASI_CZ_ENABLED: pocasi_enabled,
ECOWITT_ENABLED: ecowitt_enabled,
DEV_DBG: dev_debug,
}
if api_id is not None:
options[API_ID] = api_id
if api_key is not None:
options[API_KEY] = api_key
if ecowitt_webhook_id is not None:
options[ECOWITT_WEBHOOK_ID] = ecowitt_webhook_id
entry = SimpleNamespace()
entry.entry_id = "test_entry_id"
entry.options = options
# DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a
# config_entry is passed (see WeatherDataUpdateCoordinator.__init__).
entry.async_on_unload = lambda *_args, **_kwargs: None
# Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0.
entry.runtime_data = SimpleNamespace(
health_coordinator=health,
add_sensor_entities=None,
add_binary_entities=None,
last_seen={},
started_at=dt_util.utcnow(),
)
return entry
def _make_health_stub() -> Any:
"""Create a MagicMock-based stub for the HealthCoordinator branches."""
return SimpleNamespace(
update_ingress_result=MagicMock(),
update_forwarding=MagicMock(),
)
# ---------------------------------------------------------------------------
# received_ecowitt_data
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_received_ecowitt_disabled_returns_403_and_reports_health(hass, monkeypatch):
"""Branch 1: Ecowitt disabled -> 403 and health.update_ingress_result(disabled)."""
health = _make_health_stub()
entry = _make_entry(ecowitt_enabled=False, health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 403
assert resp.text == "Ecowitt disabled"
health.update_ingress_result.assert_called_once()
_args, kwargs = health.update_ingress_result.call_args
assert kwargs["accepted"] is False
assert kwargs["authorized"] is None
assert kwargs["reason"] == "ecowitt_disabled"
@pytest.mark.asyncio
async def test_received_ecowitt_disabled_no_health(hass, monkeypatch):
"""Branch 1 without health: covers the `if health` False edge for disabled."""
entry = _make_entry(ecowitt_enabled=False, health=None)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 403
@pytest.mark.asyncio
async def test_received_ecowitt_missing_webhook_id_raises_unauthorized(hass, monkeypatch):
"""Branch 2: enabled but expected webhook id missing -> HTTPUnauthorized."""
health = _make_health_stub()
entry = _make_entry(
ecowitt_enabled=True, ecowitt_webhook_id=None, health=health
)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _EcowittRequestStub(match_info={"webhook_id": "abc"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_args, kwargs = health.update_ingress_result.call_args
assert kwargs["accepted"] is False
assert kwargs["authorized"] is False
assert kwargs["reason"] == "ecowitt_invalid_webhook_id"
@pytest.mark.asyncio
async def test_received_ecowitt_mismatched_webhook_id_raises_unauthorized(hass, monkeypatch):
"""Branch 2: enabled but webhook id mismatch -> HTTPUnauthorized (no health)."""
entry = _make_entry(
ecowitt_enabled=True, ecowitt_webhook_id="expected", health=None
)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
request = _EcowittRequestStub(match_info={"webhook_id": "wrong"})
with pytest.raises(HTTPUnauthorized):
await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_forwarding_devlog(
hass, monkeypatch
):
"""Branch 3 success: covers lines 132-172.
- process_payload returns a mapped dict
- check_disabled returns new keys -> autodiscovery (update_options,
add_new_binary_sensors, add_new_sensors)
- async_set_updated_data + last_seen + update_stale_sensors_issue
- health.update_ingress_result(accepted) + windy + pocasi forwarding
- health.update_forwarding
- dev log via anonymize
"""
health = _make_health_stub()
entry = _make_entry(
ecowitt_enabled=True,
ecowitt_webhook_id="hook",
windy_enabled=True,
pocasi_enabled=True,
dev_debug=True,
health=health,
)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
mapped = {"outside_temp": "20"}
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
# Autodiscovery: one new sensor key.
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: ["outside_temp"],
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []
)
update_options = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_options", update_options
)
add_new_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
)
add_new_binary_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_binary_sensors",
add_new_binary_sensors,
)
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.windy.push_data_to_windy = AsyncMock()
coordinator.pocasi.push_data_to_server = AsyncMock()
anonymize = MagicMock(return_value={"safe": True})
monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize)
log_info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info)
coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(
match_info={"webhook_id": "hook"}, post_data={"tempf": "68", "model": "GW2000A"}
)
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
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
assert args[2] == SENSORS_TO_LOAD
assert "outside_temp" in args[3]
add_new_sensors.assert_called_once_with(hass, entry, ["outside_temp"])
add_new_binary_sensors.assert_called_once_with(hass, entry, ["outside_temp"])
# Coordinator data + staleness + last_seen.
coordinator.async_set_updated_data.assert_called_once_with(mapped)
update_stale.assert_called_once()
assert "outside_temp" in entry.runtime_data.last_seen
# Forwarding: windy receives the raw data dict + False, pocasi receives "WU".
coordinator.windy.push_data_to_windy.assert_awaited_once()
w_args, _ = coordinator.windy.push_data_to_windy.await_args
assert isinstance(w_args[0], dict)
assert w_args[1] is False
coordinator.pocasi.push_data_to_server.assert_awaited_once()
p_args, _ = coordinator.pocasi.push_data_to_server.await_args
assert p_args[1] == "WU"
# Health branches.
health.update_ingress_result.assert_called_once()
_ia, ikw = health.update_ingress_result.call_args
assert ikw["accepted"] is True
assert ikw["authorized"] is True
assert ikw["reason"] == "accepted"
health.update_forwarding.assert_called_once_with(coordinator.windy, coordinator.pocasi)
# Dev log.
anonymize.assert_called_once()
log_info.assert_called_once()
@pytest.mark.asyncio
async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding(
hass, monkeypatch
):
"""Branch 3 success with all optional branches False.
- health is None (skips both `if health` blocks)
- check_disabled returns [] (skips autodiscovery body)
- windy/pocasi disabled (skips forwarding)
- dev debug off (skips dev log)
"""
entry = _make_entry(
ecowitt_enabled=True,
ecowitt_webhook_id="hook",
windy_enabled=False,
pocasi_enabled=False,
dev_debug=False,
health=None,
)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
mapped = {"outside_temp": "20"}
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: [],
)
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.windy.push_data_to_windy = AsyncMock()
coordinator.pocasi.push_data_to_server = AsyncMock()
coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_called_once_with(mapped)
update_stale.assert_called_once()
coordinator.windy.push_data_to_windy.assert_not_awaited()
coordinator.pocasi.push_data_to_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_received_ecowitt_autodiscovery_extends_with_loaded_sensors(hass, monkeypatch):
"""Line 138: cover the `_loaded_sensors := loaded_sensors(...)` extend branch."""
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook", health=None)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
mapped = {"new": "1"}
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: ["new"],
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"]
)
update_options = AsyncMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", MagicMock()
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock()
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
)
coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
update_options.assert_awaited_once()
args, _kwargs = update_options.await_args
assert args[2] == SENSORS_TO_LOAD
assert set(args[3]) >= {"new", "existing"}
@pytest.mark.asyncio
async def test_health_coordinator_attribute_error_returns_none(hass, monkeypatch):
"""Lines 92-93: runtime_data without health_coordinator -> AttributeError -> None."""
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook")
# Replace runtime_data with one that lacks `health_coordinator`.
entry.runtime_data = SimpleNamespace(last_seen={})
coordinator = WeatherDataUpdateCoordinator(hass, entry)
assert coordinator._health_coordinator() is None
mapped = {"outside_temp": "20"}
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value=mapped)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _mapped, _config: [],
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
)
coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
@pytest.mark.asyncio
async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatch):
"""Branch 3 success but process_payload returns empty -> skips the `if mapped_data` block."""
entry = _make_entry(ecowitt_enabled=True, ecowitt_webhook_id="hook", health=None)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={})
update_stale = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
update_stale,
)
coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
assert resp.status == 200
coordinator.async_set_updated_data.assert_not_called()
update_stale.assert_not_called()
# ---------------------------------------------------------------------------
# received_data health branches (lines 196, 207, 225, 236, 252, 304, 321)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_received_data_wu_missing_credentials_reports_health(hass, monkeypatch):
"""Line 196: WU missing credentials -> health.update_ingress_result."""
health = _make_health_stub()
entry = _make_entry(wslink=False, health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"foo": "bar"})) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_a, kw = health.update_ingress_result.call_args
assert kw["reason"] == "missing_credentials"
assert kw["accepted"] is False
assert kw["authorized"] is False
@pytest.mark.asyncio
async def test_received_data_wslink_missing_credentials_reports_health(hass, monkeypatch):
"""Line 207: WSLink missing credentials -> health.update_ingress_result."""
health = _make_health_stub()
entry = _make_entry(wslink=True, health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"foo": "bar"})) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_a, kw = health.update_ingress_result.call_args
assert kw["reason"] == "missing_credentials"
@pytest.mark.asyncio
async def test_received_data_missing_api_id_reports_health(hass, monkeypatch):
"""Line 225: missing API ID -> health.update_ingress_result(config_missing_api_id)."""
from custom_components.sws12500.coordinator import IncorrectDataError
health = _make_health_stub()
entry = _make_entry(wslink=False, api_id=None, api_key="key", health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(IncorrectDataError):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_a, kw = health.update_ingress_result.call_args
assert kw["reason"] == "config_missing_api_id"
assert kw["authorized"] is None
@pytest.mark.asyncio
async def test_received_data_missing_api_key_reports_health(hass, monkeypatch):
"""Line 236: missing API KEY -> health.update_ingress_result(config_missing_api_key)."""
from custom_components.sws12500.coordinator import IncorrectDataError
health = _make_health_stub()
entry = _make_entry(wslink=False, api_id="id", api_key=None, health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(IncorrectDataError):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_a, kw = health.update_ingress_result.call_args
assert kw["reason"] == "config_missing_api_key"
@pytest.mark.asyncio
async def test_received_data_wrong_credentials_reports_health(hass, monkeypatch):
"""Line 252: wrong credentials -> health.update_ingress_result(unauthorized)."""
health = _make_health_stub()
entry = _make_entry(wslink=False, api_id="id", api_key="key", health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "wrong"})
) # type: ignore[arg-type]
health.update_ingress_result.assert_called_once()
_a, kw = health.update_ingress_result.call_args
assert kw["reason"] == "unauthorized"
assert kw["authorized"] is False
@pytest.mark.asyncio
async def test_received_data_success_with_health_autodiscovery_and_binary(hass, monkeypatch):
"""Lines 304 + 321: success path with health stub and autodiscovery.
- check_disabled returns a key -> add_new_binary_sensors (line 294/304-region)
- health accepted branch (line 304) + health.update_forwarding (line 321)
"""
health = _make_health_stub()
entry = _make_entry(wslink=False, api_id="id", api_key="key", health=health)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
remapped = {"x": "1"}
monkeypatch.setattr(
"custom_components.sws12500.coordinator.remap_items", lambda _d: remapped
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.check_disabled",
lambda _r, _c: ["x"],
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []
)
async def _translations(_hass, _domain, _key, **_kwargs):
return "Name"
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translations", _translations
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.translated_notification", AsyncMock()
)
monkeypatch.setattr(
"custom_components.sws12500.coordinator.update_options", AsyncMock()
)
add_new_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors
)
add_new_binary_sensors = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.coordinator.add_new_binary_sensors",
add_new_binary_sensors,
)
coordinator.async_set_updated_data = MagicMock()
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 200
add_new_binary_sensors.assert_called_once()
# Health accepted (line 304) and forwarding (line 321).
assert health.update_ingress_result.call_count == 1
_a, kw = health.update_ingress_result.call_args
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

105
tests/test_routes.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Awaitable, Callable
from aiohttp.web import Response
import pytest
from custom_components.sws12500.routes import Routes, unregistered
Handler = Callable[["_RequestStub"], Awaitable[Response]]
@dataclass(slots=True)
class _RequestStub:
"""Minimal request stub for unit-testing the dispatcher.
`Routes.dispatch` relies on `request.method` and `request.path`.
`unregistered` accepts a request object but does not use it.
"""
method: str
path: str
@dataclass(slots=True)
class _RouteStub:
"""Minimal route stub providing `method` expected by Routes.add_route`."""
method: str
@pytest.fixture
def routes() -> Routes:
return Routes()
async def test_dispatch_unknown_path_calls_unregistered(routes: Routes) -> None:
request = _RequestStub(method="GET", path="/unregistered")
response = await routes.dispatch(request) # type: ignore[arg-type]
assert response.status == 400
async def test_unregistered_handler_returns_400() -> None:
request = _RequestStub(method="GET", path="/invalid")
response = await unregistered(request) # type: ignore[arg-type]
assert response.status == 400
async def test_dispatch_registered_but_disabled_uses_fallback(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 400
async def test_dispatch_registered_and_enabled_uses_handler(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=201)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=True)
response = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert response.status == 201
def test_switch_route_enables_exactly_one(routes: Routes) -> None:
async def handler_a(_request: _RequestStub) -> Response:
return Response(text="A", status=200)
async def handler_b(_request: _RequestStub) -> Response:
return Response(text="B", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler_a, enabled=True)
routes.add_route("/b", _RouteStub(method="GET"), handler_b, enabled=False)
routes.switch_route(handler_b, "/b")
assert routes.routes["GET:/a"].enabled is False
assert routes.routes["GET:/b"].enabled is True
def test_show_enabled_returns_message_when_none_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=False)
assert routes.show_enabled() == "No routes are enabled."
def test_show_enabled_includes_url_when_enabled(routes: Routes) -> None:
async def handler(_request: _RequestStub) -> Response:
return Response(text="OK", status=200)
routes.add_route("/a", _RouteStub(method="GET"), handler, enabled=False)
routes.add_route("/b", _RouteStub(method="GET"), handler, enabled=True)
msg = routes.show_enabled()
assert "Dispatcher enabled for (GET):/b" in msg
assert "handler" in msg

82
tests/test_routes_more.py Normal file
View File

@ -0,0 +1,82 @@
"""Additional Routes coverage: __str__, ingress observer calls, canonical fallback."""
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import MagicMock
from aiohttp.web import Response
import pytest
from custom_components.sws12500.routes import RouteInfo, Routes
@dataclass(slots=True)
class _RouteStub:
method: str
@dataclass(slots=True)
class _RequestStub:
method: str
path: str
@pytest.fixture
def routes() -> Routes:
return Routes()
async def _handler(_request) -> Response:
return Response(text="OK", status=200)
def test_routeinfo_str_contains_fields() -> None:
info = RouteInfo("/a", route=_RouteStub(method="GET"), handler=_handler, enabled=True)
text = str(info)
assert "RouteInfo(" in text
assert "url_path=/a" in text
assert "enabled=True" in text
async def test_dispatch_unknown_path_notifies_observer(routes: Routes) -> None:
observer = MagicMock()
routes.set_ingress_observer(observer)
await routes.dispatch(_RequestStub(method="GET", path="/nope")) # type: ignore[arg-type]
observer.assert_called_once()
args = observer.call_args.args
assert args[1] is False
assert args[2] == "route_not_registered"
async def test_dispatch_known_path_notifies_observer(routes: Routes) -> None:
routes.add_route("/a", _RouteStub(method="GET"), _handler, enabled=True)
observer = MagicMock()
routes.set_ingress_observer(observer)
resp = await routes.dispatch(_RequestStub(method="GET", path="/a")) # type: ignore[arg-type]
assert resp.status == 200
observer.assert_called_once()
args = observer.call_args.args
assert args[1] is True
assert args[2] is None
async def test_dispatch_resolves_via_canonical_resource(routes: Routes) -> None:
"""A path with a parameter resolves through the aiohttp resource canonical URL."""
canonical = "/weatherhub/{webhook_id}"
routes.add_route(canonical, _RouteStub(method="POST"), _handler, enabled=True)
# Direct key "POST:/weatherhub/abc" is absent; fall back to resource.canonical.
request = SimpleNamespace(
method="POST",
path="/weatherhub/abc",
match_info=SimpleNamespace(route=SimpleNamespace(resource=SimpleNamespace(canonical=canonical))),
)
resp = await routes.dispatch(request) # type: ignore[arg-type]
assert resp.status == 200

View File

@ -0,0 +1,220 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from custom_components.sws12500.const import (
CHILL_INDEX,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
SENSORS_TO_LOAD,
WIND_AZIMUT,
WIND_DIR,
WIND_SPEED,
WSLINK,
)
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.sensor import (
WeatherSensor,
_auto_enable_derived_sensors,
add_new_sensors,
async_setup_entry,
)
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
class _EcowittBridgeStub:
"""Records the platform callback the sensor setup wires into the bridge."""
def __init__(self) -> None:
self.add_entities_cb: Any = None
def set_add_entities(self, callback: Any) -> None:
self.add_entities_cb = callback
class _CoordinatorStub:
"""Minimal coordinator stub for WeatherSensor and platform setup."""
def __init__(self, data: dict[str, Any] | None = None, *, options: dict[str, Any] | None = None) -> None:
self.data = data if data is not None else {}
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag.
self.config = SimpleNamespace(options=options if options is not None else {})
self.ecowitt_bridge = _EcowittBridgeStub()
class _HealthCoordinatorStub:
"""Stand-in for HealthCoordinator (health diagnostic sensors subscribe to it)."""
def __init__(self) -> None:
self.data: dict[str, Any] = {}
def _make_entry(
*, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None
) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]:
"""Build a config-entry stub carrying typed runtime_data, like the integration does."""
coordinator = coordinator or _CoordinatorStub()
runtime = SWSRuntimeData(
coordinator=coordinator, # type: ignore[arg-type]
health_coordinator=_HealthCoordinatorStub(), # type: ignore[arg-type]
last_options={},
)
entry = SimpleNamespace(
entry_id="test_entry_id",
options=options if options is not None else {},
runtime_data=runtime,
)
return entry, coordinator, runtime
@pytest.fixture
def hass():
# Sensor platform setup only forwards hass to health_sensor.async_setup_entry,
# which ignores it, and add_new_sensors deletes it. A tiny stub is enough.
class _Hass:
def __init__(self) -> None:
self.data: dict[str, Any] = {}
return _Hass()
def _capture_add_entities():
captured: list[Any] = []
def _add_entities(entities: list[Any]) -> None:
captured.extend(entities)
return captured, _add_entities
def _weather_keys(captured: list[Any]) -> set[str]:
return {e.entity_description.key for e in captured if isinstance(e, WeatherSensor)}
# --- _auto_enable_derived_sensors ------------------------------------------
def test_auto_enable_derived_sensors_wind_azimut():
expanded = _auto_enable_derived_sensors({WIND_DIR})
assert WIND_DIR in expanded
assert WIND_AZIMUT in expanded
def test_auto_enable_derived_sensors_heat_index():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY})
assert HEAT_INDEX in expanded
def test_auto_enable_derived_sensors_chill_index():
expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED})
assert CHILL_INDEX in expanded
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_setup_stores_callback_and_descriptions_even_without_sensors_to_load(hass):
entry, coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
# Callback + description map persisted for dynamic entity creation.
assert runtime.add_sensor_entities is add_entities
assert isinstance(runtime.sensor_descriptions, dict)
# Ecowitt bridge wired up even though there are no sensors to load yet.
assert coordinator.ecowitt_bridge.add_entities_cb is add_entities
# No weather sensors created (only health diagnostics, which we ignore here).
assert _weather_keys(captured) == set()
@pytest.mark.asyncio
async def test_setup_selects_weather_api_descriptions_when_wslink_disabled(hass):
entry, _coordinator, runtime = _make_entry(options={WSLINK: False})
_captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API}
@pytest.mark.asyncio
async def test_setup_selects_wslink_descriptions_when_wslink_enabled(hass):
entry, _coordinator, runtime = _make_entry(options={WSLINK: True})
_captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK}
@pytest.mark.asyncio
async def test_setup_adds_requested_entities_and_auto_enables_derived(hass):
entry, _coordinator, _runtime = _make_entry(
options={
WSLINK: False,
SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED],
}
)
captured, add_entities = _capture_add_entities()
await async_setup_entry(hass, entry, add_entities)
keys_added = _weather_keys(captured)
# Requested.
assert {WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED} <= keys_added
# Derived.
assert {WIND_AZIMUT, HEAT_INDEX, CHILL_INDEX} <= keys_added
# --- add_new_sensors -------------------------------------------------------
def test_add_new_sensors_is_noop_when_callback_missing(hass):
entry, _coordinator, runtime = _make_entry()
# Platform not set up yet -> no stored callback.
assert runtime.add_sensor_entities is None
# Must not raise.
add_new_sensors(hass, entry, keys=["anything"])
def test_add_new_sensors_ignores_unknown_keys(hass):
entry, _coordinator, runtime = _make_entry()
add_entities = MagicMock()
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = {} # nothing known
add_new_sensors(hass, entry, keys=["unknown_key"])
add_entities.assert_not_called()
def test_add_new_sensors_adds_known_keys(hass):
entry, _coordinator, runtime = _make_entry()
add_entities = MagicMock()
known_desc = SENSOR_TYPES_WEATHER_API[0]
runtime.add_sensor_entities = add_entities
runtime.sensor_descriptions = {known_desc.key: known_desc}
add_new_sensors(hass, entry, keys=[known_desc.key])
add_entities.assert_called_once()
(entities_arg,) = add_entities.call_args.args
assert isinstance(entities_arg, list)
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

@ -0,0 +1,6 @@
# Test file for sensors_common.py module
def test_sensors_common_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,6 @@
# Test file for sensors_weather.py module
def test_sensors_weather_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,10 @@
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
def test_sensor_types_wslink_structure():
assert isinstance(SENSOR_TYPES_WSLINK, tuple)
assert len(SENSOR_TYPES_WSLINK) > 0
for sensor in SENSOR_TYPES_WSLINK:
assert hasattr(sensor, "key")
assert hasattr(sensor, "native_unit_of_measurement")

View File

@ -0,0 +1,143 @@
"""Tests for stale-sensor and legacy-battery Repairs issues.
Covers:
- staleness.py: warmup short-circuit, stale detection (never seen / seen long ago),
fresh sensors clearing the issue, and the create/delete branches of
`update_stale_sensors_issue`.
- legacy.py: orphan legacy battery sensor detection and the create/delete branches
of `update_legacy_battery_issue`.
Uses the real `hass` fixture so the issue/entity registries behave like production.
"""
from __future__ import annotations
from datetime import timedelta
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.const import DOMAIN, SENSORS_TO_LOAD
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.legacy import _legacy_battery_issue_id, update_legacy_battery_issue
from custom_components.sws12500.staleness import _find_stale_keys, _stale_sensor_issue_id, update_stale_sensors_issue
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.util import dt as dt_util
def _make_entry(hass, options: dict) -> MockConfigEntry:
"""Create a config entry with typed runtime data attached and added to hass."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(
coordinator=object(), # type: ignore[arg-type]
health_coordinator=object(), # type: ignore[arg-type]
last_options=dict(options),
)
return entry
# --------------------------------------------------------------------------- #
# staleness.py
# --------------------------------------------------------------------------- #
async def test_warmup_returns_no_stale_keys_and_no_issue(hass):
"""During the warmup period no key is considered stale and no issue is raised."""
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
# started_at = now -> within WARMUP_PERIOD even with a never-seen key.
entry.runtime_data.started_at = dt_util.utcnow()
assert _find_stale_keys(entry) == []
update_stale_sensors_issue(hass, entry)
issue_id = _stale_sensor_issue_id(entry)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
async def test_never_seen_key_is_stale_and_creates_issue(hass):
"""Past warmup, a loaded key that was never seen is stale -> issue created."""
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
# last_seen left empty -> outside_temp never reported.
assert _find_stale_keys(entry) == ["outside_temp"]
update_stale_sensors_issue(hass, entry)
issue_id = _stale_sensor_issue_id(entry)
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_key == "stale_sensors_detected"
assert issue.translation_placeholders == {"sensors": "outside_temp"}
async def test_recently_seen_key_is_not_stale_and_clears_issue(hass):
"""Past warmup, a key seen just now is fresh -> issue absent/cleared."""
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow()}
assert _find_stale_keys(entry) == []
update_stale_sensors_issue(hass, entry)
issue_id = _stale_sensor_issue_id(entry)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
async def test_key_seen_long_ago_is_stale(hass):
"""A key last seen beyond STALE_THRESHOLD (24h) is stale."""
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow() - timedelta(hours=48)}
assert _find_stale_keys(entry) == ["outside_temp"]
async def test_existing_stale_issue_is_deleted_when_keys_become_fresh(hass):
"""A previously-created stale issue is cleared once the sensor reports again."""
entry = _make_entry(hass, {SENSORS_TO_LOAD: ["outside_temp"]})
entry.runtime_data.started_at = dt_util.utcnow() - timedelta(hours=2)
# First pass: stale -> issue created.
update_stale_sensors_issue(hass, entry)
issue_id = _stale_sensor_issue_id(entry)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
# Sensor reports -> second pass clears the issue.
entry.runtime_data.last_seen = {"outside_temp": dt_util.utcnow()}
update_stale_sensors_issue(hass, entry)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
# --------------------------------------------------------------------------- #
# legacy.py
# --------------------------------------------------------------------------- #
async def test_legacy_battery_sensor_creates_issue(hass):
"""A legacy (non-binary) battery sensor in the entity registry raises an issue."""
entry = _make_entry(hass, {})
ent_reg = er.async_get(hass)
ent_reg.async_get_or_create("sensor", DOMAIN, "outside_battery", config_entry=entry)
update_legacy_battery_issue(hass, entry)
issue_id = _legacy_battery_issue_id(entry)
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_key == "legacy_battery_sensor_deprecated"
assert issue.translation_placeholders is not None
assert issue.translation_placeholders["remove_version"] == "2.1.0"
async def test_no_legacy_battery_sensor_clears_issue(hass):
"""With no legacy battery sensor present, the issue is deleted/absent."""
entry = _make_entry(hass, {})
update_legacy_battery_issue(hass, entry)
issue_id = _legacy_battery_issue_id(entry)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None

6
tests/test_strings.py Normal file
View File

@ -0,0 +1,6 @@
# Test file for strings.json module
def test_strings_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,233 @@
"""Tests for the T9 air-quality (HCHO / VOC) sensor support.
Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
``t9cn`` parameters:
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``)
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
- the connection gating in ``utils.remap_wslink_items``
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
- the ``hcho`` / ``voc`` / ``t9_battery`` entries in the translation files
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from custom_components.sws12500.const import (
BATTERY_LIST,
BATTERY_NON_BINARY,
CONNECTION_GATED_SENSORS,
HCHO,
OUTSIDE_TEMP,
REMAP_WSLINK_ITEMS,
T9_BATTERY,
VOC,
VOC_LEVEL_MAP,
VOCLevel,
)
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
from custom_components.sws12500.utils import battery_5step_to_pct, remap_wslink_items, voc_level_to_text
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import CONCENTRATION_PARTS_PER_BILLION, PERCENTAGE
# Realistic WSLink payload taken from an issue report: the station sends every
# parameter, even for channels with no sensor connected (``*cn == "0"``).
ISSUE_PAYLOAD = {
"rbar": "1013.3",
"intem": "25.0",
"inhum": "44",
"t1cn": "1",
"t1tem": "11.3",
"t1hum": "92",
"t234c1cn": "1",
"t234c2cn": "1",
"t234c3cn": "0",
"t8cn": "0",
"t9cn": "1",
"t9hcho": "57",
"t9voclv": "5",
"t9bat": "5",
"t10cn": "0",
"t11cn": "0",
"apiver": "1.00",
}
# --- constants -------------------------------------------------------------
def test_t9_keys_are_remapped() -> None:
assert REMAP_WSLINK_ITEMS["t9hcho"] == HCHO
assert REMAP_WSLINK_ITEMS["t9voclv"] == VOC
assert REMAP_WSLINK_ITEMS["t9bat"] == T9_BATTERY
# t9cn is intentionally NOT remapped - it is only used as a gating flag.
assert "t9cn" not in REMAP_WSLINK_ITEMS
def test_connection_gated_sensors_definition() -> None:
# The T9 HCHO/VOC probe is gated by its own connection flag. (Multi-channel
# CH2-CH8 probes have their own gates too; we only assert the T9 one here.)
assert CONNECTION_GATED_SENSORS["t9cn"] == [HCHO, VOC, T9_BATTERY]
def test_t9_battery_is_non_binary_only() -> None:
assert BATTERY_NON_BINARY == (T9_BATTERY,)
# the 0-5 / percentage battery must not be treated as a binary low/normal one
assert T9_BATTERY not in BATTERY_LIST
def test_voc_level_map_is_complete_and_ordered() -> None:
# 1 == highest VOC reading (worst air) ... 5 == lowest VOC reading (best air)
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELLENT
assert [member.value for member in VOCLevel] == [
"unhealthy",
"poor",
"moderate",
"good",
"excellent",
]
# --- voc_level_to_text -----------------------------------------------------
@pytest.mark.parametrize("empty", [None, ""])
def test_voc_level_to_text_handles_empty(empty) -> None:
assert voc_level_to_text(empty) is None
@pytest.mark.parametrize(
("raw", "expected"),
[
("1", VOCLevel.UNHEALTHY),
("2", VOCLevel.POOR),
("3", VOCLevel.MODERATE),
("4", VOCLevel.GOOD),
("5", VOCLevel.EXCELLENT),
(3, VOCLevel.MODERATE),
],
)
def test_voc_level_to_text_maps_known_levels(raw, expected) -> None:
assert voc_level_to_text(raw) == expected
@pytest.mark.parametrize("raw", ["0", "6", 0, 6])
def test_voc_level_to_text_out_of_range_is_none(raw) -> None:
assert voc_level_to_text(raw) is None
# --- battery_5step_to_pct --------------------------------------------------
@pytest.mark.parametrize("empty", [None, ""])
def test_battery_5step_to_pct_handles_empty(empty) -> None:
assert battery_5step_to_pct(empty) is None
@pytest.mark.parametrize(
("raw", "expected"),
[("0", 0), ("1", 20), ("2", 40), ("3", 60), ("4", 80), ("5", 100), (5, 100)],
)
def test_battery_5step_to_pct_scales_to_percentage(raw, expected) -> None:
assert battery_5step_to_pct(raw) == expected
# --- remap_wslink_items connection gating ----------------------------------
def test_remap_keeps_t9_group_when_connected() -> None:
out = remap_wslink_items({"t9cn": "1", "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert out[HCHO] == "57"
assert out[VOC] == "5"
assert out[T9_BATTERY] == "5"
assert out[OUTSIDE_TEMP] == "11.3"
@pytest.mark.parametrize("conn", [{"t9cn": "0"}, {}], ids=["disconnected", "absent"])
def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None:
out = remap_wslink_items({**conn, "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert HCHO not in out
assert VOC not in out
assert T9_BATTERY not in out
# unrelated sensors are untouched by the gating
assert out[OUTSIDE_TEMP] == "11.3"
def test_remap_issue_payload_exposes_t9_when_connected() -> None:
out = remap_wslink_items(ISSUE_PAYLOAD)
# t9cn == "1" -> the T9 sensors are exposed
assert out[HCHO] == "57"
assert out[VOC] == "5"
assert out[T9_BATTERY] == "5"
# connection flags never leak into the sensor data
assert "t9cn" not in out
assert "t9_conn" not in out
# --- sensor entity descriptions -------------------------------------------
@pytest.fixture
def wslink_descriptions():
return {description.key: description for description in SENSOR_TYPES_WSLINK}
def test_hcho_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[HCHO]
assert description.translation_key == HCHO
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
assert description.state_class is SensorStateClass.MEASUREMENT
# HCHO is a numeric ppb concentration, so value_fn coerces to int.
assert description.value_fn("57") == 57
def test_voc_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[VOC]
assert description.translation_key == VOC
assert description.device_class is SensorDeviceClass.ENUM
assert description.options == list(VOCLevel)
# ENUM sensors must not declare a state_class
assert description.state_class is None
assert description.value_fn("1") == VOCLevel.UNHEALTHY
assert description.value_fn("5") == "excellent"
assert description.value_fn(None) is None
def test_t9_battery_entity_description(wslink_descriptions) -> None:
description = wslink_descriptions[T9_BATTERY]
assert description.translation_key == T9_BATTERY
assert description.device_class is SensorDeviceClass.BATTERY
assert description.native_unit_of_measurement == PERCENTAGE
assert description.state_class is SensorStateClass.MEASUREMENT
assert description.suggested_display_precision == 0
# no explicit icon -> HA renders the battery icon from the device class + %
assert description.icon is None
assert description.value_fn("5") == 100
assert description.value_fn("0") == 0
assert description.value_fn(None) is None
# --- translation files -----------------------------------------------------
_TRANSLATIONS_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500" / "translations"
@pytest.mark.parametrize("filename", ["en.json", "cs.json"])
def test_translation_files_have_t9_entries(filename) -> None:
sensors = json.loads((_TRANSLATIONS_DIR / filename).read_text(encoding="utf-8"))["entity"]["sensor"]
assert sensors["hcho"]["name"]
assert sensors["t9_battery"]["name"]
voc = sensors["voc"]
assert voc["name"]
assert set(voc["state"]) == {member.value for member in VOCLevel}

View File

@ -0,0 +1,6 @@
# Test file for translations/cs.json module
def test_translations_cs_functionality():
# Add your test cases here
pass

View File

@ -0,0 +1,6 @@
# Test file for translations/en.json module
def test_translations_en_functionality():
# Add your test cases here
pass

8
tests/test_utils.py Normal file
View File

@ -0,0 +1,8 @@
from custom_components.sws12500.utils import celsius_to_fahrenheit, fahrenheit_to_celsius
def test_temperature_conversion():
assert celsius_to_fahrenheit(0) == 32
assert celsius_to_fahrenheit(100) == 212
assert fahrenheit_to_celsius(32) == 0
assert fahrenheit_to_celsius(212) == 100

54
tests/test_utils_conv.py Normal file
View File

@ -0,0 +1,54 @@
"""Coverage for to_int / to_float edge cases and anonymize() masking."""
from __future__ import annotations
import pytest
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(
("value", "expected"),
[
(None, None),
("", None),
(" ", None),
("x", None),
("5", 5),
(7, 7),
],
)
def test_to_int_edge_cases(value, expected) -> None:
assert to_int(value) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
(" ", None),
("x", None),
("5.5", 5.5),
(7, 7.0),
],
)
def test_to_float_edge_cases(value, expected) -> None:
assert to_float(value) == expected

364
tests/test_utils_more.py Normal file
View File

@ -0,0 +1,364 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.sws12500.const import (
DEV_DBG,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
REMAP_ITEMS,
REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD,
WIND_SPEED,
UnitOfBat,
)
from custom_components.sws12500.utils import (
anonymize,
battery_level,
battery_level_to_icon,
celsius_to_fahrenheit,
check_disabled,
chill_index,
fahrenheit_to_celsius,
heat_index,
loaded_sensors,
remap_items,
remap_wslink_items,
translated_notification,
translations,
update_options,
wind_dir_to_text,
)
@dataclass(slots=True)
class _EntryStub:
entry_id: str = "test_entry_id"
options: dict[str, Any] = None # type: ignore[assignment]
class _ConfigEntriesStub:
def __init__(self) -> None:
self.async_update_entry = MagicMock(return_value=True)
class _HassStub:
def __init__(self, language: str = "en") -> None:
self.config = SimpleNamespace(language=language)
self.config_entries = _ConfigEntriesStub()
@pytest.fixture
def hass() -> _HassStub:
return _HassStub(language="en")
@pytest.fixture
def entry() -> _EntryStub:
return _EntryStub(options={})
def test_anonymize_masks_secrets_and_keeps_other_values():
data = {
"ID": "abc",
"PASSWORD": "secret",
"wsid": "id2",
"wspw": "pw2",
"temp": 10,
"ok": True,
}
out = anonymize(data)
assert out["ID"] == "***"
assert out["PASSWORD"] == "***"
assert out["wsid"] == "***"
assert out["wspw"] == "***"
assert out["temp"] == 10
assert out["ok"] is True
def test_remap_items_filters_unknown_keys():
# Pick a known legacy key from the mapping
legacy_key = next(iter(REMAP_ITEMS.keys()))
internal_key = REMAP_ITEMS[legacy_key]
entities = {legacy_key: "1", "unknown": "2"}
out = remap_items(entities)
assert out == {internal_key: "1"}
def test_remap_wslink_items_filters_unknown_keys():
wslink_key = next(iter(REMAP_WSLINK_ITEMS.keys()))
internal_key = REMAP_WSLINK_ITEMS[wslink_key]
entities = {wslink_key: "x", "unknown": "y"}
out = remap_wslink_items(entities)
assert out == {internal_key: "x"}
def test_loaded_sensors_returns_list_or_empty(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a", "b"]
assert loaded_sensors(entry) == ["a", "b"]
entry.options[SENSORS_TO_LOAD] = []
assert loaded_sensors(entry) == []
entry.options.pop(SENSORS_TO_LOAD)
assert loaded_sensors(entry) == []
def test_check_disabled_returns_none_when_all_present(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a", "b"]
entry.options[DEV_DBG] = False
missing = check_disabled({"a": "1", "b": "2"}, entry)
assert missing is None
def test_check_disabled_returns_missing_keys(entry: _EntryStub):
entry.options[SENSORS_TO_LOAD] = ["a"]
entry.options[DEV_DBG] = False
missing = check_disabled({"a": "1", "b": "2", "c": "3"}, entry)
assert missing == ["b", "c"]
def test_check_disabled_logs_when_dev_dbg_enabled(entry: _EntryStub, monkeypatch):
# Just ensure logging branches are exercised without asserting exact messages.
entry.options[SENSORS_TO_LOAD] = []
entry.options[DEV_DBG] = True
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.info", lambda *a, **k: None
)
missing = check_disabled({"a": "1"}, entry)
assert missing == ["a"]
@pytest.mark.asyncio
async def test_update_options_calls_async_update_entry(
hass: _HassStub, entry: _EntryStub
):
entry.options = {"x": 1}
ok = await update_options(hass, entry, "y", True)
assert ok is True
hass.config_entries.async_update_entry.assert_called_once()
_called_entry = hass.config_entries.async_update_entry.call_args.args[0]
assert _called_entry is entry
called_options = hass.config_entries.async_update_entry.call_args.kwargs["options"]
assert called_options["x"] == 1
assert called_options["y"] is True
@pytest.mark.asyncio
async def test_translations_returns_value_when_key_present(
hass: _HassStub, monkeypatch
):
# Build the key that translations() will look for
localize_key = "component.sws12500.entity.sensor.test.name"
get_translations = AsyncMock(return_value={localize_key: "Translated"})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
out = await translations(
hass,
"sws12500",
"sensor.test",
key="name",
category="entity",
)
assert out == "Translated"
@pytest.mark.asyncio
async def test_translations_returns_none_when_key_missing(hass: _HassStub, monkeypatch):
get_translations = AsyncMock(return_value={})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
out = await translations(hass, "sws12500", "missing")
assert out is None
@pytest.mark.asyncio
async def test_translated_notification_creates_notification_without_placeholders(
hass: _HassStub, monkeypatch
):
base_key = "component.sws12500.notify.added.message"
title_key = "component.sws12500.notify.added.title"
get_translations = AsyncMock(return_value={base_key: "Msg", title_key: "Title"})
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
create = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.utils.persistent_notification.async_create", create
)
await translated_notification(hass, "sws12500", "added")
create.assert_called_once()
args = create.call_args.args
assert args[0] is hass
assert args[1] == "Msg"
assert args[2] == "Title"
@pytest.mark.asyncio
async def test_translated_notification_formats_placeholders(
hass: _HassStub, monkeypatch
):
base_key = "component.sws12500.notify.added.message"
title_key = "component.sws12500.notify.added.title"
get_translations = AsyncMock(
return_value={base_key: "Hello {name}", title_key: "Title"}
)
monkeypatch.setattr(
"custom_components.sws12500.utils.async_get_translations", get_translations
)
create = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.utils.persistent_notification.async_create", create
)
await translated_notification(
hass, "sws12500", "added", translation_placeholders={"name": "World"}
)
create.assert_called_once()
assert create.call_args.args[1] == "Hello World"
def test_battery_level_handles_none_empty_invalid_and_known_values():
assert battery_level(None) == UnitOfBat.UNKNOWN
assert battery_level("") == UnitOfBat.UNKNOWN
assert battery_level("x") == UnitOfBat.UNKNOWN
assert battery_level(0) == UnitOfBat.LOW
assert battery_level("0") == UnitOfBat.LOW
assert battery_level(1) == UnitOfBat.NORMAL
assert battery_level("1") == UnitOfBat.NORMAL
# Unknown numeric values map to UNKNOWN
assert battery_level(2) == UnitOfBat.UNKNOWN
assert battery_level("2") == UnitOfBat.UNKNOWN
def test_battery_level_to_icon_maps_all_and_unknown():
assert battery_level_to_icon(UnitOfBat.LOW) == "mdi:battery-low"
assert battery_level_to_icon(UnitOfBat.NORMAL) == "mdi:battery"
assert battery_level_to_icon(UnitOfBat.UNKNOWN) == "mdi:battery-unknown"
def test_temperature_conversions_round_trip():
# Use a value that is exactly representable in binary-ish floats
f = 32.0
c = fahrenheit_to_celsius(f)
assert c == 0.0
assert celsius_to_fahrenheit(c) == 32.0
# General check (approx)
f2 = 77.0
c2 = fahrenheit_to_celsius(f2)
assert c2 == pytest.approx(25.0)
assert celsius_to_fahrenheit(c2) == pytest.approx(77.0)
def test_wind_dir_to_text_returns_none_for_zero_and_valid_for_positive():
assert wind_dir_to_text(0.0) is None
assert wind_dir_to_text(0) is None
# For a non-zero degree it should return some enum value
out = wind_dir_to_text(10.0)
assert out is not None
def test_heat_index_returns_none_when_missing_temp_or_humidity(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.error", lambda *a, **k: None
)
assert heat_index({OUTSIDE_HUMIDITY: "50"}) is None
assert heat_index({OUTSIDE_TEMP: "80"}) is None
assert heat_index({OUTSIDE_TEMP: "x", OUTSIDE_HUMIDITY: "50"}) is None
assert heat_index({OUTSIDE_TEMP: "80", OUTSIDE_HUMIDITY: "x"}) is None
def test_heat_index_simple_path_and_full_index_path():
# Simple path: keep simple average under threshold.
# Using temp=70F, rh=40 keeps ((simple+temp)/2) under 80 typically.
simple = heat_index({OUTSIDE_TEMP: "70", OUTSIDE_HUMIDITY: "40"})
assert simple is not None
# Full index path: choose high temp/rh -> triggers full index.
full = heat_index({OUTSIDE_TEMP: "90", OUTSIDE_HUMIDITY: "85"})
assert full is not None
def test_heat_index_low_humidity_adjustment_branch():
# This targets:
# if rh < 13 and (80 <= temp <= 112): adjustment = ...
#
# Pick a temp/rh combo that:
# - triggers the full-index path: ((simple + temp) / 2) > 80
# - satisfies low humidity adjustment bounds
out = heat_index({OUTSIDE_TEMP: "95", OUTSIDE_HUMIDITY: "10"})
assert out is not None
def test_heat_index_convert_from_celsius_path():
# If convert=True, temp is interpreted as Celsius and converted to Fahrenheit internally.
# Use 30C (~86F) and high humidity to trigger full index path.
out = heat_index({OUTSIDE_TEMP: "30", OUTSIDE_HUMIDITY: "85"}, convert=True)
assert out is not None
def test_chill_index_returns_none_when_missing_temp_or_wind(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.utils._LOGGER.error", lambda *a, **k: None
)
assert chill_index({WIND_SPEED: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "x", WIND_SPEED: "10"}) is None
assert chill_index({OUTSIDE_TEMP: "10", WIND_SPEED: "x"}) is None
def test_chill_index_returns_calculated_when_cold_and_windy():
# temp in F, wind > 3 -> calculate when temp < 50
out = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "10"})
assert out is not None
assert isinstance(out, float)
def test_chill_index_returns_temp_when_not_cold_or_not_windy():
# Not cold -> hits the `else temp` branch
out1 = chill_index({OUTSIDE_TEMP: "60", WIND_SPEED: "10"})
assert out1 == 60.0
# Not windy -> hits the `else temp` branch
out2 = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "2"})
assert out2 == 40.0
# Boundary: exactly 50F should also hit the `else temp` branch (since condition is temp < 50)
out3 = chill_index({OUTSIDE_TEMP: "50", WIND_SPEED: "10"})
assert out3 == 50.0
# Boundary: exactly 3 mph should also hit the `else temp` branch (since condition is wind > 3)
out4 = chill_index({OUTSIDE_TEMP: "40", WIND_SPEED: "3"})
assert out4 == 40.0
def test_chill_index_convert_from_celsius_path():
out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True)
assert out is not None

View File

@ -0,0 +1,254 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Callable
from unittest.mock import MagicMock
from custom_components.sws12500.const import DOMAIN
from custom_components.sws12500.sensor import WeatherSensor
@dataclass(slots=True)
class _DescriptionStub:
"""Minimal stand-in for WeatherSensorEntityDescription.
WeatherSensor only relies on:
- key
- value_fn
- value_from_data_fn
"""
key: str
value_fn: Callable[[Any], Any] | None = None
value_from_data_fn: Callable[[dict[str, Any]], Any] | None = None
class _CoordinatorStub:
"""Minimal coordinator stub used by WeatherSensor."""
def __init__(
self, data: dict[str, Any] | None = None, *, config: Any | None = None
):
self.data = data if data is not None else {}
# WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag,
# so default to a config with empty options when the test doesn't supply one.
self.config = config if config is not None else SimpleNamespace(options={})
def test_native_value_prefers_value_from_data_fn_success():
desc = _DescriptionStub(
key="derived",
value_from_data_fn=lambda data: f"v:{data.get('x')}",
value_fn=lambda raw: f"raw:{raw}", # should not be used
)
coordinator = _CoordinatorStub(data={"x": 123, "derived": "ignored"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == "v:123"
def test_native_value_value_from_data_fn_success_with_dev_logging_hits_computed_debug_branch(
monkeypatch,
):
"""Ensure value_from_data_fn works with dev logging enabled."""
desc = _DescriptionStub(
key="derived",
value_from_data_fn=lambda data: data["x"] + 1,
)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"x": 41}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_from_data_fn_exception_returns_none():
def boom(_data: dict[str, Any]) -> Any:
raise RuntimeError("nope")
desc = _DescriptionStub(key="derived", value_from_data_fn=boom)
coordinator = _CoordinatorStub(data={"derived": 1})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_missing_raw_returns_none():
desc = _DescriptionStub(key="missing", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_missing_raw_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="missing", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_raw_none_with_dev_logging_hits_debug_branch(monkeypatch):
# This targets the `raw is None` branch (not empty string) and ensures the debug line
# is actually executed (coverage sometimes won't attribute it when data is missing).
called = {"debug": 0}
def _debug(*_a, **_k):
called["debug"] += 1
monkeypatch.setattr("custom_components.sws12500.sensor._LOGGER.debug", _debug)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
# Ensure the key exists and explicitly maps to None so `data.get(key)` returns None
# in a deterministic way for coverage.
coordinator = _CoordinatorStub(data={"k": None}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
assert called["debug"] >= 1
def test_native_value_missing_raw_logs_specific_message(monkeypatch):
"""Target the exact debug log line for missing raw values.
This is meant to hit the specific `_LOGGER.debug("native_value missing raw: ...")`
statement to help achieve full `sensor.py` coverage.
"""
debug = MagicMock()
monkeypatch.setattr("custom_components.sws12500.sensor._LOGGER.debug", debug)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": None}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
debug.assert_any_call("native_value missing raw: key=%s raw=%s", "k", None)
def test_native_value_empty_string_raw_returns_none():
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"k": ""})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_empty_string_raw_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": ""}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_no_value_fn_returns_none():
desc = _DescriptionStub(key="k", value_fn=None)
coordinator = _CoordinatorStub(data={"k": 10})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_no_value_fn_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=None)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": 10}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_native_value_value_fn_success():
desc = _DescriptionStub(key="k", value_fn=lambda raw: int(raw) + 1)
coordinator = _CoordinatorStub(data={"k": "41"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_fn_success_with_dev_logging_hits_debug_branch(monkeypatch):
monkeypatch.setattr(
"custom_components.sws12500.sensor._LOGGER.debug", lambda *a, **k: None
)
desc = _DescriptionStub(key="k", value_fn=lambda raw: int(raw) + 1)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": "41"}, config=config)
entity = WeatherSensor(desc, coordinator)
assert entity.native_value == 42
def test_native_value_value_fn_exception_returns_none():
def boom(_raw: Any) -> Any:
raise ValueError("bad")
desc = _DescriptionStub(key="k", value_fn=boom)
coordinator = _CoordinatorStub(data={"k": "x"})
entity = WeatherSensor(desc, coordinator)
assert entity.native_value is None
def test_suggested_entity_id_uses_sensor_domain_and_key(monkeypatch):
# `homeassistant.helpers.entity.generate_entity_id` requires either `current_ids` or `hass`.
# Our entity isn't attached to hass in this unit test, so patch it to a deterministic result.
monkeypatch.setattr(
"custom_components.sws12500.sensor.generate_entity_id",
lambda _fmt, key: f"sensor.{key}",
)
desc = _DescriptionStub(key="outside_temp", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"outside_temp": 1})
entity = WeatherSensor(desc, coordinator)
suggested = entity.suggested_entity_id
assert suggested == "sensor.outside_temp"
def test_device_info_contains_expected_identifiers_and_domain():
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
coordinator = _CoordinatorStub(data={"k": 1})
entity = WeatherSensor(desc, coordinator)
info = entity.device_info
assert info is not None
# DeviceInfo is mapping-like; access defensively.
assert info.get("name") == "Weather Station SWS 12500"
assert info.get("manufacturer") == "Schizza"
assert info.get("model") == "PWS" # no ecowitt/wslink in stub options -> PWS
identifiers = info.get("identifiers")
assert isinstance(identifiers, set)
assert (DOMAIN,) in identifiers
def test_dev_log_flag_reads_from_config_entry_options():
# When coordinator has a config with options, WeatherSensor should read dev_debug_checkbox.
desc = _DescriptionStub(key="k", value_fn=lambda raw: raw)
config = SimpleNamespace(options={"dev_debug_checkbox": True})
coordinator = _CoordinatorStub(data={"k": 1}, config=config)
entity = WeatherSensor(desc, coordinator)
# We don't assert logs; we just ensure native_value still works with dev logging enabled.
assert entity.native_value == 1

6
tests/test_windy_func.py Normal file
View File

@ -0,0 +1,6 @@
# Test file for windy_func.py module
def test_windy_func_functionality():
# Add your test cases here
pass

121
tests/test_windy_more.py Normal file
View File

@ -0,0 +1,121 @@
"""Additional Windy branch coverage: duplicate (409), rate limit (429), 3-strike disable."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
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)
class _FakeResponse:
status: int
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class _FakeSession:
def __init__(self, response: _FakeResponse) -> None:
self._response = response
def get(self, url: str, *, params=None, headers=None):
return self._response
@pytest.fixture
def hass():
return SimpleNamespace()
def _make_entry(**options: Any):
defaults = {
WINDY_LOGGER_ENABLED: False,
WINDY_ENABLED: True,
WINDY_STATION_ID: "station",
WINDY_STATION_PW: "token",
}
defaults.update(options)
return SimpleNamespace(options=defaults)
def test_verify_response_duplicate_raises(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyDuplicatePayloadDetected):
wp.verify_windy_response(_FakeResponse(status=409))
def test_verify_response_rate_limit_raises(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyRateLimitExceeded):
wp.verify_windy_response(_FakeResponse(status=429))
@pytest.mark.asyncio
async def test_push_duplicate_payload_sets_status_and_counts(monkeypatch, hass):
wp = WindyPush(hass, _make_entry())
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(_FakeResponse(status=409)),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.last_status == "duplicate"
assert wp.invalid_response_count == 1
@pytest.mark.asyncio
async def test_push_rate_limited_pauses_five_minutes(monkeypatch, hass):
wp = WindyPush(hass, _make_entry())
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 = dt_util.utcnow()
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.last_status == "rate_limited_remote"
# next_update pushed ~5 minutes out by the rate-limit handler.
assert wp.next_update > before + timedelta(minutes=4)
@pytest.mark.asyncio
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 = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(_FakeResponse(status=409)),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.invalid_response_count == 3
update_options.assert_awaited_once_with(hass, wp.config, WINDY_ENABLED, False)

478
tests/test_windy_push.py Normal file
View File

@ -0,0 +1,478 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.client_exceptions import ClientError
import pytest
from custom_components.sws12500.const import (
PURGE_DATA,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WINDY_UNEXPECTED,
WINDY_URL,
)
from custom_components.sws12500.windy_func import WindyNotInserted, WindyPasswordMissing, WindyPush, WindySuccess
from homeassistant.util import dt as dt_util
@dataclass(slots=True)
class _FakeResponse:
status: int
text_value: str = ""
async def text(self) -> str:
return self.text_value
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class _FakeSession:
def __init__(
self, *, response: _FakeResponse | None = None, exc: Exception | None = None
):
self._response = response
self._exc = exc
self.calls: list[dict[str, Any]] = []
def get(
self,
url: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
):
self.calls.append(
{"url": url, "params": dict(params or {}), "headers": dict(headers or {})}
)
if self._exc is not None:
raise self._exc
assert self._response is not None
return self._response
@pytest.fixture
def hass():
# Use HA provided fixture if available; otherwise a minimal stub works because we patch session getter.
return SimpleNamespace()
def _make_entry(**options: Any):
defaults = {
WINDY_LOGGER_ENABLED: False,
WINDY_ENABLED: True,
WINDY_STATION_ID: "station",
WINDY_STATION_PW: "token",
}
defaults.update(options)
return SimpleNamespace(options=defaults)
def test_verify_windy_response_notice_raises_not_inserted(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyNotInserted):
wp.verify_windy_response(_FakeResponse(status=400, text_value="Bad Request"))
def test_verify_windy_response_success_raises_success(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindySuccess):
wp.verify_windy_response(_FakeResponse(status=200, text_value="OK"))
def test_verify_windy_response_password_missing_raises(hass):
wp = WindyPush(hass, _make_entry())
with pytest.raises(WindyPasswordMissing):
wp.verify_windy_response(_FakeResponse(status=401, text_value="Unauthorized"))
def test_covert_wslink_to_pws_maps_keys(hass):
wp = WindyPush(hass, _make_entry())
data = {
"t1ws": "1",
"t1wgust": "2",
"t1wdir": "3",
"t1hum": "4",
"t1dew": "5",
"t1tem": "6",
"rbar": "7",
"t1rainhr": "8",
"t1uvi": "9",
"t1solrad": "10",
"other": "keep",
}
out = wp._covert_wslink_to_pws(data)
assert out["wind"] == "1"
assert out["gust"] == "2"
assert out["winddir"] == "3"
assert out["humidity"] == "4"
assert out["dewpoint"] == "5"
assert out["temp"] == "6"
assert out["mbar"] == "7"
assert out["precip"] == "8"
assert out["uv"] == "9"
assert out["solarradiation"] == "10"
assert out["other"] == "keep"
for k in (
"t1ws",
"t1wgust",
"t1wdir",
"t1hum",
"t1dew",
"t1tem",
"rbar",
"t1rainhr",
"t1uvi",
"t1solrad",
):
assert k not in out
@pytest.mark.asyncio
async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
# Ensure "next_update > now" is true
wp.next_update = dt_util.utcnow() + timedelta(minutes=10)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: _FakeSession(response=_FakeResponse(status=200, text_value="OK")),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
@pytest.mark.asyncio
async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
# Force it to send now
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
data = {k: "x" for k in PURGE_DATA}
data.update({"keep": "1"})
ok = await wp.push_data_to_windy(data, wslink=False)
assert ok is True
assert len(session.calls) == 1
call = session.calls[0]
assert call["url"] == WINDY_URL
# Purged keys removed
for k in PURGE_DATA:
assert k not in call["params"]
# Added keys
assert call["params"]["id"] == entry.options[WINDY_STATION_ID]
assert call["params"]["time"] == "now"
assert (
call["headers"]["Authorization"] == f"Bearer {entry.options[WINDY_STATION_PW]}"
)
@pytest.mark.asyncio
async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, wslink=True)
assert ok is True
params = session.calls[0]["params"]
assert "wind" in params and params["wind"] == "1"
assert "temp" in params and params["temp"] == "2"
assert "t1ws" not in params and "t1tem" not in params
@pytest.mark.asyncio
async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch, hass):
entry = _make_entry()
entry.options.pop(WINDY_STATION_ID)
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch, hass):
entry = _make_entry()
entry.options.pop(WINDY_STATION_PW)
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False
assert session.calls == []
@pytest.mark.asyncio
async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, hass):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
# Response triggers WindyPasswordMissing (401)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
@pytest.mark.asyncio
async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_debug(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(
response=_FakeResponse(status=401, text_value="Unauthorized")
)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
update_options = AsyncMock(return_value=False)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
dbg.assert_called()
@pytest.mark.asyncio
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 = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=400, text_value="Bad Request"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
err = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.error", err)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
# It logs WINDY_NOT_INSERTED regardless of log setting
err.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_success_logs_info_when_logger_enabled(
monkeypatch, hass
):
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.info", info)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
# It should log WINDY_SUCCESS (or at least call info) when logging is enabled
info.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_verify_no_raise_logs_debug_not_inserted_when_logger_enabled(
monkeypatch, hass
):
"""Cover the `else:` branch when `verify_windy_response` does not raise.
This is a defensive branch in `push_data_to_windy`:
try: verify(...)
except ...:
else:
if self.log:
_LOGGER.debug(WINDY_NOT_INSERTED)
"""
entry = _make_entry(**{WINDY_LOGGER_ENABLED: True})
wp = WindyPush(hass, entry)
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"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
debug = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", debug)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
debug.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_client_error_increments_and_disables_after_three(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
crit = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
# Cause ClientError on session.get
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
# First 3 calls should not disable; 4th should
for i in range(4):
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.invalid_response_count == 4
# update_options awaited once when count > 3
update_options.assert_awaited()
args = update_options.await_args.args
assert args[2] == WINDY_ENABLED
assert args[3] is False
# It should log WINDY_UNEXPECTED at least once
assert any(
WINDY_UNEXPECTED in str(c.args[0]) for c in crit.call_args_list if c.args
)
@pytest.mark.asyncio
async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
monkeypatch, hass
):
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.invalid_response_count = 3 # next error will push it over the threshold
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
update_options = AsyncMock(return_value=False)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.update_options", update_options
)
dbg = MagicMock()
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.create",
MagicMock(),
)
session = _FakeSession(exc=ClientError("boom"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
dbg.assert_called()

8
tools/.cargo/config.toml Normal file
View File

@ -0,0 +1,8 @@
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
[env]
CC_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-gcc"
CXX_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-g++"
AR_x86_64_unknown_linux_gnu = "x86_64-linux-gnu-ar"

@ -0,0 +1 @@
Subproject commit 81539fc79d8c3af267241ad6fd63b1924583354f