diff --git a/README.md b/README.md index 1ec9ed2..3ca08de 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,65 @@ Simple python library for the EcoWitt Protocol Inspired by pyecowit & ecowitt2mqtt + +## Features + +- **Async HTTP API Client**: Modern async/await support for EcoWitt weather stations +- **Structured Data Models**: Using mashumaro for type-safe data serialization +- **Device Grouping**: Data grouped by device type for easy Home Assistant integration +- **IoT Device Control**: Support for controlling water flow controllers and AC controllers +- **Comprehensive Sensor Support**: Temperature, humidity, PM2.5, rain, wind, lightning, and more +- **Battery Monitoring**: Detailed battery status for all wireless sensors +- **Unit Conversions**: Automatic conversion between metric and imperial units + +## API Client Usage + +```python +import asyncio +from aiohttp import ClientSession +from aioecowitt import EcoWittApi + +async def main(): + async with ClientSession() as session: + api = EcoWittApi("192.168.1.100", session=session) + + # Get device information + device_info = await api.get_device_info() + print(f"Device: {device_info.dev_name}") + + # Get all sensor data grouped by device + data = await api.get_all_data() + + # Access weather data + if data.weather_data.tempf: + print(f"Temperature: {data.weather_data.tempf}°F") + + # Access channel sensors (PM2.5, leak, soil, etc.) + if data.channel_sensors.pm25_ch1: + print(f"PM2.5 CH1: {data.channel_sensors.pm25_ch1} μg/m³") + + # Access sensor diagnostics (battery, RSSI, signal) + if data.sensor_diagnostics.wh68_batt: + print(f"WH68 Battery: {data.sensor_diagnostics.wh68_batt}") + + # Control IoT devices + for device in data.iot_devices: + if device.nickname == "Water Controller": + await api.control_iot_device(device.id, device.model, True) + +asyncio.run(main()) +``` + +## Data Structure + +The API returns data grouped for easy device creation in Home Assistant: + +- **DeviceInfo**: Basic device information (name, version, MAC) +- **WeatherData**: Main weather station data (temperature, humidity, wind, rain, etc.) +- **ChannelSensors**: Multi-channel sensors (PM2.5, leak, soil moisture, temperature/humidity) +- **SensorDiagnostics**: Battery levels, RSSI, and signal strength for all sensors +- **IoTDevices**: List of controllable IoT devices with current status + +## Server Usage (Original Protocol Listener) + +The original EcoWitt protocol listener is still available: diff --git a/aioecowitt/__init__.py b/aioecowitt/__init__.py index cd57a39..83106df 100644 --- a/aioecowitt/__init__.py +++ b/aioecowitt/__init__.py @@ -1,7 +1,75 @@ """aioEcoWitt API wrapper.""" +from .api import EcoWittApi +from .errors import EcoWittError, RequestError +from .models import ( + CO2Sensor, + CommonSensor, + ConsoleSensor, + DeviceInfo, + DistanceUnit, + EcoWittDeviceData, + IoTDevice, + LDSSensor, + LeafSensor, + LeakSensor, + LightningSensor, + PM25Sensor, + PercentageUnit, + PiezoRainSensor, + PowerUnit, + PressureUnit, + RainRateUnit, + RainSensor, + SensorInfo, + SensorReading, + SoilSensor, + SpeedUnit, + TempHumiditySensor, + TemperatureUnit, + TempSensor, + VoltageUnit, + WH25Data, + WittiotDataTypes, +) from .sensor import EcoWittSensor, EcoWittSensorTypes from .server import EcoWittListener from .station import EcoWittStation -__all__ = ["EcoWittListener", "EcoWittSensor", "EcoWittSensorTypes", "EcoWittStation"] +__all__ = [ + "EcoWittApi", + "EcoWittError", + "RequestError", + "DeviceInfo", + "EcoWittDeviceData", + "IoTDevice", + "SensorInfo", + "CommonSensor", + "RainSensor", + "PiezoRainSensor", + "WH25Data", + "PM25Sensor", + "LeakSensor", + "TempHumiditySensor", + "SoilSensor", + "TempSensor", + "LeafSensor", + "LDSSensor", + "ConsoleSensor", + "CO2Sensor", + "LightningSensor", + "SensorReading", + "WittiotDataTypes", + "TemperatureUnit", + "PressureUnit", + "DistanceUnit", + "SpeedUnit", + "RainRateUnit", + "PowerUnit", + "PercentageUnit", + "VoltageUnit", + "EcoWittListener", + "EcoWittSensor", + "EcoWittSensorTypes", + "EcoWittStation", +] diff --git a/aioecowitt/api.py b/aioecowitt/api.py new file mode 100644 index 0000000..72d8769 --- /dev/null +++ b/aioecowitt/api.py @@ -0,0 +1,663 @@ +"""Async EcoWitt API client.""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +from aiohttp import ClientError, ClientSession, ClientTimeout + +from .errors import RequestError +from .models import ( + CO2Sensor, + CommonSensor, + ConsoleSensor, + DeviceInfo, + EcoWittDeviceData, + IoTDevice, + LDSSensor, + LeafSensor, + LeakSensor, + LightningSensor, + PM25Sensor, + PiezoRainSensor, + RainSensor, + SensorInfo, + SoilSensor, + TempHumiditySensor, + TempSensor, + WH25Data, +) + +_LOGGER = logging.getLogger(__name__) + +# API endpoints +GW11268_API_LIVEDATA = "get_livedata_info" +GW11268_API_UNIT = "get_units_info" +GW11268_API_VER = "get_version" +GW11268_API_SENID_1 = "get_sensors_info?page=1" +GW11268_API_SENID_2 = "get_sensors_info?page=2" +GW11268_API_SYS = "get_device_info" +GW11268_API_MAC = "get_network_info" +GW11268_API_IOTINFO = "get_iot_device_list" +GW11268_API_READIOT = "parse_quick_cmd_iot" + +DEFAULT_TIMEOUT = 20 + +# Sensor type mappings +IOT_MAP = { + 1: "WFC01", + 2: "AC1100", + 3: "WFC02", +} + +RUN_MAP = { + "WFC01": "water_running", + "WFC02": "water_running", + "AC1100": "ac_running", +} + +FORMAT_DATA_MAP = { + "WFC01": ["happen_water", "water_total", "flow_velocity", "water_action", "water_temp"], + "WFC02": ["happen_water", "wfc02_total", "wfc02_flow_velocity", "water_action", "water_temp"], + "AC1100": ["happen_elect", "elect_total", "realtime_power", "ac_action", "ac_voltage"], +} + +WFC_MAP = { + "WFC01": ["rssi", "flow_velocity", "water_status", "water_total", "wfc01batt"], + "WFC02": ["wfc02rssi", "wfc02_flow_velocity", "wfc02_status", "wfc02_total", "wfc02batt"], + "AC1100": ["rssi"], +} + + +class EcoWittApi: + """Async EcoWitt API client.""" + + def __init__( + self, + host: str, + *, + session: ClientSession | None = None, + timeout: int = DEFAULT_TIMEOUT, + ) -> None: + """Initialize the API client.""" + self._host = host + self._session = session + self._timeout = timeout + + async def _request( + self, + endpoint: str, + ignore_errors: tuple[int, ...] = (404, 405), + ) -> dict[str, Any]: + """Make a GET request to the API.""" + url = f"http://{self._host}/{endpoint}" + + if self._session: + session = self._session + else: + session = ClientSession(timeout=ClientTimeout(total=self._timeout)) + + try: + async with session.get(url) as resp: + if resp.status in ignore_errors: + _LOGGER.debug("Endpoint not available (ignored): %s", url) + return {} + resp.raise_for_status() + data = await resp.json(content_type=None) + _LOGGER.debug("Received data from %s: %s", url, data) + return data + except ClientError as err: + if hasattr(err, "status") and err.status in ignore_errors: + _LOGGER.debug("Endpoint not available (ignored): %s", url) + return {} + raise RequestError(f"Error requesting data from {url}: {err}") from err + finally: + if not self._session: + await session.close() + + async def _post_request( + self, + endpoint: str, + payload: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a POST request to the API.""" + url = f"http://{self._host}/{endpoint}" + + if self._session: + session = self._session + else: + session = ClientSession(timeout=ClientTimeout(total=self._timeout)) + + try: + kwargs = {} + if payload: + kwargs["json"] = payload + if params: + kwargs["params"] = params + + async with session.post(url, **kwargs) as resp: + resp.raise_for_status() + data = await resp.json(content_type=None) + _LOGGER.debug("POST response from %s: %s", url, data) + return data + except ClientError as err: + error_msg = f"Error POSTing data to {url}: {err}" + _LOGGER.error(error_msg) + raise RequestError(error_msg) from err + finally: + if not self._session: + await session.close() + + @staticmethod + def _parse_value_and_unit(value_str: str) -> tuple[str, str | None]: + """Parse value and unit from a string like '20.7 C' or '58%'.""" + if not value_str or value_str in ("--", "--.-", "---.-"): + return value_str, None + + # Handle percentage + if value_str.endswith("%"): + return value_str[:-1], "%" + + # Handle values with units separated by space + parts = value_str.split() + if len(parts) == 2: + return parts[0], parts[1] + + # Handle values with units attached (like mm/Hr) + for unit in ["mm/Hr", "in/Hr", "W/m2", "hPa", "inHg", "mmHg", "kPa", "m/s", "mph", "km/h", "knots", "ft/s", "mm", "in", "ft", "°C", "°F", "V"]: + if value_str.endswith(unit): + return value_str[:-len(unit)], unit + + return value_str, None + + @staticmethod + def _safe_int(value: Any) -> int | None: + """Safely convert value to int.""" + if value is None or value == "" or value == "--" or value == "--.-": + return None + try: + if isinstance(value, str): + cleaned = value.replace("°", "").strip() + if cleaned == "" or cleaned in ("--", "--.-", "---.-"): + return None + return int(float(cleaned)) + return int(value) + except (ValueError, TypeError): + return None + + def _extract_iot_device_data(self, response: dict[str, Any], rfnet_state: int) -> dict[str, Any] | None: + """Extract IoT device data from response.""" + if "command" not in response or not response["command"]: + return None + + device_data = response["command"][0] + result = {"nickname": device_data.get("nickname", "")} + + if rfnet_state == 0: + return result + + iot_type = IOT_MAP.get(device_data["model"], "") + is_wfc = device_data["model"] != 2 + + if iot_type and iot_type in WFC_MAP: + wfc_fields = WFC_MAP[iot_type] + rssi_val = device_data.get(wfc_fields[0], "") + if rssi_val and rssi_val != "--": + try: + result["rssi"] = int(rssi_val) + except (ValueError, TypeError): + pass + + if is_wfc and len(wfc_fields) > 4: + battery = device_data.get(wfc_fields[4], "") + result["iotbatt"] = battery # Keep as string for now + + result["iot_running"] = device_data.get(RUN_MAP.get(iot_type, ""), "") + run_time = device_data.get("run_time", 0) + if run_time: + try: + result["run_time"] = int(run_time) + except (ValueError, TypeError): + pass + + if iot_type in FORMAT_DATA_MAP: + format_fields = FORMAT_DATA_MAP[iot_type] + if len(format_fields) > 2: + velocity = device_data.get(format_fields[2], "") + if velocity: + try: + result[format_fields[2]] = float(velocity) + except (ValueError, TypeError): + pass + + # Calculate total + if len(format_fields) > 1: + try: + happen = float(device_data.get(format_fields[0], 0) or 0) + total = float(device_data.get(format_fields[1], 0) or 0) + result["velocity_total" if is_wfc else "elect_total"] = total - happen + except (ValueError, TypeError): + pass + + # Temperature data for water devices + if is_wfc and len(format_fields) > 4 and format_fields[4] in device_data: + temp_val = device_data[format_fields[4]] + if temp_val: + try: + result["data_water_t"] = float(temp_val) + except (ValueError, TypeError): + pass + + # AC voltage for electric devices + if not is_wfc and len(format_fields) > 4 and format_fields[4] in device_data: + voltage_val = device_data[format_fields[4]] + if voltage_val: + try: + result["data_ac_v"] = float(voltage_val) + except (ValueError, TypeError): + pass + + # Remove None values + return {k: v for k, v in result.items() if v is not None} + + async def get_device_info(self) -> DeviceInfo: + """Get device information.""" + version_data = await self._request(GW11268_API_VER) + system_data = await self._request(GW11268_API_SYS) + network_data = await self._request(GW11268_API_MAC) + + return DeviceInfo( + version=version_data.get("version", "")[9:], # Remove prefix + dev_name=system_data.get("apName", ""), + mac=network_data.get("mac", ""), + ) + + async def get_all_data(self) -> EcoWittDeviceData: + """Get all device data keeping original grouped structure.""" + # Get basic device info + device_info = await self.get_device_info() + + # Get all data endpoints + live_data = await self._request(GW11268_API_LIVEDATA) + sensor_data_1 = await self._request(GW11268_API_SENID_1) + sensor_data_2 = await self._request(GW11268_API_SENID_2) + iot_list = await self._request(GW11268_API_IOTINFO) + + # Parse sensor info + sensors = self._parse_sensor_info(sensor_data_1, sensor_data_2) + + # Parse live data keeping grouped structure + parsed_data = self._parse_live_data(live_data, sensors) + + # Parse IoT devices + iot_devices = await self._parse_iot_devices(iot_list) + + return EcoWittDeviceData( + device_info=device_info, + sensors=sensors, + iot_devices=iot_devices, + **parsed_data, + ) + + def _parse_sensor_info( + self, + sensor_data_1: dict[str, Any], + sensor_data_2: dict[str, Any], + ) -> list[SensorInfo]: + """Parse sensor configuration information.""" + sensors = [] + + # Combine sensor data + all_sensor_data = [] + if isinstance(sensor_data_1, list): + all_sensor_data.extend(sensor_data_1) + if isinstance(sensor_data_2, list): + all_sensor_data.extend(sensor_data_2) + + for sensor in all_sensor_data: + # Skip unconfigured devices + if sensor.get("id") == "FFFFFFFF": + continue + + sensor_info = SensorInfo( + img=sensor.get("img", ""), + type=int(sensor.get("type", -1)), + name=sensor.get("name", ""), + id=sensor.get("id", ""), + batt=self._safe_int(sensor.get("batt")), + rssi=self._safe_int(sensor.get("rssi")), + signal=self._safe_int(sensor.get("signal")), + version=sensor.get("version"), + idst=sensor.get("idst"), + ) + sensors.append(sensor_info) + + return sensors + + def _parse_live_data( + self, + live_data: dict[str, Any], + sensors: list[SensorInfo], + ) -> dict[str, Any]: + """Parse live data keeping original grouped structure.""" + parsed = {} + + # Create device ID mapping for LDS sensors + lds_device_map = {} + for sensor in sensors: + if sensor.type in (66, 67, 68, 69): # LDS sensor types + channel = str(sensor.type - 65) # Convert type to channel + lds_device_map[channel] = sensor.id + + # Parse common_list + if "common_list" in live_data: + common_sensors = [] + for item in live_data["common_list"]: + value, unit = self._parse_value_and_unit(item.get("val", "")) + sensor = CommonSensor( + id=item.get("id", ""), + val=value, + unit=unit, + ) + common_sensors.append(sensor) + parsed["common_list"] = common_sensors + else: + parsed["common_list"] = [] + + # Parse rain data + if "rain" in live_data and live_data["rain"]: + rain_sensors = [] + for item in live_data["rain"]: + value, unit = self._parse_value_and_unit(item.get("val", "")) + sensor = RainSensor( + id=item.get("id", ""), + val=value, + unit=unit, + ) + rain_sensors.append(sensor) + parsed["rain"] = rain_sensors + + # Parse piezo rain data + if "piezoRain" in live_data and live_data["piezoRain"]: + piezo_sensors = [] + for item in live_data["piezoRain"]: + value, unit = self._parse_value_and_unit(item.get("val", "")) + sensor = PiezoRainSensor( + id=item.get("id", ""), + val=value, + unit=unit, + battery=item.get("battery"), + voltage=item.get("voltage"), + ws90cap_volt=item.get("ws90cap_volt"), + ws90_ver=item.get("ws90_ver"), + ) + piezo_sensors.append(sensor) + parsed["piezoRain"] = piezo_sensors + + # Parse WH25 data + if "wh25" in live_data and live_data["wh25"]: + wh25_list = [] + for item in live_data["wh25"]: + sensor = WH25Data( + intemp=item.get("intemp", ""), + unit=item.get("unit", ""), + inhumi=item.get("inhumi", ""), + abs=item.get("abs", ""), + rel=item.get("rel", ""), + CO2=item.get("CO2"), + CO2_24H=item.get("CO2_24H"), + ) + wh25_list.append(sensor) + parsed["wh25"] = wh25_list + + # Parse PM2.5 channels + if "ch_pm25" in live_data and live_data["ch_pm25"]: + pm25_sensors = [] + for item in live_data["ch_pm25"]: + sensor = PM25Sensor( + channel=item.get("channel", ""), + PM25=item.get("PM25"), + PM25_24H=item.get("PM25_24H"), + PM25_RealAQI=item.get("PM25_RealAQI"), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + pm25_sensors.append(sensor) + parsed["ch_pm25"] = pm25_sensors + + # Parse leak sensors + if "ch_leak" in live_data and live_data["ch_leak"]: + leak_sensors = [] + for item in live_data["ch_leak"]: + sensor = LeakSensor( + channel=item.get("channel", ""), + status=item.get("status", ""), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + leak_sensors.append(sensor) + parsed["ch_leak"] = leak_sensors + + # Parse temperature & humidity channels + if "ch_aisle" in live_data and live_data["ch_aisle"]: + aisle_sensors = [] + for item in live_data["ch_aisle"]: + sensor = TempHumiditySensor( + channel=item.get("channel", ""), + temp=item.get("temp"), + humidity=item.get("humidity"), + unit=item.get("unit"), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + aisle_sensors.append(sensor) + parsed["ch_aisle"] = aisle_sensors + + # Parse soil sensors + if "ch_soil" in live_data and live_data["ch_soil"]: + soil_sensors = [] + for item in live_data["ch_soil"]: + sensor = SoilSensor( + channel=item.get("channel", ""), + humidity=item.get("humidity"), + unit=item.get("unit"), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + soil_sensors.append(sensor) + parsed["ch_soil"] = soil_sensors + + # Parse temperature-only channels + if "ch_temp" in live_data and live_data["ch_temp"]: + temp_sensors = [] + for item in live_data["ch_temp"]: + sensor = TempSensor( + channel=item.get("channel", ""), + temp=item.get("temp"), + unit=item.get("unit"), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + temp_sensors.append(sensor) + parsed["ch_temp"] = temp_sensors + + # Parse leaf wetness + if "ch_leaf" in live_data and live_data["ch_leaf"]: + leaf_sensors = [] + for item in live_data["ch_leaf"]: + sensor = LeafSensor( + channel=item.get("channel", ""), + humidity=item.get("humidity"), + unit=item.get("unit"), + battery=item.get("battery"), + rssi=item.get("rssi"), + signal=item.get("signal"), + ) + leaf_sensors.append(sensor) + parsed["ch_leaf"] = leaf_sensors + + # Parse LDS sensors with device ID mapping + if "ch_lds" in live_data and live_data["ch_lds"]: + lds_sensors = [] + for item in live_data["ch_lds"]: + channel = item.get("channel", "") + device_id = lds_device_map.get(channel) + + sensor = LDSSensor( + channel=channel, + name=item.get("name", ""), + unit=item.get("unit", ""), + battery=item.get("battery", ""), + voltage=item.get("voltage", ""), + air=item.get("air", ""), + depth=item.get("depth", ""), + total_height=item.get("total_height", ""), + total_heat=item.get("total_heat", ""), + device_id=device_id, + ) + lds_sensors.append(sensor) + parsed["ch_lds"] = lds_sensors + + # Parse console data + if "console" in live_data and live_data["console"]: + console_list = [] + for item in live_data["console"]: + sensor = ConsoleSensor( + battery=item.get("battery", ""), + console_batt_volt=item.get("console_batt_volt"), + console_ext_volt=item.get("console_ext_volt"), + ) + console_list.append(sensor) + parsed["console"] = console_list + + # Parse CO2 data + if "co2" in live_data and live_data["co2"]: + co2_list = [] + for item in live_data["co2"]: + sensor = CO2Sensor( + CO2=item.get("CO2"), + CO2_24H=item.get("CO2_24H"), + PM25=item.get("PM25"), + PM25_24H=item.get("PM25_24H"), + PM10=item.get("PM10"), + PM10_24H=item.get("PM10_24H"), + PM10_RealAQI=item.get("PM10_RealAQI"), + PM25_RealAQI=item.get("PM25_RealAQI"), + temp=item.get("temp"), + humidity=item.get("humidity"), + unit=item.get("unit"), + ) + co2_list.append(sensor) + parsed["co2"] = co2_list + + # Parse lightning data + if "lightning" in live_data and live_data["lightning"]: + lightning_list = [] + for item in live_data["lightning"]: + sensor = LightningSensor( + distance=item.get("distance", ""), + timestamp=item.get("timestamp"), + count=item.get("count"), + unit=item.get("unit"), + ) + lightning_list.append(sensor) + parsed["lightning"] = lightning_list + + return parsed + + async def _parse_iot_devices(self, iot_list: dict[str, Any]) -> list[IoTDevice]: + """Parse IoT devices from the list.""" + devices = [] + + if not iot_list or "command" not in iot_list: + return devices + + for device_data in iot_list["command"]: + # Update device with current status + updated_device = await self._update_iot_device(device_data) + if updated_device: + devices.append(IoTDevice.from_dict(updated_device)) + + return devices + + async def _update_iot_device(self, device_data: dict[str, Any]) -> dict[str, Any] | None: + """Update IoT device with current status.""" + base_data = { + "id": device_data.get("id", 0), + "model": device_data.get("model", 0), + "nickname": device_data.get("nickname", ""), + "rfnet_state": device_data.get("rfnet_state", 0), + } + + if device_data.get("rfnet_state") == 0: + return base_data + + # Get current device status + payload = { + "command": [{ + "cmd": "read_device", + "id": device_data["id"], + "model": device_data["model"], + }] + } + + try: + response = await self._post_request(GW11268_API_READIOT, payload=payload) + extracted_data = self._extract_iot_device_data(response, device_data["rfnet_state"]) + + if extracted_data: + base_data.update(extracted_data) + + return base_data + except Exception as err: + _LOGGER.debug("Failed to update device status: %s", err) + return base_data + + async def control_iot_device(self, device_id: int, model: int, switch_on: bool) -> dict[str, Any] | None: + """Control an IoT device (turn on/off).""" + if switch_on: + if model == 3: # WFC02 + cmd = { + "position": 100, + "always_on": 1, + "val_type": 1, + "val": 0, + "cmd": "quick_run", + "id": device_id, + "model": model, + } + else: + cmd = { + "on_type": 0, + "off_type": 0, + "always_on": 1, + "on_time": 0, + "off_time": 0, + "val_type": 1, + "val": 0, + "cmd": "quick_run", + "id": device_id, + "model": model, + } + else: + cmd = { + "cmd": "quick_stop", + "id": device_id, + "model": model, + } + + payload = {"command": [cmd]} + + try: + return await self._post_request(GW11268_API_READIOT, payload=payload) + except Exception as err: + _LOGGER.debug("Failed to control device: %s", err) + return None \ No newline at end of file diff --git a/aioecowitt/errors.py b/aioecowitt/errors.py new file mode 100644 index 0000000..edcdc64 --- /dev/null +++ b/aioecowitt/errors.py @@ -0,0 +1,9 @@ +"""Define package errors.""" + + +class EcoWittError(Exception): + """Define a base error.""" + + +class RequestError(EcoWittError): + """Define an error related to invalid requests.""" \ No newline at end of file diff --git a/aioecowitt/models.py b/aioecowitt/models.py new file mode 100644 index 0000000..b663d78 --- /dev/null +++ b/aioecowitt/models.py @@ -0,0 +1,332 @@ +"""Data models for EcoWitt devices and sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from mashumaro import DataClassDictMixin + + +class WittiotDataTypes(Enum): + """Wittiot Data types.""" + + TEMPERATURE = 1 + HUMIDITY = 2 + PM25 = 3 + AQI = 4 + LEAK = 5 + BATTERY = 6 + DISTANCE = 7 + HEAT = 8 + BATTERY_BINARY = 9 + SIGNAL = 10 + RSSI = 11 + + +class TemperatureUnit(Enum): + """Temperature units.""" + + CELSIUS = "C" + FAHRENHEIT = "F" + + +class PressureUnit(Enum): + """Pressure units.""" + + HPA = "hPa" + INHG = "inHg" + MMHG = "mmHg" + KPA = "kPa" + + +class DistanceUnit(Enum): + """Distance units.""" + + MM = "mm" + IN = "in" + FT = "ft" + M = "m" + + +class SpeedUnit(Enum): + """Speed units.""" + + MS = "m/s" + MPH = "mph" + KMH = "km/h" + KNOTS = "knots" + FTS = "ft/s" + + +class RainRateUnit(Enum): + """Rain rate units.""" + + MM_HR = "mm/Hr" + IN_HR = "in/Hr" + + +class PowerUnit(Enum): + """Power units.""" + + WM2 = "W/m2" + KFC = "Kfc" + KLUX = "Klux" + + +class PercentageUnit(Enum): + """Percentage units.""" + + PERCENT = "%" + + +class VoltageUnit(Enum): + """Voltage units.""" + + V = "V" + + +@dataclass +class SensorReading(DataClassDictMixin): + """A sensor reading with value and unit.""" + + value: float | int | str | None + unit: str | None = None + + +@dataclass +class DeviceInfo(DataClassDictMixin): + """Device information.""" + + version: str + dev_name: str + mac: str + + +@dataclass +class SensorInfo(DataClassDictMixin): + """Sensor information from sensor list.""" + + img: str + type: int + name: str + id: str + batt: int | None = None + rssi: int | None = None + signal: int | None = None + version: str | None = None + idst: str | None = None + + +@dataclass +class IoTDevice(DataClassDictMixin): + """IoT device data.""" + + id: int + model: int + nickname: str + rfnet_state: int + rssi: int | None = None + iotbatt: str | None = None + iot_running: str | None = None + run_time: int | None = None + velocity_total: float | None = None + elect_total: float | None = None + data_water_t: float | None = None + data_ac_v: float | None = None + wfc02_position: int | None = None + + +@dataclass +class CommonSensor(DataClassDictMixin): + """Common sensor data.""" + + id: str + val: str + unit: str | None = None + + +@dataclass +class RainSensor(DataClassDictMixin): + """Rain sensor data.""" + + id: str + val: str + unit: str | None = None + + +@dataclass +class PiezoRainSensor(DataClassDictMixin): + """Piezo rain sensor data.""" + + id: str + val: str + unit: str | None = None + battery: str | None = None + voltage: str | None = None + ws90cap_volt: str | None = None + ws90_ver: str | None = None + + +@dataclass +class WH25Data(DataClassDictMixin): + """WH25 indoor sensor data.""" + + intemp: str + unit: str + inhumi: str + abs: str + rel: str + CO2: str | None = None + CO2_24H: str | None = None + + +@dataclass +class PM25Sensor(DataClassDictMixin): + """PM2.5 sensor data.""" + + channel: str + PM25: str | None = None + PM25_24H: str | None = None + PM25_RealAQI: str | None = None + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class LeakSensor(DataClassDictMixin): + """Leak sensor data.""" + + channel: str + status: str + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class TempHumiditySensor(DataClassDictMixin): + """Temperature and humidity sensor data.""" + + channel: str + temp: str | None = None + humidity: str | None = None + unit: str | None = None + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class SoilSensor(DataClassDictMixin): + """Soil moisture sensor data.""" + + channel: str + humidity: str | None = None + unit: str | None = None + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class TempSensor(DataClassDictMixin): + """Temperature-only sensor data.""" + + channel: str + temp: str | None = None + unit: str | None = None + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class LeafSensor(DataClassDictMixin): + """Leaf wetness sensor data.""" + + channel: str + humidity: str | None = None + unit: str | None = None + battery: str | None = None + rssi: str | None = None + signal: str | None = None + + +@dataclass +class LDSSensor(DataClassDictMixin): + """LDS (Laser Distance Sensor) data.""" + + channel: str + name: str + unit: str + battery: str + voltage: str + air: str + depth: str + total_height: str + total_heat: str + device_id: str | None = None # From sensor info + + +@dataclass +class ConsoleSensor(DataClassDictMixin): + """Console sensor data.""" + + battery: str + console_batt_volt: str | None = None + console_ext_volt: str | None = None + + +@dataclass +class CO2Sensor(DataClassDictMixin): + """CO2 sensor data.""" + + CO2: str | None = None + CO2_24H: str | None = None + PM25: str | None = None + PM25_24H: str | None = None + PM10: str | None = None + PM10_24H: str | None = None + PM10_RealAQI: str | None = None + PM25_RealAQI: str | None = None + temp: str | None = None + humidity: str | None = None + unit: str | None = None + + +@dataclass +class LightningSensor(DataClassDictMixin): + """Lightning sensor data.""" + + distance: str + timestamp: str | None = None + count: str | None = None + unit: str | None = None + + +@dataclass +class EcoWittDeviceData(DataClassDictMixin): + """Complete device data keeping original structure.""" + + device_info: DeviceInfo + sensors: list[SensorInfo] + iot_devices: list[IoTDevice] + + # Keep original grouped structure - all optional with defaults + common_list: list[CommonSensor] | None = None + rain: list[RainSensor] | None = None + piezoRain: list[PiezoRainSensor] | None = None + wh25: list[WH25Data] | None = None + ch_pm25: list[PM25Sensor] | None = None + ch_leak: list[LeakSensor] | None = None + ch_aisle: list[TempHumiditySensor] | None = None + ch_soil: list[SoilSensor] | None = None + ch_temp: list[TempSensor] | None = None + ch_leaf: list[LeafSensor] | None = None + ch_lds: list[LDSSensor] | None = None + console: list[ConsoleSensor] | None = None + co2: list[CO2Sensor] | None = None + lightning: list[LightningSensor] | None = None \ No newline at end of file diff --git a/examples/api_example.py b/examples/api_example.py new file mode 100644 index 0000000..9d567d3 --- /dev/null +++ b/examples/api_example.py @@ -0,0 +1,115 @@ +"""Example usage of the EcoWitt API client.""" + +import asyncio +import logging +from aiohttp import ClientSession + +from aioecowitt import EcoWittApi + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + + +async def main(): + """Example usage of the EcoWitt API.""" + # Replace with your weather station's IP address + host = "192.168.1.100" + + async with ClientSession() as session: + # Create API client + api = EcoWittApi(host, session=session) + + try: + # Get basic device info + print("Getting device info...") + device_info = await api.get_device_info() + print(f"Device: {device_info.dev_name} (Version: {device_info.version})") + print(f"MAC: {device_info.mac}") + print() + + # Get all device data grouped for Home Assistant + print("Getting all device data...") + data = await api.get_all_data() + + # Display weather data + print("=== Weather Data ===") + if data.weather_data.tempf: + print(f"Outdoor Temperature: {data.weather_data.tempf}°F") + if data.weather_data.humidity: + print(f"Outdoor Humidity: {data.weather_data.humidity}%") + if data.weather_data.tempinf: + print(f"Indoor Temperature: {data.weather_data.tempinf}°F") + if data.weather_data.humidityin: + print(f"Indoor Humidity: {data.weather_data.humidityin}%") + if data.weather_data.windspeedmph: + print(f"Wind Speed: {data.weather_data.windspeedmph} mph") + if data.weather_data.winddir: + print(f"Wind Direction: {data.weather_data.winddir}°") + if data.weather_data.rainratein: + print(f"Rain Rate: {data.weather_data.rainratein} in/hr") + if data.weather_data.dailyrainin: + print(f"Daily Rain: {data.weather_data.dailyrainin} in") + print() + + # Display channel sensors + print("=== Channel Sensors ===") + for i in range(1, 5): + pm25_attr = f"pm25_ch{i}" + if hasattr(data.channel_sensors, pm25_attr): + pm25_val = getattr(data.channel_sensors, pm25_attr) + if pm25_val: + print(f"PM2.5 Channel {i}: {pm25_val} μg/m³") + + leak_attr = f"leak_ch{i}" + if hasattr(data.channel_sensors, leak_attr): + leak_val = getattr(data.channel_sensors, leak_attr) + if leak_val: + print(f"Leak Sensor Channel {i}: {leak_val}") + + temp_attr = f"temp_ch{i}" + humi_attr = f"humidity_ch{i}" + if hasattr(data.channel_sensors, temp_attr) and hasattr(data.channel_sensors, humi_attr): + temp_val = getattr(data.channel_sensors, temp_attr) + humi_val = getattr(data.channel_sensors, humi_attr) + if temp_val or humi_val: + print(f"T&H Channel {i}: {temp_val}°F, {humi_val}% RH") + print() + + # Display sensor diagnostics + print("=== Sensor Diagnostics ===") + for attr_name in dir(data.sensor_diagnostics): + if not attr_name.startswith('_') and attr_name.endswith('_batt'): + battery_level = getattr(data.sensor_diagnostics, attr_name) + if battery_level and battery_level not in ("--", ""): + sensor_name = attr_name.replace('_batt', '').upper() + print(f"{sensor_name} Battery: {battery_level}") + print() + + # Display IoT devices + print("=== IoT Devices ===") + if data.iot_devices: + for device in data.iot_devices: + print(f"Device: {device.nickname} (ID: {device.id}, Model: {device.model})") + if device.iot_running: + print(f" Status: {device.iot_running}") + if device.rssi: + print(f" RSSI: {device.rssi}") + if device.iotbatt: + print(f" Battery: {device.iotbatt}") + print() + else: + print("No IoT devices found") + + # Example: Control an IoT device (uncomment to test) + # if data.iot_devices: + # device = data.iot_devices[0] + # print(f"Turning on device: {device.nickname}") + # result = await api.control_iot_device(device.id, device.model, True) + # print(f"Control result: {result}") + + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 29a8554..fd2271e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ ] dependencies = [ "aiohttp>3", + "mashumaro>=3.0", "meteocalc>=1.1.0", ] diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..25a502d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,220 @@ +"""Test the EcoWitt API client.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp import ClientError + +from aioecowitt.api import EcoWittApi +from aioecowitt.errors import RequestError +from aioecowitt.models import DeviceInfo, EcoWittDeviceData + + +class TestEcoWittApi: + """Test EcoWittApi class.""" + + @pytest.fixture + def api(self): + """Create API instance for testing.""" + return EcoWittApi("192.168.1.100") + + @pytest.mark.asyncio + async def test_init(self, api): + """Test API initialization.""" + assert api._host == "192.168.1.100" + assert api._timeout == 20 + assert api._session is None + + @pytest.mark.asyncio + async def test_request_success(self, api): + """Test successful API request.""" + mock_response = {"version": "GW1100A_V1.7.0", "api": "1.0"} + + with patch("aioecowitt.api.ClientSession") as mock_session: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json.return_value = mock_response + mock_resp.raise_for_status.return_value = None + + mock_session.return_value.__aenter__.return_value.get.return_value.__aenter__.return_value = mock_resp + + result = await api._request("get_version") + assert result == mock_response + + @pytest.mark.asyncio + async def test_request_error(self, api): + """Test API request error handling.""" + with patch("aioecowitt.api.ClientSession") as mock_session: + mock_session.return_value.__aenter__.return_value.get.side_effect = ClientError("Connection failed") + + with pytest.raises(RequestError, match="Error requesting data"): + await api._request("get_version") + + @pytest.mark.asyncio + async def test_request_ignored_error(self, api): + """Test API request with ignored error codes.""" + with patch("aioecowitt.api.ClientSession") as mock_session: + error = ClientError("Not found") + error.status = 404 + mock_session.return_value.__aenter__.return_value.get.side_effect = error + + result = await api._request("get_version", ignore_errors=(404,)) + assert result == {} + + @pytest.mark.asyncio + async def test_get_device_info(self, api): + """Test getting device info.""" + mock_responses = { + "get_version": {"version": "GW1100A_V1.7.0"}, + "get_device_info": {"apName": "My Weather Station"}, + "get_network_info": {"mac": "AA:BB:CC:DD:EE:FF"}, + } + + async def mock_request(endpoint, **kwargs): + return mock_responses.get(endpoint, {}) + + api._request = AsyncMock(side_effect=mock_request) + + device_info = await api.get_device_info() + + assert isinstance(device_info, DeviceInfo) + assert device_info.version == "V1.7.0" + assert device_info.dev_name == "My Weather Station" + assert device_info.mac == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.asyncio + async def test_parse_value_and_unit(self, api): + """Test value and unit parsing.""" + # Test temperature with space + value, unit = api._parse_value_and_unit("20.7 C") + assert value == "20.7" + assert unit == "C" + + # Test percentage + value, unit = api._parse_value_and_unit("58%") + assert value == "58" + assert unit == "%" + + # Test rain rate + value, unit = api._parse_value_and_unit("0.0 mm/Hr") + assert value == "0.0" + assert unit == "mm/Hr" + + # Test pressure + value, unit = api._parse_value_and_unit("1013.2hPa") + assert value == "1013.2" + assert unit == "hPa" + + # Test empty/invalid + value, unit = api._parse_value_and_unit("--") + assert value == "--" + assert unit is None + + @pytest.mark.asyncio + async def test_safe_conversions(self, api): + """Test safe conversion methods.""" + # Test safe_int + assert api._safe_int("20") == 20 + assert api._safe_int("20.5") == 20 + assert api._safe_int("--") is None + assert api._safe_int("invalid") is None + + @pytest.mark.asyncio + async def test_get_all_data_structure(self, api): + """Test that get_all_data returns properly structured data.""" + # Mock all required API endpoints + mock_responses = { + "get_version": {"version": "GW1100A_V1.7.0"}, + "get_device_info": {"apName": "Test Station"}, + "get_network_info": {"mac": "AA:BB:CC:DD:EE:FF"}, + "get_livedata_info": { + "common_list": [ + {"id": "0x02", "val": "20.7 C"}, + {"id": "0x07", "val": "58%"}, + ], + "wh25": [{"intemp": "22.0", "unit": "C", "inhumi": "45%", "rel": "1013.2 hPa", "abs": "1013.2 hPa"}], + "piezoRain": [ + {"id": "0x0D", "val": "0.0 mm"}, + {"id": "0x0E", "val": "0.0 mm/Hr"}, + ], + "ch_lds": [ + { + "channel": "1", + "name": "", + "unit": "mm", + "battery": "5", + "voltage": "3.28", + "air": "1565 mm", + "depth": "--.-", + "total_height": "0", + "total_heat": "15" + } + ] + }, + "get_sensors_info?page=1": [ + { + "img": "wh54", + "type": "66", + "name": "Lds CH1", + "id": "2B77", + "batt": "5", + "rssi": "-62", + "signal": "4", + "idst": "1" + } + ], + "get_sensors_info?page=2": [], + "get_iot_device_list": {"command": []}, + } + + async def mock_request(endpoint, **kwargs): + return mock_responses.get(endpoint, {}) + + api._request = AsyncMock(side_effect=mock_request) + + data = await api.get_all_data() + + assert isinstance(data, EcoWittDeviceData) + assert isinstance(data.device_info, DeviceInfo) + assert data.device_info.version == "V1.7.0" + assert data.device_info.dev_name == "Test Station" + assert data.device_info.mac == "AA:BB:CC:DD:EE:FF" + + # Check that grouped structure is maintained + assert hasattr(data, "common_list") + assert hasattr(data, "wh25") + assert hasattr(data, "piezoRain") + assert hasattr(data, "ch_lds") + assert hasattr(data, "sensors") + assert isinstance(data.iot_devices, list) + + # Test that values and units are parsed correctly + assert len(data.common_list) == 2 + assert data.common_list[0].id == "0x02" + assert data.common_list[0].val == "20.7" + assert data.common_list[0].unit == "C" + assert data.common_list[1].val == "58" + assert data.common_list[1].unit == "%" + + # Test WH25 data + assert len(data.wh25) == 1 + assert data.wh25[0].intemp == "22.0" + assert data.wh25[0].unit == "C" + + # Test LDS data with device ID mapping + assert len(data.ch_lds) == 1 + assert data.ch_lds[0].channel == "1" + assert data.ch_lds[0].device_id == "2B77" # Mapped from sensor info + + # Test sensor filtering (configured sensors only) + assert len(data.sensors) == 1 + assert data.sensors[0].id == "2B77" + assert data.sensors[0].type == 66 + + # Test mashumaro functionality + device_dict = data.device_info.to_dict() + assert device_dict["version"] == "V1.7.0" + + # Test from_dict creation + new_device = DeviceInfo.from_dict(device_dict) + assert new_device.version == "V1.7.0" \ No newline at end of file