From 15e9df00cab5d3f553abbff7ead8e659c44626b8 Mon Sep 17 00:00:00 2001 From: schizza Date: Sat, 11 Apr 2026 16:37:17 +0200 Subject: [PATCH] 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. --- custom_components/sws12500/__init__.py | 117 ++++++- custom_components/sws12500/battery_sensors.py | 10 +- .../sws12500/battery_sensors_def.py | 35 ++- custom_components/sws12500/binary_sensor.py | 104 ++++++ custom_components/sws12500/const.py | 1 + custom_components/sws12500/ecowitt.py | 297 ++++++++++++++++++ .../sws12500/health_coordinator.py | 3 + custom_components/sws12500/manifest.json | 2 +- custom_components/sws12500/routes.py | 56 +++- custom_components/sws12500/sensor.py | 37 +-- tools/.cargo/config.toml | 8 + tools/test-station-server | 1 + 12 files changed, 629 insertions(+), 42 deletions(-) create mode 100644 custom_components/sws12500/binary_sensor.py create mode 100644 custom_components/sws12500/ecowitt.py create mode 100644 tools/.cargo/config.toml create mode 160000 tools/test-station-server diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index 1e1c487..1df9535 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -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/ + + _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()) diff --git a/custom_components/sws12500/battery_sensors.py b/custom_components/sws12500/battery_sensors.py index ac8e9f0..0e4048d 100644 --- a/custom_components/sws12500/battery_sensors.py +++ b/custom_components/sws12500/battery_sensors.py @@ -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 diff --git a/custom_components/sws12500/battery_sensors_def.py b/custom_components/sws12500/battery_sensors_def.py index 7292858..9514087 100644 --- a/custom_components/sws12500/battery_sensors_def.py +++ b/custom_components/sws12500/battery_sensors_def.py @@ -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, + ), ) diff --git a/custom_components/sws12500/binary_sensor.py b/custom_components/sws12500/binary_sensor.py new file mode 100644 index 0000000..6e31052 --- /dev/null +++ b/custom_components/sws12500/binary_sensor.py @@ -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) diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index ebf8706..f597c20 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -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] = { diff --git a/custom_components/sws12500/ecowitt.py b/custom_components/sws12500/ecowitt.py new file mode 100644 index 0000000..5448b60 --- /dev/null +++ b/custom_components/sws12500/ecowitt.py @@ -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() diff --git a/custom_components/sws12500/health_coordinator.py b/custom_components/sws12500/health_coordinator.py index 4e8f14c..9aeee48 100644 --- a/custom_components/sws12500/health_coordinator.py +++ b/custom_components/sws12500/health_coordinator.py @@ -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" diff --git a/custom_components/sws12500/manifest.json b/custom_components/sws12500/manifest.json index c858579..87d1e9e 100644 --- a/custom_components/sws12500/manifest.json +++ b/custom_components/sws12500/manifest.json @@ -17,6 +17,6 @@ "aioecowitt==2025.9.2" ], "ssdp": [], - "version": "2.0.0-pre0", + "version": "2.0.0-pre1", "zeroconf": [] } diff --git a/custom_components/sws12500/routes.py b/custom_components/sws12500/routes.py index 0d3d838..7d452d9 100644 --- a/custom_components/sws12500/routes.py +++ b/custom_components/sws12500/routes.py @@ -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.""" diff --git a/custom_components/sws12500/sensor.py b/custom_components/sws12500/sensor.py index 3c592f2..5ade087 100644 --- a/custom_components/sws12500/sensor.py +++ b/custom_components/sws12500/sensor.py @@ -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 diff --git a/tools/.cargo/config.toml b/tools/.cargo/config.toml new file mode 100644 index 0000000..bb1af67 --- /dev/null +++ b/tools/.cargo/config.toml @@ -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" diff --git a/tools/test-station-server b/tools/test-station-server new file mode 160000 index 0000000..81539fc --- /dev/null +++ b/tools/test-station-server @@ -0,0 +1 @@ +Subproject commit 81539fc79d8c3af267241ad6fd63b1924583354f