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