fix: address remaining review findings across forwarders and utils
Forwarders (Windy / Pocasi): - `enabled` becomes a property reading the option live. Toggling it does not reload the entry, so the cached copy left the diagnostics sensor reporting a stale value indefinitely for a disabled forwarder. - Reject empty credentials explicitly: an empty string is still a `str`, so `checked` alone let an unconfigured forwarder send a request that could only ever be refused. - Use `persistent_notification.async_create` instead of the sync variant. - Use WINDY_MAX_RETRIES consistently; add POCASI_CZ_MAX_RETRIES. Utils: - `voc_level_to_text` / `battery_5step_to_pct` go through `to_int` like every other value_fn. A bare `int()` raised on a garbage payload value, which `WeatherSensor.native_value` then logged with a full traceback on every push. Out-of-range battery steps are clamped to a valid percentage. - Simplify `check_disabled` (drop the redundant camelCase flag). Config flow: - `retain_data` re-reads SENSORS_TO_LOAD at submit time. Auto-discovery appends to it from the webhook handler, so writing back the snapshot taken when the step opened silently rolled back any sensor discovered meanwhile. Coordinator: - Drop the per-push staleness recalculation; the hourly timer already covers it. Ecowitt: - Tolerate aioecowitt moving/renaming the internal SENSOR_MAP rather than failing the whole module import; unit-variant dedup degrades instead. Translations: - Battery state key `unknown` -> `drained`: the entity emits UnitOfBat.UNKNOWN, whose value is "drained", so the old key never matched and the raw state leaked into the UI. cs.json was already correct. - Remove the dead `migration` step; `async_step_migration` does not exist. Also: remove verified-dead constants/helpers and fix ~30 docstring typos.ecowitt_support
parent
8b38db6951
commit
5e4b02fd6e
|
|
@ -1,7 +1,7 @@
|
||||||
"""Battery sensors templates.
|
"""Battery sensors templates.
|
||||||
|
|
||||||
We create a sensor tempate here.
|
We create a sensor template here.
|
||||||
Actualy loaded senors are gated in coordinator.
|
Actually loaded sensors are gated in coordinator.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from yarl import URL
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
from homeassistant.helpers.network import get_url
|
from homeassistant.helpers.network import get_url
|
||||||
|
|
||||||
|
|
@ -43,14 +42,6 @@ from .const import (
|
||||||
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
|
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(HomeAssistantError):
|
|
||||||
"""We can not connect. - not used in push mechanism."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuth(HomeAssistantError):
|
|
||||||
"""Invalid auth exception."""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigOptionsFlowHandler(OptionsFlow):
|
class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle WeatherStation ConfigFlow."""
|
"""Handle WeatherStation ConfigFlow."""
|
||||||
|
|
||||||
|
|
@ -63,7 +54,6 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
self.windy_data_schema = {}
|
self.windy_data_schema = {}
|
||||||
self.user_data: dict[str, Any] = {}
|
self.user_data: dict[str, Any] = {}
|
||||||
self.user_data_schema = {}
|
self.user_data_schema = {}
|
||||||
self.sensors: dict[str, Any] = {}
|
|
||||||
self.migrate_schema = {}
|
self.migrate_schema = {}
|
||||||
self.pocasi_cz: dict[str, Any] = {}
|
self.pocasi_cz: dict[str, Any] = {}
|
||||||
self.pocasi_cz_schema = {}
|
self.pocasi_cz_schema = {}
|
||||||
|
|
@ -91,12 +81,6 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
|
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sensors = {
|
|
||||||
SENSORS_TO_LOAD: (
|
|
||||||
entry_data.get(SENSORS_TO_LOAD) if isinstance(entry_data.get(SENSORS_TO_LOAD), list) else []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.windy_data = {
|
self.windy_data = {
|
||||||
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
|
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
|
||||||
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
|
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
|
||||||
|
|
@ -157,7 +141,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
|
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
|
||||||
|
|
||||||
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
|
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
|
||||||
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credantials empty.
|
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credentials empty.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
@ -322,16 +306,24 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Retain user_data."""
|
"""Merge the submitted step over every other section's current values.
|
||||||
|
|
||||||
|
`SENSORS_TO_LOAD` is re-read here rather than taken from the `_get_entry_data`
|
||||||
|
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
|
||||||
|
left open while the station reports a new field would otherwise roll that
|
||||||
|
discovery back on submit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**self.user_data,
|
**self.user_data,
|
||||||
**self.windy_data,
|
**self.windy_data,
|
||||||
**self.pocasi_cz,
|
**self.pocasi_cz,
|
||||||
**self.sensors,
|
|
||||||
**self.ecowitt,
|
**self.ecowitt,
|
||||||
**self.wslink_addon_port,
|
**self.wslink_addon_port,
|
||||||
**dict(data),
|
**dict(data),
|
||||||
|
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ PURGE_DATA: Final = [
|
||||||
"dailyrainin",
|
"dailyrainin",
|
||||||
]
|
]
|
||||||
|
|
||||||
"""NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
|
"""NOTE: These are sensors that should be available with PWS protocol according to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US:
|
||||||
|
|
||||||
I have no option to test, if it will work correctly. So their implementatnion will be in future releases.
|
I have no option to test, if it will work correctly. So their implementation will be in future releases.
|
||||||
|
|
||||||
leafwetness - [%]
|
leafwetness - [%]
|
||||||
+ for sensor 2 use leafwetness2
|
+ for sensor 2 use leafwetness2
|
||||||
|
|
@ -189,9 +189,7 @@ WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
|
||||||
ECOWITT: Final = "ecowitt"
|
ECOWITT: Final = "ecowitt"
|
||||||
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
|
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
|
||||||
ECOWITT_ENABLED: Final = "ecowitt_enabled"
|
ECOWITT_ENABLED: Final = "ecowitt_enabled"
|
||||||
ECOWITT_URL: Final = "/weather/ecowitt"
|
|
||||||
ECOWITT_URL_PREFIX: Final = "/weatherhub"
|
ECOWITT_URL_PREFIX: Final = "/weatherhub"
|
||||||
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
|
|
||||||
|
|
||||||
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
||||||
"tempf": OUTSIDE_TEMP,
|
"tempf": OUTSIDE_TEMP,
|
||||||
|
|
@ -225,11 +223,11 @@ REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
||||||
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
||||||
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
||||||
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
|
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
|
||||||
POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox"
|
POCASI_CZ_ENABLED = "pocasi_enabled_checkbox"
|
||||||
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
|
POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
|
||||||
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
|
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?"
|
||||||
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
|
POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo"
|
||||||
POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!"
|
POCASI_CZ_UNEXPECTED: Final = "Pocasi Meteo responded unexpectedly 3 times in row. Resending is now disabled!"
|
||||||
|
|
||||||
WINDY_STATION_ID = "WINDY_STATION_ID"
|
WINDY_STATION_ID = "WINDY_STATION_ID"
|
||||||
WINDY_STATION_PW = "WINDY_STATION_PWD"
|
WINDY_STATION_PW = "WINDY_STATION_PWD"
|
||||||
|
|
@ -243,13 +241,6 @@ WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully i
|
||||||
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
|
WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!"
|
||||||
|
|
||||||
|
|
||||||
PURGE_DATA_POCAS: Final = [
|
|
||||||
"ID",
|
|
||||||
"PASSWORD",
|
|
||||||
"action",
|
|
||||||
"rtfreq",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
REMAP_WSLINK_ITEMS: dict[str, str] = {
|
REMAP_WSLINK_ITEMS: dict[str, str] = {
|
||||||
"intem": INDOOR_TEMP,
|
"intem": INDOOR_TEMP,
|
||||||
|
|
@ -308,10 +299,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
|
||||||
#
|
#
|
||||||
# 'inbat' indoor battery level (1 normal, 0 low)
|
# 'inbat' indoor battery level (1 normal, 0 low)
|
||||||
# 't1bat': outdoor battery level (1 normal, 0 low)
|
# 't1bat': outdoor battery level (1 normal, 0 low)
|
||||||
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLin
|
# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink
|
||||||
#
|
#
|
||||||
# In the following there are sensors that should be available by WSLink.
|
# In the following there are sensors that should be available by WSLink.
|
||||||
# We need to compare them to PWS API to make sure, we have the same intarnal
|
# We need to compare them to PWS API to make sure, we have the same internal
|
||||||
# representation of same sensors.
|
# representation of same sensors.
|
||||||
|
|
||||||
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
|
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
|
||||||
|
|
@ -360,38 +351,12 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
|
||||||
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
|
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
|
||||||
# &t11co= CO concentration integer ppm
|
# &t11co= CO concentration integer ppm
|
||||||
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
|
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
|
||||||
# &t11cn= CO sensor connection (Connected=1, No connect=0) integero
|
# &t11cn= CO sensor connection (Connected=1, No connect=0) integer
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
DISABLED_BY_DEFAULT: Final = [
|
# Station reports batteries as 0/1 (low/normal). Sensors reporting a 0-5 level
|
||||||
CH2_TEMP,
|
# (e.g. T9_BATTERY) are plain sensors, not binary ones.
|
||||||
CH2_HUMIDITY,
|
|
||||||
CH2_BATTERY,
|
|
||||||
CH3_TEMP,
|
|
||||||
CH3_HUMIDITY,
|
|
||||||
CH3_BATTERY,
|
|
||||||
CH4_TEMP,
|
|
||||||
CH4_HUMIDITY,
|
|
||||||
CH4_BATTERY,
|
|
||||||
CH5_TEMP,
|
|
||||||
CH5_HUMIDITY,
|
|
||||||
CH5_BATTERY,
|
|
||||||
CH6_TEMP,
|
|
||||||
CH6_HUMIDITY,
|
|
||||||
CH6_BATTERY,
|
|
||||||
CH7_TEMP,
|
|
||||||
CH7_HUMIDITY,
|
|
||||||
CH7_BATTERY,
|
|
||||||
CH8_TEMP,
|
|
||||||
CH8_HUMIDITY,
|
|
||||||
CH8_BATTERY,
|
|
||||||
OUTSIDE_BATTERY,
|
|
||||||
WBGT_TEMP,
|
|
||||||
]
|
|
||||||
|
|
||||||
# Station reports batteries as 0/1 (low/normal) for most of sensors.
|
|
||||||
# Batteries reported as 0-5 level are stored in `BATTERY_NON_BINARY` tuple
|
|
||||||
BATTERY_LIST: Final[tuple[str, ...]] = (
|
BATTERY_LIST: Final[tuple[str, ...]] = (
|
||||||
OUTSIDE_BATTERY,
|
OUTSIDE_BATTERY,
|
||||||
INDOOR_BATTERY,
|
INDOOR_BATTERY,
|
||||||
|
|
@ -404,7 +369,6 @@ BATTERY_LIST: Final[tuple[str, ...]] = (
|
||||||
CH8_BATTERY,
|
CH8_BATTERY,
|
||||||
)
|
)
|
||||||
|
|
||||||
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,)
|
|
||||||
|
|
||||||
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
|
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
|
||||||
# Multi-channel temp/humidity probes (CH2 - CH8)
|
# Multi-channel temp/humidity probes (CH2 - CH8)
|
||||||
|
|
@ -487,9 +451,3 @@ class UnitOfBat(StrEnum):
|
||||||
NORMAL = "normal"
|
NORMAL = "normal"
|
||||||
UNKNOWN = "drained"
|
UNKNOWN = "drained"
|
||||||
|
|
||||||
|
|
||||||
BATTERY_LEVEL: list[UnitOfBat] = [
|
|
||||||
UnitOfBat.LOW,
|
|
||||||
UnitOfBat.NORMAL,
|
|
||||||
UnitOfBat.UNKNOWN,
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ from .ecowitt import EcowittBridge
|
||||||
from .health_coordinator import HealthCoordinator
|
from .health_coordinator import HealthCoordinator
|
||||||
from .pocasti_cz import PocasiPush
|
from .pocasti_cz import PocasiPush
|
||||||
from .sensor import add_new_sensors
|
from .sensor import add_new_sensors
|
||||||
from .staleness import update_stale_sensors_issue
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
anonymize,
|
anonymize,
|
||||||
check_disabled,
|
check_disabled,
|
||||||
|
|
@ -218,7 +217,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
now = dt_util.utcnow()
|
now = dt_util.utcnow()
|
||||||
for key in mapped_data:
|
for key in mapped_data:
|
||||||
self.config.runtime_data.last_seen[key] = now
|
self.config.runtime_data.last_seen[key] = now
|
||||||
update_stale_sensors_issue(self.hass, self.config)
|
|
||||||
|
|
||||||
if health:
|
if health:
|
||||||
health.update_ingress_result(
|
health.update_ingress_result(
|
||||||
|
|
@ -313,7 +311,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
now = dt_util.utcnow()
|
now = dt_util.utcnow()
|
||||||
for key in remaped_items:
|
for key in remaped_items:
|
||||||
self.config.runtime_data.last_seen[key] = now
|
self.config.runtime_data.last_seen[key] = now
|
||||||
update_stale_sensors_issue(self.hass, self.config)
|
|
||||||
|
|
||||||
if health:
|
if health:
|
||||||
health.update_ingress_result(
|
health.update_ingress_result(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ import logging
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
||||||
from aioecowitt.sensor import SENSOR_MAP
|
|
||||||
|
try:
|
||||||
|
# Internal to aioecowitt; see `_build_unit_twins` for why this is tolerated.
|
||||||
|
from aioecowitt.sensor import SENSOR_MAP
|
||||||
|
except ImportError: # pragma: no cover - defensive, module moved upstream
|
||||||
|
SENSOR_MAP = {}
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
@ -42,10 +47,25 @@ def _build_unit_twins() -> dict[str, frozenset[str]]:
|
||||||
|
|
||||||
aioecowitt exposes both metric and imperial sensors for many readings (e.g.
|
aioecowitt exposes both metric and imperial sensors for many readings (e.g.
|
||||||
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
|
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
|
||||||
|
|
||||||
|
`SENSOR_MAP` is aioecowitt-internal (not part of its public API), so an upstream
|
||||||
|
rename must not take the whole integration down with an ImportError/AttributeError
|
||||||
|
at module import. Degrading to "no twins known" only costs us the duplicate-unit
|
||||||
|
dedup in `_on_new_sensor`.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
sensor_map = SENSOR_MAP.items()
|
||||||
|
except AttributeError: # pragma: no cover - defensive, shape changed upstream
|
||||||
|
_LOGGER.warning("aioecowitt SENSOR_MAP is not a mapping; unit-variant dedup disabled")
|
||||||
|
return {}
|
||||||
|
|
||||||
by_name: dict[str, set[str]] = {}
|
by_name: dict[str, set[str]] = {}
|
||||||
for key, meta in SENSOR_MAP.items():
|
for key, meta in sensor_map:
|
||||||
by_name.setdefault(meta.name, set()).add(key)
|
name = getattr(meta, "name", None)
|
||||||
|
if name is None: # pragma: no cover - defensive
|
||||||
|
continue
|
||||||
|
by_name.setdefault(name, set()).add(key)
|
||||||
|
|
||||||
twins: dict[str, frozenset[str]] = {}
|
twins: dict[str, frozenset[str]] = {}
|
||||||
for keys in by_name.values():
|
for keys in by_name.values():
|
||||||
if len(keys) > 1:
|
if len(keys) > 1:
|
||||||
|
|
@ -210,7 +230,7 @@ class EcowittBridge:
|
||||||
"""Bridge between HA webhook and aioecowitt parsing.
|
"""Bridge between HA webhook and aioecowitt parsing.
|
||||||
|
|
||||||
We do not run EcoWittListener.start() - this would start separate HTTP server.
|
We do not run EcoWittListener.start() - this would start separate HTTP server.
|
||||||
Instead we are calling listener.process_data() manualy from our webhook handler
|
Instead we are calling listener.process_data() manually from our webhook handler
|
||||||
and we are just using parsing/discovery logic.
|
and we are just using parsing/discovery logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -249,8 +269,8 @@ class EcowittBridge:
|
||||||
"""Process raw Ecowitt POST payload.
|
"""Process raw Ecowitt POST payload.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict of internal sensor keys -> values (fro mapped senors).
|
Dict of internal sensor keys -> values (for mapped sensors).
|
||||||
Unmapped sensors are handeled via _on_new_sensor callback.
|
Unmapped sensors are handled via _on_new_sensor callback.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -269,7 +289,7 @@ class EcowittBridge:
|
||||||
def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
|
def _on_new_sensor(self, sensor: EcoWittSensor) -> None:
|
||||||
"""Call me by aioecowitt when a new sensor is discovered.
|
"""Call me by aioecowitt when a new sensor is discovered.
|
||||||
|
|
||||||
If the senosor does not have internal mapping,
|
If the sensor does not have internal mapping,
|
||||||
create native Ecowitt entity.
|
create native Ecowitt entity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -313,16 +333,17 @@ class EcowittBridge:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
|
def unmapped_sensor(self) -> dict[str, EcoWittSensor]:
|
||||||
"""Return al sensors that don't have an internal mapping."""
|
"""Return all sensors that don't have an internal mapping."""
|
||||||
|
|
||||||
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
|
return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all_sensors(self) -> dict[str, EcoWittSensor]:
|
def all_sensors(self) -> dict[str, EcoWittSensor]:
|
||||||
"""Return all discovered sensors."""
|
"""Return every sensor aioecowitt has parsed so far."""
|
||||||
return self._listener.sensors
|
return self._listener.sensors
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class EcoWittNativeSensor(SensorEntity):
|
class EcoWittNativeSensor(SensorEntity):
|
||||||
"""Sensor entity for Ecowitt sensors without internal mapping.
|
"""Sensor entity for Ecowitt sensors without internal mapping.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ class HealthCoordinator(DataUpdateCoordinator):
|
||||||
- whether the request was rejected before processing
|
- whether the request was rejected before processing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# We do not want to proccess health requests
|
# We do not want to process health requests
|
||||||
if request.path == HEALTH_URL:
|
if request.path == HEALTH_URL:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Legacy battery sensor deprecation.
|
"""Legacy battery sensor deprecation.
|
||||||
|
|
||||||
The integration used to expose battery state as regular SensorEntity instance
|
The integration used to expose battery state as regular SensorEntity instance
|
||||||
(unique_id == bettery key), they have been migrated to BinarySensorEntity (uniqui_id == `key`_binary). Old entity-registry entries from
|
(unique_id == battery key), they have been migrated to BinarySensorEntity (unique_id == `key`_binary). Old entity-registry entries from
|
||||||
pre-migration installs orphan. This module raises a Repairs issue so user can celan them up.
|
pre-migration installs orphan. This module raises a Repairs issue so user can clean them up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -33,17 +33,17 @@ LEGACY_BATTERY_KEYS: Final[frozenset[str]] = frozenset(
|
||||||
|
|
||||||
|
|
||||||
def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str:
|
def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str:
|
||||||
"""Return Repairs issue id fpr this config entry."""
|
"""Return Repairs issue id for this config entry."""
|
||||||
return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
|
return f"legacy_battery_sensor_deprecation_{entry.entry_id}"
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]:
|
def _orphan_legacy_battery_entries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]:
|
||||||
"""Return entity_ids of legacy battery sensors still present in entity registry.
|
"""Return entity_ids of legacy battery sensors still present in entity registry.
|
||||||
|
|
||||||
Old non-binary battery entities have:
|
Old non-binary battery entities have:
|
||||||
- domian == "sensor"
|
- domain == "sensor"
|
||||||
- unique_id matches a LEGACY_BATTERY_KESY entry (without `_binary` suffix)
|
- unique_id matches a LEGACY_BATTERY_KEYS entry (without `_binary` suffix)
|
||||||
"""
|
"""
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
return [
|
return [
|
||||||
|
|
@ -55,10 +55,10 @@ def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) ->
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
|
def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
|
||||||
"""Create or clear a Repairs issue for orphan legacy battery snesors."""
|
"""Create or clear a Repairs issue for orphan legacy battery sensors."""
|
||||||
|
|
||||||
issue_id = _legacy_battery_issue_id(entry=entry)
|
issue_id = _legacy_battery_issue_id(entry=entry)
|
||||||
orphans = _orphan_legacy_battery_etries(hass, entry)
|
orphans = _orphan_legacy_battery_entries(hass, entry)
|
||||||
|
|
||||||
if orphans:
|
if orphans:
|
||||||
ir.async_create_issue(
|
ir.async_create_issue(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
from py_typecheck.core import checked
|
from py_typecheck.core import checked_or
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
@ -43,7 +43,6 @@ class PocasiPush:
|
||||||
"""Init."""
|
"""Init."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.config = config
|
self.config = config
|
||||||
self.enabled: bool = self.config.options.get(POCASI_CZ_ENABLED, False)
|
|
||||||
self.last_status: str = "disabled" if not self.enabled else "idle"
|
self.last_status: str = "disabled" if not self.enabled else "idle"
|
||||||
self.last_error: str | None = None
|
self.last_error: str | None = None
|
||||||
self.last_attempt_at: str | None = None
|
self.last_attempt_at: str | None = None
|
||||||
|
|
@ -55,6 +54,16 @@ class PocasiPush:
|
||||||
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
|
self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED)
|
||||||
self.invalid_response_count = 0
|
self.invalid_response_count = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Whether forwarding is currently on, read live from the options.
|
||||||
|
|
||||||
|
Toggling this option does not reload the entry (see `update_listener`), so a
|
||||||
|
cached copy would leave the diagnostics sensor reporting a stale value until
|
||||||
|
the next push - or forever, since a disabled forwarder is never called again.
|
||||||
|
"""
|
||||||
|
return checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
|
||||||
|
|
||||||
def verify_response(self, status: int, body: str) -> PocasiResult:
|
def verify_response(self, status: int, body: str) -> PocasiResult:
|
||||||
"""Classify a send by its HTTP status.
|
"""Classify a send by its HTTP status.
|
||||||
|
|
||||||
|
|
@ -76,7 +85,6 @@ class PocasiPush:
|
||||||
async def _disable_pocasi(self, reason: str) -> None:
|
async def _disable_pocasi(self, reason: str) -> None:
|
||||||
"""Turn resending off and persist it, so it survives a restart."""
|
"""Turn resending off and persist it, so it survives a restart."""
|
||||||
|
|
||||||
self.enabled = False
|
|
||||||
self.last_error = reason
|
self.last_error = reason
|
||||||
|
|
||||||
if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False):
|
if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False):
|
||||||
|
|
@ -86,17 +94,18 @@ class PocasiPush:
|
||||||
"""Pushes weather data to server."""
|
"""Pushes weather data to server."""
|
||||||
|
|
||||||
_data = data.copy()
|
_data = data.copy()
|
||||||
self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False)
|
|
||||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
self.last_attempt_at = dt_util.utcnow().isoformat()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
# An empty string is still a `str`, so `checked` alone would let unconfigured
|
||||||
|
# credentials through and send a request that can only ever be rejected.
|
||||||
|
if not (_api_id := checked_or(self.config.options.get(POCASI_CZ_API_ID), str, "")):
|
||||||
_LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.")
|
_LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.")
|
||||||
self.last_status = "config_error"
|
self.last_status = "config_error"
|
||||||
self.last_error = "Missing API ID."
|
self.last_error = "Missing API ID."
|
||||||
return
|
return
|
||||||
|
|
||||||
if (_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)) is None:
|
if not (_api_key := checked_or(self.config.options.get(POCASI_CZ_API_KEY), str, "")):
|
||||||
_LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.")
|
_LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.")
|
||||||
self.last_status = "config_error"
|
self.last_status = "config_error"
|
||||||
self.last_error = "Missing API key."
|
self.last_error = "Missing API key."
|
||||||
|
|
@ -112,7 +121,7 @@ class PocasiPush:
|
||||||
if self.next_update > dt_util.utcnow():
|
if self.next_update > dt_util.utcnow():
|
||||||
self.last_status = "rate_limited_local"
|
self.last_status = "rate_limited_local"
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s",
|
"Triggered update interval limit of %s seconds. Next possible update is set to: %s",
|
||||||
self._interval,
|
self._interval,
|
||||||
self.next_update,
|
self.next_update,
|
||||||
)
|
)
|
||||||
|
|
@ -163,7 +172,7 @@ class PocasiPush:
|
||||||
self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo."
|
self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo."
|
||||||
self.invalid_response_count += 1
|
self.invalid_response_count += 1
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Unexpected HTTP status %s from Pocasi Meteo. Retries before disabling resend: %s",
|
"Unexpected HTTP status %s from Pocasi Meteo. Rentries before disabling resend: %s",
|
||||||
http_status,
|
http_status,
|
||||||
POCASI_CZ_MAX_RETRIES - self.invalid_response_count,
|
POCASI_CZ_MAX_RETRIES - self.invalid_response_count,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class Routes:
|
||||||
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
|
def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None:
|
||||||
"""Enable or disable the Ecowitt sticky route.
|
"""Enable or disable the Ecowitt sticky route.
|
||||||
|
|
||||||
switch_route() does not involves sticky routes, so we need another
|
switch_route() does not involve sticky routes, so we need another
|
||||||
method for Ecowitt state at reload.
|
method for Ecowitt state at reload.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -229,5 +229,5 @@ async def unregistered(request: Request) -> Response:
|
||||||
a clear error message when the station pushes to the wrong endpoint.
|
a clear error message when the station pushes to the wrong endpoint.
|
||||||
"""
|
"""
|
||||||
_ = request
|
_ = request
|
||||||
_LOGGER.debug("Received data to unregistred or disabled webhook.")
|
_LOGGER.debug("Received data to unregistered or disabled webhook.")
|
||||||
return Response(text="Unregistred webhook. Check your settings.", status=400)
|
return Response(text="Unregistered webhook. Check your settings.", status=400)
|
||||||
|
|
|
||||||
|
|
@ -142,18 +142,6 @@
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
|
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"title": "Statistic migration.",
|
|
||||||
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
|
|
||||||
"data": {
|
|
||||||
"sensor_to_migrate": "Sensor to migrate",
|
|
||||||
"trigger_action": "Trigger migration"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
|
|
||||||
"trigger_action": "Trigger the sensor statistics migration after checking."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -475,7 +463,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch2_battery": {
|
"ch2_battery": {
|
||||||
|
|
@ -483,7 +471,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch3_battery": {
|
"ch3_battery": {
|
||||||
|
|
@ -491,7 +479,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch4_battery": {
|
"ch4_battery": {
|
||||||
|
|
@ -499,7 +487,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch5_battery": {
|
"ch5_battery": {
|
||||||
|
|
@ -507,7 +495,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch6_battery": {
|
"ch6_battery": {
|
||||||
|
|
@ -515,7 +503,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch7_battery": {
|
"ch7_battery": {
|
||||||
|
|
@ -523,7 +511,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch8_battery": {
|
"ch8_battery": {
|
||||||
|
|
@ -531,7 +519,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indoor_battery": {
|
"indoor_battery": {
|
||||||
|
|
@ -539,7 +527,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,7 @@
|
||||||
"windy": "Nastavení pro přeposílání dat na Windy",
|
"windy": "Nastavení pro přeposílání dat na Windy",
|
||||||
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
|
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
|
||||||
"ecowitt": "Nastavení pro stanice Ecowitt",
|
"ecowitt": "Nastavení pro stanice Ecowitt",
|
||||||
"wslink_port_setup": "Nastavení portu WSLink Addonu",
|
"wslink_port_setup": "Nastavení portu WSLink Addonu"
|
||||||
"migration": "Migrace statistiky senzoru"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic": {
|
"basic": {
|
||||||
|
|
@ -143,18 +142,6 @@
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu."
|
"WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"title": "Migrace statistiky senzoru.",
|
|
||||||
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
|
|
||||||
"data": {
|
|
||||||
"sensor_to_migrate": "Senzor pro migraci",
|
|
||||||
"trigger_action": "Spustit migraci"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"sensor_to_migrate": "Vyberte správný senzor pri migraci statistiky. \n Hodnoty senzoru budou zachovány, nepřepočítají se, pouze se změní jednotka v dlouhodobé statistice. ",
|
|
||||||
"trigger_action": "Po zaškrtnutí se spustí migrace statistiky senzoru."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -476,7 +463,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indoor_battery": {
|
"indoor_battery": {
|
||||||
|
|
@ -492,7 +479,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch3_battery": {
|
"ch3_battery": {
|
||||||
|
|
@ -500,7 +487,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch4_battery": {
|
"ch4_battery": {
|
||||||
|
|
@ -508,7 +495,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch5_battery": {
|
"ch5_battery": {
|
||||||
|
|
@ -516,7 +503,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch6_battery": {
|
"ch6_battery": {
|
||||||
|
|
@ -524,7 +511,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch7_battery": {
|
"ch7_battery": {
|
||||||
|
|
@ -532,7 +519,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch8_battery": {
|
"ch8_battery": {
|
||||||
|
|
@ -540,7 +527,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"low": "Nízká",
|
"low": "Nízká",
|
||||||
"normal": "Normální",
|
"normal": "Normální",
|
||||||
"unknown": "Neznámá / zcela vybitá"
|
"drained": "Neznámá / zcela vybitá"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,18 +142,6 @@
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
|
"WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"title": "Statistic migration.",
|
|
||||||
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
|
|
||||||
"data": {
|
|
||||||
"sensor_to_migrate": "Sensor to migrate",
|
|
||||||
"trigger_action": "Trigger migration"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.",
|
|
||||||
"trigger_action": "Trigger the sensor statistics migration after checking."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -475,7 +463,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch2_battery": {
|
"ch2_battery": {
|
||||||
|
|
@ -483,7 +471,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch3_battery": {
|
"ch3_battery": {
|
||||||
|
|
@ -491,7 +479,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch4_battery": {
|
"ch4_battery": {
|
||||||
|
|
@ -499,7 +487,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch5_battery": {
|
"ch5_battery": {
|
||||||
|
|
@ -507,7 +495,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch6_battery": {
|
"ch6_battery": {
|
||||||
|
|
@ -515,7 +503,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch7_battery": {
|
"ch7_battery": {
|
||||||
|
|
@ -523,7 +511,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ch8_battery": {
|
"ch8_battery": {
|
||||||
|
|
@ -531,7 +519,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indoor_battery": {
|
"indoor_battery": {
|
||||||
|
|
@ -539,7 +527,7 @@
|
||||||
"state": {
|
"state": {
|
||||||
"normal": "OK",
|
"normal": "OK",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"unknown": "Unknown / drained out"
|
"drained": "Unknown / drained out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,6 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str
|
||||||
|
|
||||||
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
|
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
|
||||||
|
|
||||||
entityFound: bool = False
|
|
||||||
_loaded_sensors: list[str] = loaded_sensors(config_entry)
|
_loaded_sensors: list[str] = loaded_sensors(config_entry)
|
||||||
missing_sensors: list[str] = []
|
missing_sensors: list[str] = []
|
||||||
|
|
||||||
|
|
@ -180,11 +179,10 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str
|
||||||
|
|
||||||
if item not in _loaded_sensors:
|
if item not in _loaded_sensors:
|
||||||
missing_sensors.append(item)
|
missing_sensors.append(item)
|
||||||
entityFound = True
|
|
||||||
if log:
|
if log:
|
||||||
_LOGGER.info("Add sensor (%s) to loading queue", item)
|
_LOGGER.info("Add sensor (%s) to loading queue", item)
|
||||||
|
|
||||||
return missing_sensors if entityFound else None
|
return missing_sensors or None
|
||||||
|
|
||||||
|
|
||||||
def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
|
def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
|
||||||
|
|
@ -235,18 +233,6 @@ def battery_level(battery: int | str | None) -> UnitOfBat:
|
||||||
return level_map.get(vi, UnitOfBat.UNKNOWN)
|
return level_map.get(vi, UnitOfBat.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
def battery_level_to_icon(battery: UnitOfBat) -> str:
|
|
||||||
"""Return battery level in icon representation.
|
|
||||||
|
|
||||||
Returns str
|
|
||||||
"""
|
|
||||||
|
|
||||||
icons = {
|
|
||||||
UnitOfBat.LOW: "mdi:battery-low",
|
|
||||||
UnitOfBat.NORMAL: "mdi:battery",
|
|
||||||
}
|
|
||||||
|
|
||||||
return icons.get(battery, "mdi:battery-unknown")
|
|
||||||
|
|
||||||
|
|
||||||
def fahrenheit_to_celsius(fahrenheit: float) -> float:
|
def fahrenheit_to_celsius(fahrenheit: float) -> float:
|
||||||
|
|
@ -382,17 +368,27 @@ def chill_index(data: dict[str, str | float | int], convert: bool = False) -> fl
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def voc_level_to_text(value: str | None) -> VOCLevel | None:
|
def voc_level_to_text(value: Any) -> VOCLevel | None:
|
||||||
"""Map 1-5 VOC level to text state."""
|
"""Map the 1-5 VOC level to a text state.
|
||||||
if value in (None, ""):
|
|
||||||
|
Goes through `to_int` like every other value_fn: a bare `int()` raises on a garbage
|
||||||
|
payload value, which `WeatherSensor.native_value` then logs with a full traceback on
|
||||||
|
every push.
|
||||||
|
"""
|
||||||
|
level = to_int(value)
|
||||||
|
if level is None:
|
||||||
return None
|
return None
|
||||||
return VOC_LEVEL_MAP.get(int(value))
|
return VOC_LEVEL_MAP.get(level)
|
||||||
|
|
||||||
|
|
||||||
def battery_5step_to_pct(value: str) -> int | None:
|
def battery_5step_to_pct(value: Any) -> int | None:
|
||||||
"""Convert 0-5 battery steps to percentage."""
|
"""Convert the 0-5 battery step to a percentage.
|
||||||
|
|
||||||
if value in (None, ""):
|
Out-of-range steps are clamped so the reading stays valid for a battery
|
||||||
|
device class (see `voc_level_to_text` for why `to_int` is used).
|
||||||
|
"""
|
||||||
|
step = to_int(value)
|
||||||
|
if step is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return round(int(value) / 5 * 100)
|
return round(min(max(step, 0), 5) / 5 * 100)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import logging
|
||||||
|
|
||||||
from aiohttp.client import ClientResponse
|
from aiohttp.client import ClientResponse
|
||||||
from aiohttp.client_exceptions import ClientError
|
from aiohttp.client_exceptions import ClientError
|
||||||
from py_typecheck import checked
|
from py_typecheck import checked_or
|
||||||
|
|
||||||
from homeassistant.components import persistent_notification
|
from homeassistant.components import persistent_notification
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
|
@ -81,7 +81,6 @@ class WindyPush:
|
||||||
"""Init."""
|
"""Init."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.config = config
|
self.config = config
|
||||||
self.enabled: bool = self.config.options.get(WINDY_ENABLED, False)
|
|
||||||
self.last_status: str = "disabled" if not self.enabled else "idle"
|
self.last_status: str = "disabled" if not self.enabled else "idle"
|
||||||
self.last_error: str | None = None
|
self.last_error: str | None = None
|
||||||
self.last_attempt_at: str | None = None
|
self.last_attempt_at: str | None = None
|
||||||
|
|
@ -94,10 +93,20 @@ class WindyPush:
|
||||||
|
|
||||||
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
||||||
|
|
||||||
# Lets chcek if Windy server is responding right.
|
# Lets check if Windy server is responding right.
|
||||||
# Otherwise, try 3 times and then disable resending.
|
# Otherwise, try WINDY_MAX_RETRIES times and then disable resending.
|
||||||
self.invalid_response_count: int = 0
|
self.invalid_response_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""Whether forwarding is currently on, read live from the options.
|
||||||
|
|
||||||
|
Toggling this option does not reload the entry (see `update_listener`), so a
|
||||||
|
cached copy would leave the diagnostics sensor reporting a stale value until
|
||||||
|
the next push - or forever, since a disabled forwarder is never called again.
|
||||||
|
"""
|
||||||
|
return checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
|
||||||
|
|
||||||
# Refactored responses verification.
|
# Refactored responses verification.
|
||||||
#
|
#
|
||||||
# We now comply to API at https://stations.windy.com/api-reference
|
# We now comply to API at https://stations.windy.com/api-reference
|
||||||
|
|
@ -150,15 +159,18 @@ class WindyPush:
|
||||||
return indata
|
return indata
|
||||||
|
|
||||||
async def _disable_windy(self, reason: str) -> None:
|
async def _disable_windy(self, reason: str) -> None:
|
||||||
"""Disable Windy resending."""
|
"""Disable Windy resending.
|
||||||
self.enabled = False
|
|
||||||
|
`enabled` reads the option back, so persisting it here is what actually turns
|
||||||
|
forwarding off.
|
||||||
|
"""
|
||||||
self.last_status = "disabled"
|
self.last_status = "disabled"
|
||||||
self.last_error = reason
|
self.last_error = reason
|
||||||
|
|
||||||
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
|
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
|
||||||
_LOGGER.debug("Failed to set Windy options to false.")
|
_LOGGER.debug("Failed to set Windy options to false.")
|
||||||
|
|
||||||
persistent_notification.create(self.hass, reason, "Windy resending disabled.")
|
persistent_notification.async_create(self.hass, reason, "Windy resending disabled.")
|
||||||
|
|
||||||
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
|
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
|
||||||
"""Pushes weather data do Windy stations.
|
"""Pushes weather data do Windy stations.
|
||||||
|
|
@ -170,19 +182,20 @@ class WindyPush:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# First check if we have valid credentials, before any data manipulation.
|
# First check if we have valid credentials, before any data manipulation.
|
||||||
self.enabled = self.config.options.get(WINDY_ENABLED, False)
|
|
||||||
self.last_attempt_at = dt_util.utcnow().isoformat()
|
self.last_attempt_at = dt_util.utcnow().isoformat()
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None:
|
# An empty string is still a `str`, so `checked` alone would let unconfigured
|
||||||
_LOGGER.error("Windy API key is not provided! Check your configuration.")
|
# credentials through and send a request that can only ever be rejected.
|
||||||
|
if not (windy_station_id := checked_or(self.config.options.get(WINDY_STATION_ID), str, "")):
|
||||||
|
_LOGGER.error("Windy station ID is not provided! Check your configuration.")
|
||||||
self.last_status = "config_error"
|
self.last_status = "config_error"
|
||||||
await self._disable_windy(
|
await self._disable_windy(
|
||||||
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration."
|
"Windy station ID is not provided. Resending is disabled for now. Reconfigure your integration."
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if (windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)) is None:
|
if not (windy_station_pw := checked_or(self.config.options.get(WINDY_STATION_PW), str, "")):
|
||||||
_LOGGER.error("Windy station password is missing! Check your configuration.")
|
_LOGGER.error("Windy station password is missing! Check your configuration.")
|
||||||
self.last_status = "config_error"
|
self.last_status = "config_error"
|
||||||
await self._disable_windy(
|
await self._disable_windy(
|
||||||
|
|
@ -238,7 +251,7 @@ class WindyPush:
|
||||||
|
|
||||||
# log despite of settings
|
# log despite of settings
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"%s Max retries before disable resend function: %s",
|
"%s Max rentries before disable resend function: %s",
|
||||||
WINDY_NOT_INSERTED,
|
WINDY_NOT_INSERTED,
|
||||||
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
||||||
)
|
)
|
||||||
|
|
@ -255,7 +268,7 @@ class WindyPush:
|
||||||
self.last_status = "duplicate"
|
self.last_status = "duplicate"
|
||||||
self.last_error = "Duplicate payload detected by Windy server."
|
self.last_error = "Duplicate payload detected by Windy server."
|
||||||
_LOGGER.critical(
|
_LOGGER.critical(
|
||||||
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s",
|
"Duplicate payload detected by Windy server. Will try again later. Max rentries before disabling resend function: %s",
|
||||||
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
||||||
)
|
)
|
||||||
self.invalid_response_count += 1
|
self.invalid_response_count += 1
|
||||||
|
|
@ -283,12 +296,15 @@ class WindyPush:
|
||||||
self.invalid_response_count += 1
|
self.invalid_response_count += 1
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Unexpected response from Windy. Max retries before disabling resend function: %s",
|
"Unexpected response from Windy. Max rentries before disabling resend function: %s",
|
||||||
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if self.invalid_response_count >= 3:
|
if self.invalid_response_count >= WINDY_MAX_RETRIES:
|
||||||
_LOGGER.critical("Invalid response from Windy 3 times. Disabling resend option.")
|
_LOGGER.critical(
|
||||||
|
"Invalid response from Windy %s times. Disabling resend option.",
|
||||||
|
WINDY_MAX_RETRIES,
|
||||||
|
)
|
||||||
await self._disable_windy(
|
await self._disable_windy(
|
||||||
reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
|
reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
|
||||||
)
|
)
|
||||||
|
|
@ -299,7 +315,7 @@ class WindyPush:
|
||||||
# attributes; str(ex) could embed the request URL.
|
# attributes; str(ex) could embed the request URL.
|
||||||
self.last_error = type(ex).__name__
|
self.last_error = type(ex).__name__
|
||||||
_LOGGER.critical(
|
_LOGGER.critical(
|
||||||
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s",
|
"Invalid response from Windy: %s. Will try again later, max rentries before disabling resend function: %s",
|
||||||
str(ex),
|
str(ex),
|
||||||
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
(WINDY_MAX_RETRIES - self.invalid_response_count),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from custom_components.sws12500.const import (
|
||||||
POCASI_CZ_LOGGER_ENABLED,
|
POCASI_CZ_LOGGER_ENABLED,
|
||||||
POCASI_CZ_SEND_INTERVAL,
|
POCASI_CZ_SEND_INTERVAL,
|
||||||
POCASI_CZ_SEND_MINIMUM,
|
POCASI_CZ_SEND_MINIMUM,
|
||||||
|
SENSORS_TO_LOAD,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
WINDY_LOGGER_ENABLED,
|
WINDY_LOGGER_ENABLED,
|
||||||
WINDY_STATION_ID,
|
WINDY_STATION_ID,
|
||||||
|
|
@ -463,3 +464,49 @@ async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integration
|
||||||
assert done["type"] == "create_entry"
|
assert done["type"] == "create_entry"
|
||||||
assert done["data"][ECOWITT_ENABLED] is True
|
assert done["data"][ECOWITT_ENABLED] is True
|
||||||
assert done["data"][LEGACY_ENABLED] is False
|
assert done["data"][LEGACY_ENABLED] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_flow_does_not_roll_back_concurrent_autodiscovery(
|
||||||
|
hass,
|
||||||
|
enable_custom_integrations,
|
||||||
|
) -> None:
|
||||||
|
"""Auto-discovery that lands while the dialog is open must survive the submit.
|
||||||
|
|
||||||
|
The options flow snapshots the entry when a step opens, but the webhook handler
|
||||||
|
appends to SENSORS_TO_LOAD independently. Writing back the snapshot would silently
|
||||||
|
drop any sensor discovered in between.
|
||||||
|
"""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={},
|
||||||
|
options={
|
||||||
|
API_ID: "station",
|
||||||
|
API_KEY: "secret",
|
||||||
|
LEGACY_ENABLED: True,
|
||||||
|
SENSORS_TO_LOAD: ["outside_temp"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
|
||||||
|
|
||||||
|
# The station starts reporting a new field while the form is on screen.
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
options={**entry.options, SENSORS_TO_LOAD: ["outside_temp", "wind_gust"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
done = await hass.config_entries.options.async_configure(
|
||||||
|
init["flow_id"],
|
||||||
|
user_input={
|
||||||
|
API_ID: "station",
|
||||||
|
API_KEY: "secret",
|
||||||
|
WSLINK: False,
|
||||||
|
LEGACY_ENABLED: True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert done["type"] == "create_entry"
|
||||||
|
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,21 @@ def _make_entry(
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _write_through_update_options(entry: Any) -> AsyncMock:
|
||||||
|
"""Mock `update_options` that really mutates the entry, like the real helper.
|
||||||
|
|
||||||
|
`PocasiPush.enabled` reads the option back, so a mock that only records the call
|
||||||
|
would leave `enabled` reporting the pre-disable value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _apply(_hass, _entry, key, value):
|
||||||
|
entry.options[key] = value
|
||||||
|
return True
|
||||||
|
|
||||||
|
return AsyncMock(side_effect=_apply)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def hass():
|
def hass():
|
||||||
# Minimal hass-like object; we patch client session retrieval.
|
# Minimal hass-like object; we patch client session retrieval.
|
||||||
|
|
@ -206,7 +221,7 @@ async def test_push_data_to_server_auth_error_disables_feature(monkeypatch, hass
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
|
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=True)
|
update_options = _write_through_update_options(entry)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
||||||
)
|
)
|
||||||
|
|
@ -265,7 +280,7 @@ async def test_push_data_to_server_server_error_disables_after_max_retries(monke
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
|
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=True)
|
update_options = _write_through_update_options(entry)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
||||||
)
|
)
|
||||||
|
|
@ -292,7 +307,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
|
||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
pp = PocasiPush(hass, entry)
|
pp = PocasiPush(hass, entry)
|
||||||
|
|
||||||
update_options = AsyncMock(return_value=True)
|
update_options = _write_through_update_options(entry)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
"custom_components.sws12500.pocasti_cz.update_options", update_options
|
||||||
)
|
)
|
||||||
|
|
@ -369,6 +384,57 @@ async def test_disable_pocasi_logs_when_option_write_fails(monkeypatch, hass):
|
||||||
|
|
||||||
await pp._disable_pocasi("because")
|
await pp._disable_pocasi("because")
|
||||||
|
|
||||||
assert pp.enabled is False
|
# `enabled` mirrors the persisted option: if the write failed, forwarding is still
|
||||||
|
# on as far as the config is concerned, and the failure is logged instead.
|
||||||
|
assert pp.enabled is True
|
||||||
assert pp.last_error == "because"
|
assert pp.last_error == "because"
|
||||||
dbg.assert_called()
|
dbg.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live `enabled` and empty-credential rejection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_reads_options_live(hass):
|
||||||
|
"""Toggling the option is visible immediately - no reload, no cached copy.
|
||||||
|
|
||||||
|
`update_listener` deliberately skips the reload when only this flag changes, so a
|
||||||
|
value cached in __init__ would leave the diagnostics sensor permanently stale.
|
||||||
|
"""
|
||||||
|
entry = _make_entry()
|
||||||
|
pp = PocasiPush(hass, entry)
|
||||||
|
assert pp.enabled is True
|
||||||
|
|
||||||
|
entry.options[POCASI_CZ_ENABLED] = False
|
||||||
|
assert pp.enabled is False
|
||||||
|
|
||||||
|
entry.options[POCASI_CZ_ENABLED] = True
|
||||||
|
assert pp.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("api_id", "api_key"),
|
||||||
|
[("", "key"), ("id", ""), ("", "")],
|
||||||
|
ids=["empty-id", "empty-key", "both-empty"],
|
||||||
|
)
|
||||||
|
async def test_empty_credentials_never_reach_the_network(monkeypatch, hass, api_id, api_key):
|
||||||
|
"""An empty string is still a `str`, so it must be rejected explicitly.
|
||||||
|
|
||||||
|
Otherwise a blank configuration sends a request that can only ever be refused.
|
||||||
|
"""
|
||||||
|
entry = _make_entry(api_id=api_id, api_key=api_key)
|
||||||
|
pp = PocasiPush(hass, entry)
|
||||||
|
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||||
|
|
||||||
|
session = _FakeSession(response=_FakeResponse("OK"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
|
||||||
|
lambda _h: session,
|
||||||
|
)
|
||||||
|
|
||||||
|
await pp.push_data_to_server({"x": 1}, "WU")
|
||||||
|
|
||||||
|
assert session.calls == []
|
||||||
|
assert pp.last_status == "config_error"
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
||||||
- process_payload returns a mapped dict
|
- process_payload returns a mapped dict
|
||||||
- check_disabled returns new keys -> autodiscovery (update_options,
|
- check_disabled returns new keys -> autodiscovery (update_options,
|
||||||
add_new_binary_sensors, add_new_sensors)
|
add_new_binary_sensors, add_new_sensors)
|
||||||
- async_set_updated_data + last_seen + update_stale_sensors_issue
|
- async_set_updated_data + last_seen
|
||||||
- health.update_ingress_result(accepted) + windy + pocasi forwarding
|
- health.update_ingress_result(accepted) + windy + pocasi forwarding
|
||||||
- health.update_forwarding
|
- health.update_forwarding
|
||||||
- dev log via anonymize
|
- dev log via anonymize
|
||||||
|
|
@ -223,11 +223,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
||||||
"custom_components.sws12500.coordinator.add_new_binary_sensors",
|
"custom_components.sws12500.coordinator.add_new_binary_sensors",
|
||||||
add_new_binary_sensors,
|
add_new_binary_sensors,
|
||||||
)
|
)
|
||||||
update_stale = MagicMock()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
|
||||||
update_stale,
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinator.windy.push_data_to_windy = AsyncMock()
|
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||||
|
|
@ -261,7 +256,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
|
||||||
|
|
||||||
# Coordinator data + staleness + last_seen.
|
# Coordinator data + staleness + last_seen.
|
||||||
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
||||||
update_stale.assert_called_once()
|
|
||||||
assert "outside_temp" in entry.runtime_data.last_seen
|
assert "outside_temp" in entry.runtime_data.last_seen
|
||||||
|
|
||||||
# Forwarding: windy receives the raw data dict + False, pocasi receives "WU".
|
# Forwarding: windy receives the raw data dict + False, pocasi receives "WU".
|
||||||
|
|
@ -314,11 +308,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding
|
||||||
"custom_components.sws12500.coordinator.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _mapped, _config: [],
|
lambda _mapped, _config: [],
|
||||||
)
|
)
|
||||||
update_stale = MagicMock()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
|
||||||
update_stale,
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinator.windy.push_data_to_windy = AsyncMock()
|
coordinator.windy.push_data_to_windy = AsyncMock()
|
||||||
coordinator.pocasi.push_data_to_server = AsyncMock()
|
coordinator.pocasi.push_data_to_server = AsyncMock()
|
||||||
|
|
@ -329,7 +318,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding
|
||||||
|
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
coordinator.async_set_updated_data.assert_called_once_with(mapped)
|
||||||
update_stale.assert_called_once()
|
|
||||||
coordinator.windy.push_data_to_windy.assert_not_awaited()
|
coordinator.windy.push_data_to_windy.assert_not_awaited()
|
||||||
coordinator.pocasi.push_data_to_server.assert_not_awaited()
|
coordinator.pocasi.push_data_to_server.assert_not_awaited()
|
||||||
|
|
||||||
|
|
@ -361,9 +349,6 @@ async def test_received_ecowitt_autodiscovery_extends_with_loaded_sensors(hass,
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock()
|
"custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock()
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
|
||||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
|
|
||||||
)
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||||
|
|
@ -392,9 +377,6 @@ async def test_health_coordinator_attribute_error_returns_none(hass, monkeypatch
|
||||||
"custom_components.sws12500.coordinator.check_disabled",
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
lambda _mapped, _config: [],
|
lambda _mapped, _config: [],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
|
||||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock()
|
|
||||||
)
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||||
|
|
@ -410,11 +392,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc
|
||||||
|
|
||||||
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={})
|
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={})
|
||||||
|
|
||||||
update_stale = MagicMock()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"custom_components.sws12500.coordinator.update_stale_sensors_issue",
|
|
||||||
update_stale,
|
|
||||||
)
|
|
||||||
coordinator.async_set_updated_data = MagicMock()
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
request = _EcowittRequestStub(match_info={"webhook_id": "hook"})
|
||||||
|
|
@ -422,7 +399,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc
|
||||||
|
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
coordinator.async_set_updated_data.assert_not_called()
|
coordinator.async_set_updated_data.assert_not_called()
|
||||||
update_stale.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
|
||||||
``t9cn`` parameters:
|
``t9cn`` parameters:
|
||||||
|
|
||||||
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
|
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
|
||||||
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``)
|
``VOCLevel`` / ``VOC_LEVEL_MAP``)
|
||||||
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
|
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
|
||||||
- the connection gating in ``utils.remap_wslink_items``
|
- the connection gating in ``utils.remap_wslink_items``
|
||||||
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
|
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
|
||||||
|
|
@ -20,7 +20,6 @@ import pytest
|
||||||
|
|
||||||
from custom_components.sws12500.const import (
|
from custom_components.sws12500.const import (
|
||||||
BATTERY_LIST,
|
BATTERY_LIST,
|
||||||
BATTERY_NON_BINARY,
|
|
||||||
CONNECTION_GATED_SENSORS,
|
CONNECTION_GATED_SENSORS,
|
||||||
HCHO,
|
HCHO,
|
||||||
OUTSIDE_TEMP,
|
OUTSIDE_TEMP,
|
||||||
|
|
@ -76,7 +75,6 @@ def test_connection_gated_sensors_definition() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_t9_battery_is_non_binary_only() -> None:
|
def test_t9_battery_is_non_binary_only() -> None:
|
||||||
assert BATTERY_NON_BINARY == (T9_BATTERY,)
|
|
||||||
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
||||||
assert T9_BATTERY not in BATTERY_LIST
|
assert T9_BATTERY not in BATTERY_LIST
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ from custom_components.sws12500.const import (
|
||||||
)
|
)
|
||||||
from custom_components.sws12500.utils import (
|
from custom_components.sws12500.utils import (
|
||||||
anonymize,
|
anonymize,
|
||||||
|
battery_5step_to_pct,
|
||||||
battery_level,
|
battery_level,
|
||||||
battery_level_to_icon,
|
|
||||||
celsius_to_fahrenheit,
|
celsius_to_fahrenheit,
|
||||||
check_disabled,
|
check_disabled,
|
||||||
chill_index,
|
chill_index,
|
||||||
|
|
@ -32,6 +32,7 @@ from custom_components.sws12500.utils import (
|
||||||
translated_notification,
|
translated_notification,
|
||||||
translations,
|
translations,
|
||||||
update_options,
|
update_options,
|
||||||
|
voc_level_to_text,
|
||||||
wind_dir_to_text,
|
wind_dir_to_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -253,11 +254,6 @@ def test_battery_level_handles_none_empty_invalid_and_known_values():
|
||||||
assert battery_level("2") == UnitOfBat.UNKNOWN
|
assert battery_level("2") == UnitOfBat.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
def test_battery_level_to_icon_maps_all_and_unknown():
|
|
||||||
assert battery_level_to_icon(UnitOfBat.LOW) == "mdi:battery-low"
|
|
||||||
assert battery_level_to_icon(UnitOfBat.NORMAL) == "mdi:battery"
|
|
||||||
assert battery_level_to_icon(UnitOfBat.UNKNOWN) == "mdi:battery-unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def test_temperature_conversions_round_trip():
|
def test_temperature_conversions_round_trip():
|
||||||
# Use a value that is exactly representable in binary-ish floats
|
# Use a value that is exactly representable in binary-ish floats
|
||||||
|
|
@ -362,3 +358,36 @@ def test_chill_index_returns_temp_when_not_cold_or_not_windy():
|
||||||
def test_chill_index_convert_from_celsius_path():
|
def test_chill_index_convert_from_celsius_path():
|
||||||
out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True)
|
out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True)
|
||||||
assert out is not None
|
assert out is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Converters must degrade to None, not raise
|
||||||
|
#
|
||||||
|
# `WeatherSensor.native_value` catches value_fn exceptions and logs them with
|
||||||
|
# `_LOGGER.exception`, so a bare int() on a garbage payload value produced a full
|
||||||
|
# traceback on *every* push rather than a quiet `unknown` state.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", "1.5.2", [], {}])
|
||||||
|
def test_voc_level_to_text_rejects_garbage(bad):
|
||||||
|
assert voc_level_to_text(bad) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", None, "n/a", "--", object()])
|
||||||
|
def test_battery_5step_to_pct_rejects_garbage(bad):
|
||||||
|
assert battery_5step_to_pct(bad) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[(0, 0), ("0", 0), (1, 20), ("3", 60), (5, 100), ("5.0", 100)],
|
||||||
|
)
|
||||||
|
def test_battery_5step_to_pct_maps_the_scale(value, expected):
|
||||||
|
assert battery_5step_to_pct(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "expected"), [(-3, 0), (9, 100)])
|
||||||
|
def test_battery_5step_to_pct_clamps_out_of_range(value, expected):
|
||||||
|
"""Out-of-range steps stay a valid battery percentage."""
|
||||||
|
assert battery_5step_to_pct(value) == expected
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ async def test_push_duplicate_third_strike_disables(monkeypatch, hass):
|
||||||
"custom_components.sws12500.windy_func.update_options", update_options
|
"custom_components.sws12500.windy_func.update_options", update_options
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch,
|
||||||
"custom_components.sws12500.windy_func.update_options", update_options
|
"custom_components.sws12500.windy_func.update_options", update_options
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -252,7 +252,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch,
|
||||||
"custom_components.sws12500.windy_func.update_options", update_options
|
"custom_components.sws12500.windy_func.update_options", update_options
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -281,7 +281,7 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha
|
||||||
"custom_components.sws12500.windy_func.update_options", update_options
|
"custom_components.sws12500.windy_func.update_options", update_options
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -314,7 +314,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de
|
||||||
dbg = MagicMock()
|
dbg = MagicMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -416,7 +416,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
|
||||||
crit = MagicMock()
|
crit = MagicMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -462,7 +462,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
|
||||||
dbg = MagicMock()
|
dbg = MagicMock()
|
||||||
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.sws12500.windy_func.persistent_notification.create",
|
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue