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
schizza 2026-04-11 16:37:17 +02:00
parent 50fe9e35fe
commit 15e9df00ca
No known key found for this signature in database
12 changed files with 629 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,6 @@
"aioecowitt==2025.9.2"
],
"ssdp": [],
"version": "2.0.0-pre0",
"version": "2.0.0-pre1",
"zeroconf": []
}

View File

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

View File

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

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

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

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