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_KEY,
|
||||
DEFAULT_URL,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
POCASI_CZ_ENABLED,
|
||||
SENSORS_TO_LOAD,
|
||||
|
|
@ -52,6 +55,7 @@ from .const import (
|
|||
WSLINK_URL,
|
||||
)
|
||||
from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS
|
||||
from .ecowitt import EcowittBridge # noqa: PLC0415
|
||||
from .health_coordinator import HealthCoordinator
|
||||
from .pocasti_cz import PocasiPush
|
||||
from .routes import Routes
|
||||
|
|
@ -68,7 +72,7 @@ from .utils import (
|
|||
from .windy_func import WindyPush
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
class IncorrectDataError(InvalidStateError):
|
||||
|
|
@ -102,6 +106,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
self.config: ConfigEntry = config
|
||||
self.windy: WindyPush = WindyPush(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)
|
||||
|
||||
def _health_coordinator(self) -> HealthCoordinator | None:
|
||||
|
|
@ -114,6 +123,93 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
coordinator = entry.get(ENTRY_HEALTH_COORD)
|
||||
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:
|
||||
"""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
|
||||
# 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)
|
||||
|
||||
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.
|
||||
self.async_set_updated_data(remaped_items)
|
||||
|
|
@ -322,6 +420,7 @@ def register_path(
|
|||
raise ConfigEntryNotReady
|
||||
|
||||
_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
|
||||
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")
|
||||
_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
|
||||
hass_data["routes"] = routes
|
||||
|
||||
|
|
@ -356,6 +461,13 @@ def register_path(
|
|||
enabled=True,
|
||||
sticky=True,
|
||||
)
|
||||
routes.add_route(
|
||||
_ecowitt_path,
|
||||
_ecowitt_route,
|
||||
coordinator.recieved_ecowitt_data,
|
||||
enabled=_ecowitt_enabled,
|
||||
sticky=True,
|
||||
)
|
||||
else:
|
||||
routes.set_ingress_observer(coordinator_h.record_dispatch)
|
||||
_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)
|
||||
|
||||
_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")
|
||||
|
||||
if routes:
|
||||
_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.set_ecowitt_enabled(_ecowitt_path, coordinator.recieved_ecowitt_data, _ecowitt_enabled)
|
||||
routes.set_ingress_observer(coordinator_health.record_dispatch)
|
||||
coordinator_health.update_routing(routes)
|
||||
_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 functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from py_typecheck import checked_or
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
CoordinatorEntity, BinarySensorEntity
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
"""Battery sensors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
|
||||
|
||||
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
||||
BinarySensorEntityDescription(
|
||||
|
|
@ -21,4 +18,34 @@ BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
|
|||
translation_key="ch2_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_ENABLED: Final = "ecowitt_enabled"
|
||||
ECOWITT_URL: Final = "/weather/ecowitt"
|
||||
ECOWITT_URL_PREFIX: Final = "/weatherhub"
|
||||
ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"}
|
||||
|
||||
REMAP_ECOWITT_COMPAT: dict[str, str] = {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_URL_PREFIX,
|
||||
HEALTH_URL,
|
||||
POCASI_CZ_ENABLED,
|
||||
WINDY_ENABLED,
|
||||
|
|
@ -64,6 +65,8 @@ def _protocol_from_path(path: str) -> str:
|
|||
return "wu"
|
||||
if path == HEALTH_URL:
|
||||
return "health"
|
||||
if path.startswith(ECOWITT_URL_PREFIX + "/"):
|
||||
return "ecowitt"
|
||||
return "unknown"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@
|
|||
"aioecowitt==2025.9.2"
|
||||
],
|
||||
"ssdp": [],
|
||||
"version": "2.0.0-pre0",
|
||||
"version": "2.0.0-pre1",
|
||||
"zeroconf": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,18 +62,56 @@ class Routes:
|
|||
self.routes: dict[str, RouteInfo] = {}
|
||||
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:
|
||||
"""Set a callback notified for every incoming dispatcher request."""
|
||||
self._ingress_observer = observer
|
||||
|
||||
async def dispatch(self, request: Request) -> Response:
|
||||
"""Dispatch incoming request to either the enabled handler or a fallback."""
|
||||
key = f"{request.method}:{request.path}"
|
||||
info = self.routes.get(key)
|
||||
|
||||
info = self._resolve_route(request)
|
||||
|
||||
if not info:
|
||||
_LOGGER.debug(
|
||||
"Route (%s):%s is not registered!", request.method, request.path
|
||||
)
|
||||
_LOGGER.debug("Route (%s):%s is not registered!", request.method, request.path)
|
||||
if self._ingress_observer is not None:
|
||||
self._ingress_observer(request, False, "route_not_registered")
|
||||
return await unregistered(request)
|
||||
|
|
@ -125,9 +163,7 @@ class Routes:
|
|||
`dispatch` uses after aiohttp has routed the request by path.
|
||||
"""
|
||||
key = f"{route.method}:{url_path}"
|
||||
self.routes[key] = RouteInfo(
|
||||
url_path, route=route, handler=handler, enabled=enabled, sticky=sticky
|
||||
)
|
||||
self.routes[key] = RouteInfo(url_path, route=route, handler=handler, enabled=enabled, sticky=sticky)
|
||||
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
|
||||
|
||||
def show_enabled(self) -> str:
|
||||
|
|
@ -144,9 +180,7 @@ class Routes:
|
|||
|
||||
def path_enabled(self, url_path: str) -> bool:
|
||||
"""Return whether any route registered for `url_path` is enabled."""
|
||||
return any(
|
||||
route.enabled for route in self.routes.values() if route.url_path == url_path
|
||||
)
|
||||
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return a compact routing snapshot for diagnostics."""
|
||||
|
|
|
|||
|
|
@ -98,9 +98,7 @@ async def async_setup_entry(
|
|||
|
||||
# we have to check if entry_data are present
|
||||
# It is created by integration setup, so it should be presnet
|
||||
if (
|
||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
||||
) is None:
|
||||
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||
# This should not happen in normal operation.
|
||||
return
|
||||
|
||||
|
|
@ -125,25 +123,24 @@ async def async_setup_entry(
|
|||
# look up its description here and instantiate the matching entity.
|
||||
entry_data[ENTRY_DESCRIPTIONS] = {desc.key: desc for desc in sensor_types}
|
||||
|
||||
sensors_to_load = checked_or(
|
||||
config_entry.options.get(SENSORS_TO_LOAD), list[str], []
|
||||
)
|
||||
sensors_to_load = checked_or(config_entry.options.get(SENSORS_TO_LOAD), list[str], [])
|
||||
if not sensors_to_load:
|
||||
return
|
||||
|
||||
requested = _auto_enable_derived_sensors(set(sensors_to_load))
|
||||
|
||||
entities: list[WeatherSensor] = [
|
||||
WeatherSensor(description, coordinator)
|
||||
for description in sensor_types
|
||||
if description.key in requested
|
||||
WeatherSensor(description, coordinator) for description in sensor_types if description.key in requested
|
||||
]
|
||||
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]
|
||||
) -> None:
|
||||
|
||||
def add_new_sensors(hass: HomeAssistant, config_entry: ConfigEntry, keys: list[str]) -> None:
|
||||
"""Dynamically add newly discovered sensors without reloading the entry.
|
||||
|
||||
Called by the webhook handler when the station starts sending new fields.
|
||||
|
|
@ -157,9 +154,7 @@ def add_new_sensors(
|
|||
if (hass_data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
if (
|
||||
entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])
|
||||
) is None:
|
||||
if (entry_data := checked(hass_data.get(config_entry.entry_id), dict[str, Any])) is None:
|
||||
return
|
||||
|
||||
add_entities = entry_data.get(ENTRY_ADD_ENTITIES)
|
||||
|
|
@ -208,9 +203,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
|
||||
config_entry = getattr(self.coordinator, "config", None)
|
||||
self._dev_log = checked_or(
|
||||
config_entry.options.get("dev_debug_checkbox")
|
||||
if config_entry is not None
|
||||
else False,
|
||||
config_entry.options.get("dev_debug_checkbox") if config_entry is not None else False,
|
||||
bool,
|
||||
False,
|
||||
)
|
||||
|
|
@ -236,9 +229,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
try:
|
||||
value = description.value_from_data_fn(data)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"native_value compute failed via value_from_data_fn for key=%s", key
|
||||
)
|
||||
_LOGGER.exception("native_value compute failed via value_from_data_fn for key=%s", key)
|
||||
return None
|
||||
|
||||
return value
|
||||
|
|
@ -257,9 +248,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
try:
|
||||
value = description.value_fn(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"native_value compute failed via value_fn for key=%s raw=%s", key, raw
|
||||
)
|
||||
_LOGGER.exception("native_value compute failed via value_fn for key=%s raw=%s", key, raw)
|
||||
return None
|
||||
|
||||
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