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.
ecowitt_support
v2.0.0pre1
parent
50fe9e35fe
commit
15e9df00ca
|
|
@ -43,7 +43,10 @@ from .const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
|
DEV_DBG,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ECOWITT_ENABLED,
|
||||||
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
SENSORS_TO_LOAD,
|
SENSORS_TO_LOAD,
|
||||||
|
|
@ -52,6 +55,7 @@ from .const import (
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS
|
from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS
|
||||||
|
from .ecowitt import EcowittBridge # noqa: PLC0415
|
||||||
from .health_coordinator import HealthCoordinator
|
from .health_coordinator import HealthCoordinator
|
||||||
from .pocasti_cz import PocasiPush
|
from .pocasti_cz import PocasiPush
|
||||||
from .routes import Routes
|
from .routes import Routes
|
||||||
|
|
@ -68,7 +72,7 @@ from .utils import (
|
||||||
from .windy_func import WindyPush
|
from .windy_func import WindyPush
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||||
|
|
||||||
|
|
||||||
class IncorrectDataError(InvalidStateError):
|
class IncorrectDataError(InvalidStateError):
|
||||||
|
|
@ -102,6 +106,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
self.config: ConfigEntry = config
|
self.config: ConfigEntry = config
|
||||||
self.windy: WindyPush = WindyPush(hass, config)
|
self.windy: WindyPush = WindyPush(hass, config)
|
||||||
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
||||||
|
|
||||||
|
# Ecowitt bridge - aioecowitt parser without HTTP server
|
||||||
|
|
||||||
|
self.ecowitt_bridge: EcowittBridge = EcowittBridge(hass, config)
|
||||||
|
|
||||||
super().__init__(hass, _LOGGER, name=DOMAIN)
|
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||||
|
|
||||||
def _health_coordinator(self) -> HealthCoordinator | None:
|
def _health_coordinator(self) -> HealthCoordinator | None:
|
||||||
|
|
@ -114,6 +123,93 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
coordinator = entry.get(ENTRY_HEALTH_COORD)
|
coordinator = entry.get(ENTRY_HEALTH_COORD)
|
||||||
return coordinator if isinstance(coordinator, HealthCoordinator) else None
|
return coordinator if isinstance(coordinator, HealthCoordinator) else None
|
||||||
|
|
||||||
|
async def recieved_ecowitt_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||||
|
"""Handle incoming Ecowitt webhook payload.
|
||||||
|
|
||||||
|
We are using aioecowitt for parsing payload. Sensors with internal
|
||||||
|
mapping will use SWS pipline. Sensors withou mapping will create
|
||||||
|
native Ecowitt entity trough bridge callback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .const import ECOWITT_ENABLED, ECOWITT_WEBHOOK_ID # noqa: PLC0415
|
||||||
|
|
||||||
|
health = self._health_coordinator()
|
||||||
|
|
||||||
|
# Do we have Ecowitt enabled?
|
||||||
|
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="Ecowwit disabled", status=403)
|
||||||
|
|
||||||
|
# Check webhook ID from URL
|
||||||
|
expected_webhook = self.config.options.get(ECOWITT_WEBHOOK_ID, "")
|
||||||
|
actual_webhook = webdata.match_info.get("webhook_id", "")
|
||||||
|
|
||||||
|
if not expected_webhook or actual_webhook != expected_webhook:
|
||||||
|
_LOGGER.error("Ecowitt: invalid webhook ID")
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=False,
|
||||||
|
authorized=False,
|
||||||
|
reason="ecowitt_invalid_webhook_id",
|
||||||
|
)
|
||||||
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
|
# Parse POST body
|
||||||
|
post_data = await webdata.post()
|
||||||
|
data: dict[str, Any] = dict(post_data)
|
||||||
|
|
||||||
|
# Bridge: aioecowitt parsing + internal remap
|
||||||
|
mapped_data = await self.ecowitt_bridge.process_payload(data)
|
||||||
|
|
||||||
|
# Mapped sensors to SWS pipline (auto-discovery + fan-out)
|
||||||
|
if mapped_data:
|
||||||
|
if sensors := check_disabled(mapped_data, self.config):
|
||||||
|
newly_discovered = list(sensors)
|
||||||
|
if _loaded_senosrs := loaded_sensors(self.config):
|
||||||
|
sensors.extend(_loaded_senosrs)
|
||||||
|
await update_options(self.hass, self.config, SENSORS_TO_LOAD, sensors)
|
||||||
|
|
||||||
|
from .binary_sensor import add_new_binary_sensors # noqa: PLC0415
|
||||||
|
from .sensor import add_new_sensors # noqa: PLC0415
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if health:
|
||||||
|
health.update_ingress_result(
|
||||||
|
webdata,
|
||||||
|
accepted=True,
|
||||||
|
authorized=True,
|
||||||
|
reason="accepted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Forwarding (mapped data in WU units)
|
||||||
|
_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)
|
||||||
|
|
||||||
|
# Will push just WU payload to POCASI
|
||||||
|
# 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(self.config.options.get(DEV_DBG), True)) is not None:
|
||||||
|
_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:
|
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||||
"""Handle incoming webhook payload from the station.
|
"""Handle incoming webhook payload from the station.
|
||||||
|
|
||||||
|
|
@ -268,9 +364,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
# NOTE: Some linters prefer top-level imports. In this case the local import is
|
# NOTE: Some linters prefer top-level imports. In this case the local import is
|
||||||
# intentional and prevents "partially initialized module" errors.
|
# intentional and prevents "partially initialized module" errors.
|
||||||
|
|
||||||
|
from .binary_sensor import add_new_binary_sensors # noqa: PLC0415 (local import is intentional)
|
||||||
from .sensor import add_new_sensors # noqa: PLC0415 (local import is intentional)
|
from .sensor import add_new_sensors # noqa: PLC0415 (local import is intentional)
|
||||||
|
|
||||||
add_new_sensors(self.hass, self.config, newly_discovered)
|
add_new_sensors(self.hass, self.config, newly_discovered)
|
||||||
|
add_new_binary_sensors(self.hass, self.config, newly_discovered)
|
||||||
|
|
||||||
# Fan-out update: notify all subscribed entities.
|
# Fan-out update: notify all subscribed entities.
|
||||||
self.async_set_updated_data(remaped_items)
|
self.async_set_updated_data(remaped_items)
|
||||||
|
|
@ -322,6 +420,7 @@ def register_path(
|
||||||
raise ConfigEntryNotReady
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||||
|
_ecowitt_enabled: bool = checked_or(config.options.get(ECOWITT_ENABLED), bool, False)
|
||||||
|
|
||||||
# Load registred routes
|
# Load registred routes
|
||||||
routes: Routes | None = hass_data.get("routes", None)
|
routes: Routes | None = hass_data.get("routes", None)
|
||||||
|
|
@ -337,6 +436,12 @@ def register_path(
|
||||||
_wslink_get_route = hass.http.app.router.add_get(WSLINK_URL, routes.dispatch, name="_wslink_get_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")
|
_health_route = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch, name="_health_route")
|
||||||
|
|
||||||
|
# Ecowitt URL contains {webhook_id} as a parameter.
|
||||||
|
# Station is configured to send data to: http://ha:8123/weatherhub/<webhook_id>
|
||||||
|
|
||||||
|
_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
|
# Save initialised routes
|
||||||
hass_data["routes"] = routes
|
hass_data["routes"] = routes
|
||||||
|
|
||||||
|
|
@ -356,6 +461,13 @@ def register_path(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
sticky=True,
|
sticky=True,
|
||||||
)
|
)
|
||||||
|
routes.add_route(
|
||||||
|
_ecowitt_path,
|
||||||
|
_ecowitt_route,
|
||||||
|
coordinator.recieved_ecowitt_data,
|
||||||
|
enabled=_ecowitt_enabled,
|
||||||
|
sticky=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
routes.set_ingress_observer(coordinator_h.record_dispatch)
|
routes.set_ingress_observer(coordinator_h.record_dispatch)
|
||||||
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
|
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
|
||||||
|
|
@ -418,12 +530,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options)
|
||||||
|
|
||||||
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
||||||
|
_ecowitt_enabled = checked_or(entry.options.get(ECOWITT_ENABLED), bool, False)
|
||||||
|
_ecowitt_path = ECOWITT_URL_PREFIX + "/{webhook_id}"
|
||||||
|
|
||||||
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
||||||
|
|
||||||
if routes:
|
if routes:
|
||||||
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
_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)
|
routes.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL)
|
||||||
|
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.recieved_ecowitt_data, _ecowitt_enabled)
|
||||||
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
||||||
coordinator_health.update_routing(routes)
|
coordinator_health.update_routing(routes)
|
||||||
_LOGGER.debug("%s", routes.show_enabled())
|
_LOGGER.debug("%s", routes.show_enabled())
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
"""Battery binary sensor entities."""
|
"""Battery binary sensor entities for SWS 12500.
|
||||||
|
|
||||||
|
Expose low-batter warnings as binary sensors.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cached_property
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from py_typecheck import checked_or
|
from py_typecheck import checked_or
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
CoordinatorEntity, BinarySensorEntity
|
CoordinatorEntity, BinarySensorEntity
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
"""Battery sensors."""
|
"""Battery sensors."""
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
|
||||||
BinarySensorDeviceClass,
|
|
||||||
BinarySensorEntityDescription,
|
|
||||||
)
|
|
||||||
|
|
||||||
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
||||||
BinarySensorEntityDescription(
|
BinarySensorEntityDescription(
|
||||||
|
|
@ -21,4 +18,34 @@ BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
||||||
translation_key="ch2_battery",
|
translation_key="ch2_battery",
|
||||||
device_class=BinarySensorDeviceClass.BATTERY,
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
),
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch3_battery",
|
||||||
|
translation_key="ch3_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch4_battery",
|
||||||
|
translation_key="ch4_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch5_battery",
|
||||||
|
translation_key="ch5_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch6_battery",
|
||||||
|
translation_key="ch6_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch7_battery",
|
||||||
|
translation_key="ch7_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
BinarySensorEntityDescription(
|
||||||
|
key="ch8_battery",
|
||||||
|
translation_key="ch8_battery",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""Binary sensor platform for SWS12500.
|
||||||
|
|
||||||
|
Exposes low-battery warnings as binary sensors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from py_typecheck import checked
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
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 DOMAIN, SENSORS_TO_LOAD
|
||||||
|
from .data import ENTRY_ADD_BINARY_ENTITIES, ENTRY_ADDED_BINARY_KEYS, ENTRY_BINARY_DESCRIPTION, ENTRY_COORDINATOR
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
|
"""Set up battery binary sensors."""
|
||||||
|
|
||||||
|
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
coordinator = entry_data.get(ENTRY_COORDINATOR)
|
||||||
|
if coordinator is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save callback and descriptions for later auto-discovery.
|
||||||
|
# webhook needs this to dynamicaly add entities withou reload
|
||||||
|
|
||||||
|
description = {desc.key: desc for desc in BATTERY_BINARY_SENSORS}
|
||||||
|
entry_data[ENTRY_ADD_BINARY_ENTITIES] = async_add_entities
|
||||||
|
entry_data[ENTRY_BINARY_DESCRIPTION] = description
|
||||||
|
|
||||||
|
added_keys: set[str] = set()
|
||||||
|
entry_data[ENTRY_ADDED_BINARY_KEYS] = added_keys
|
||||||
|
|
||||||
|
# Create binary sensors for battery key that station send.
|
||||||
|
# SENSORS_TO_LOAD contains all discovered keys.
|
||||||
|
|
||||||
|
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))
|
||||||
|
added_keys.add(desc.key)
|
||||||
|
|
||||||
|
if entities:
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
def add_new_binary_sensors(hass: HomeAssistant, entry: ConfigEntry, keys: list[str]) -> None:
|
||||||
|
"""Dynamic add newly discovered enetities.
|
||||||
|
|
||||||
|
Called from webhook handler in __init__.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (entry_data := checked(hass_data.get(entry.entry_id), dict[str, Any])) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
add_entities = entry_data.get(ENTRY_ADD_BINARY_ENTITIES)
|
||||||
|
description = entry_data.get(ENTRY_BINARY_DESCRIPTION)
|
||||||
|
coordinator = entry_data.get(ENTRY_COORDINATOR)
|
||||||
|
added_keys: set[str] | None = entry_data.get(ENTRY_ADDED_BINARY_KEYS)
|
||||||
|
|
||||||
|
if add_entities is None or description is None or coordinator is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if added_keys is None:
|
||||||
|
added_keys = set()
|
||||||
|
entry_data[ENTRY_ADDED_BINARY_KEYS] = added_keys
|
||||||
|
|
||||||
|
description_map = cast("dict[str, Any]", description)
|
||||||
|
added = added_keys
|
||||||
|
|
||||||
|
new_entities: list[BinarySensorEntity] = []
|
||||||
|
for key in keys:
|
||||||
|
if key in added:
|
||||||
|
continue
|
||||||
|
desc = description_map.get(key)
|
||||||
|
if desc is None:
|
||||||
|
continue
|
||||||
|
new_entities.append(BatteryBinarySensor(coordinator, desc))
|
||||||
|
added.add(key)
|
||||||
|
|
||||||
|
if new_entities:
|
||||||
|
add_fn = cast("AddEntitiesCallback", add_entities)
|
||||||
|
add_fn(new_entities)
|
||||||
|
|
@ -234,6 +234,7 @@ 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: Final = "/weather/ecowitt"
|
||||||
|
ECOWITT_URL_PREFIX: Final = "/weatherhub"
|
||||||
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
|
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
|
||||||
|
|
||||||
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""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 pipline. Unmapped sensors are exposed as
|
||||||
|
native Ecowitt entites for forward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import DOMAIN, REMAP_ECOWITT_COMPAT
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Reverse mapping: internal key to ecowitt field name
|
||||||
|
# we need to know which key is internaly covered.
|
||||||
|
_MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
|
||||||
|
|
||||||
|
# aioecowitt sensor type to HA device class + unit
|
||||||
|
# We cover most common types, addidional 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.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: ConfigEntry) -> 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 senosrs have an entity
|
||||||
|
self._know_native_keys: set[str] = set()
|
||||||
|
|
||||||
|
# Callback for new entities
|
||||||
|
self._add_entities_cb = callback
|
||||||
|
|
||||||
|
def set_add_entities(self, callback: AddEntitiesCallback) -> None:
|
||||||
|
"""Store the platform callback for dynamic entity creation."""
|
||||||
|
|
||||||
|
self._add_entities_cb = callback
|
||||||
|
|
||||||
|
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 internaly 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
|
||||||
|
|
||||||
|
if self._add_entities_cb is None:
|
||||||
|
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._know_native_keys.add(sensor.key)
|
||||||
|
entity = EcoWittNativeSensor(sensor)
|
||||||
|
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-trough" - theri 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
|
||||||
|
_atttr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, sensor: EcoWittSensor) -> None:
|
||||||
|
"""Initialize native EcoWittSensor."""
|
||||||
|
|
||||||
|
self._ecowitt_sensor = sensor
|
||||||
|
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
||||||
|
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||||
|
|
||||||
|
# set HomeAssistant metadata from aioecowitt sensor type
|
||||||
|
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_calss = state_class
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Sensor name from aioecowitt."""
|
||||||
|
return self._ecowitt_sensor.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | int | float | None:
|
||||||
|
"""Current value from Ecowitt sensor."""
|
||||||
|
value = self._ecowitt_sensor.value
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
"""Link to the Ecowitt station device."""
|
||||||
|
station = self._ecowitt_sensor.station
|
||||||
|
return DeviceInfo(
|
||||||
|
connections=set(),
|
||||||
|
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
|
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
|
||||||
|
manufacturer="Ecowitt impl. from Schizza for SWS12500",
|
||||||
|
model=station.model if station else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 sensro values update from aioecowitt."""
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
@ -36,6 +36,7 @@ from homeassistant.util import dt as dt_util
|
||||||
from .const import (
|
from .const import (
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ECOWITT_URL_PREFIX,
|
||||||
HEALTH_URL,
|
HEALTH_URL,
|
||||||
POCASI_CZ_ENABLED,
|
POCASI_CZ_ENABLED,
|
||||||
WINDY_ENABLED,
|
WINDY_ENABLED,
|
||||||
|
|
@ -64,6 +65,8 @@ def _protocol_from_path(path: str) -> str:
|
||||||
return "wu"
|
return "wu"
|
||||||
if path == HEALTH_URL:
|
if path == HEALTH_URL:
|
||||||
return "health"
|
return "health"
|
||||||
|
if path.startswith(ECOWITT_URL_PREFIX + "/"):
|
||||||
|
return "ecowitt"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@
|
||||||
"aioecowitt==2025.9.2"
|
"aioecowitt==2025.9.2"
|
||||||
],
|
],
|
||||||
"ssdp": [],
|
"ssdp": [],
|
||||||
"version": "2.0.0-pre0",
|
"version": "2.0.0-pre1",
|
||||||
"zeroconf": []
|
"zeroconf": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,18 +62,56 @@ class Routes:
|
||||||
self.routes: dict[str, RouteInfo] = {}
|
self.routes: dict[str, RouteInfo] = {}
|
||||||
self._ingress_observer: IngressObserver | None = None
|
self._ingress_observer: IngressObserver | None = None
|
||||||
|
|
||||||
|
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
||||||
|
"""Find the matching RouteInfo for a request.
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
resource = request.match_info.route.resource
|
||||||
|
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 set_ingress_observer(self, observer: IngressObserver | None) -> None:
|
def set_ingress_observer(self, observer: IngressObserver | None) -> None:
|
||||||
"""Set a callback notified for every incoming dispatcher request."""
|
"""Set a callback notified for every incoming dispatcher request."""
|
||||||
self._ingress_observer = observer
|
self._ingress_observer = observer
|
||||||
|
|
||||||
async def dispatch(self, request: Request) -> Response:
|
async def dispatch(self, request: Request) -> Response:
|
||||||
"""Dispatch incoming request to either the enabled handler or a fallback."""
|
"""Dispatch incoming request to either the enabled handler or a fallback."""
|
||||||
key = f"{request.method}:{request.path}"
|
|
||||||
info = self.routes.get(key)
|
info = self._resolve_route(request)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("Route (%s):%s is not registered!", request.method, request.path)
|
||||||
"Route (%s):%s is not registered!", request.method, request.path
|
|
||||||
)
|
|
||||||
if self._ingress_observer is not None:
|
if self._ingress_observer is not None:
|
||||||
self._ingress_observer(request, False, "route_not_registered")
|
self._ingress_observer(request, False, "route_not_registered")
|
||||||
return await unregistered(request)
|
return await unregistered(request)
|
||||||
|
|
@ -125,9 +163,7 @@ class Routes:
|
||||||
`dispatch` uses after aiohttp has routed the request by path.
|
`dispatch` uses after aiohttp has routed the request by path.
|
||||||
"""
|
"""
|
||||||
key = f"{route.method}:{url_path}"
|
key = f"{route.method}:{url_path}"
|
||||||
self.routes[key] = RouteInfo(
|
self.routes[key] = RouteInfo(url_path, route=route, handler=handler, enabled=enabled, sticky=sticky)
|
||||||
url_path, route=route, handler=handler, enabled=enabled, sticky=sticky
|
|
||||||
)
|
|
||||||
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
|
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
|
||||||
|
|
||||||
def show_enabled(self) -> str:
|
def show_enabled(self) -> str:
|
||||||
|
|
@ -144,9 +180,7 @@ class Routes:
|
||||||
|
|
||||||
def path_enabled(self, url_path: str) -> bool:
|
def path_enabled(self, url_path: str) -> bool:
|
||||||
"""Return whether any route registered for `url_path` is enabled."""
|
"""Return whether any route registered for `url_path` is enabled."""
|
||||||
return any(
|
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
|
||||||
route.enabled for route in self.routes.values() if route.url_path == url_path
|
|
||||||
)
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, Any]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
"""Return a compact routing snapshot for diagnostics."""
|
"""Return a compact routing snapshot for diagnostics."""
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,7 @@ async def async_setup_entry(
|
||||||
|
|
||||||
# we have to check if entry_data are present
|
# we have to check if entry_data are present
|
||||||
# It is created by integration setup, so it should be presnet
|
# It is created by integration setup, so it should be presnet
|
||||||
if (
|
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
|
||||||
) is None:
|
|
||||||
# This should not happen in normal operation.
|
# This should not happen in normal operation.
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -125,25 +123,24 @@ async def async_setup_entry(
|
||||||
# look up its description here and instantiate the matching entity.
|
# look up its description here and instantiate the matching entity.
|
||||||
entry_data[ENTRY_DESCRIPTIONS] = {desc.key: desc for desc in sensor_types}
|
entry_data[ENTRY_DESCRIPTIONS] = {desc.key: desc for desc in sensor_types}
|
||||||
|
|
||||||
sensors_to_load = checked_or(
|
sensors_to_load = checked_or(config_entry.options.get(SENSORS_TO_LOAD), list[str], [])
|
||||||
config_entry.options.get(SENSORS_TO_LOAD), list[str], []
|
|
||||||
)
|
|
||||||
if not sensors_to_load:
|
if not sensors_to_load:
|
||||||
return
|
return
|
||||||
|
|
||||||
requested = _auto_enable_derived_sensors(set(sensors_to_load))
|
requested = _auto_enable_derived_sensors(set(sensors_to_load))
|
||||||
|
|
||||||
entities: list[WeatherSensor] = [
|
entities: list[WeatherSensor] = [
|
||||||
WeatherSensor(description, coordinator)
|
WeatherSensor(description, coordinator) for description in sensor_types if description.key in requested
|
||||||
for description in sensor_types
|
|
||||||
if description.key in requested
|
|
||||||
]
|
]
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
# Connect Ecowitt bridge to sensor platform,
|
||||||
|
# so it can dynamically add native Ecowitt entities
|
||||||
|
if hasattr(coordinator, "ecowitt_bridge"):
|
||||||
|
coordinator.ecowitt_bridge.set_add_entities(async_add_entities)
|
||||||
|
|
||||||
def add_new_sensors(
|
|
||||||
hass: HomeAssistant, config_entry: ConfigEntry, keys: list[str]
|
def add_new_sensors(hass: HomeAssistant, config_entry: ConfigEntry, keys: list[str]) -> None:
|
||||||
) -> None:
|
|
||||||
"""Dynamically add newly discovered sensors without reloading the entry.
|
"""Dynamically add newly discovered sensors without reloading the entry.
|
||||||
|
|
||||||
Called by the webhook handler when the station starts sending new fields.
|
Called by the webhook handler when the station starts sending new fields.
|
||||||
|
|
@ -157,9 +154,7 @@ def add_new_sensors(
|
||||||
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if (
|
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
|
||||||
) is None:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
add_entities = entry_data.get(ENTRY_ADD_ENTITIES)
|
add_entities = entry_data.get(ENTRY_ADD_ENTITIES)
|
||||||
|
|
@ -208,9 +203,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
config_entry = getattr(self.coordinator, "config", None)
|
config_entry = getattr(self.coordinator, "config", None)
|
||||||
self._dev_log = checked_or(
|
self._dev_log = checked_or(
|
||||||
config_entry.options.get("dev_debug_checkbox")
|
config_entry.options.get("dev_debug_checkbox") if config_entry is not None else False,
|
||||||
if config_entry is not None
|
|
||||||
else False,
|
|
||||||
bool,
|
bool,
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
|
|
@ -236,9 +229,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
try:
|
try:
|
||||||
value = description.value_from_data_fn(data)
|
value = description.value_from_data_fn(data)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_LOGGER.exception(
|
_LOGGER.exception("native_value compute failed via value_from_data_fn for key=%s", key)
|
||||||
"native_value compute failed via value_from_data_fn for key=%s", key
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
@ -257,9 +248,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
try:
|
try:
|
||||||
value = description.value_fn(raw)
|
value = description.value_fn(raw)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_LOGGER.exception(
|
_LOGGER.exception("native_value compute failed via value_fn for key=%s raw=%s", key, raw)
|
||||||
"native_value compute failed via value_fn for key=%s raw=%s", key, raw
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue