From 105bb3e08264c753012f10fd35f8358c8683646d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 31 Aug 2022 12:51:39 +0200 Subject: [PATCH] Ecowitt integration (#77441) * Add ecowitt integration * add tests * use total * use total * test coverage * Update homeassistant/components/ecowitt/__init__.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/ecowitt/binary_sensor.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/ecowitt/entity.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/ecowitt/diagnostics.py Co-authored-by: Paulus Schoutsen * add to async_on_unload * remove attr_name / unload callback * support unload platforms * using replace * address mapping * update type * mark final * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Fix bracket * Fix another bracket * Address comment * Add strings * update tests * Update homeassistant/components/ecowitt/strings.json Co-authored-by: Martin Hjelmare * update text * Update homeassistant/components/ecowitt/strings.json Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .coveragerc | 7 +- CODEOWNERS | 2 + homeassistant/components/ecowitt/__init__.py | 47 ++++ .../components/ecowitt/binary_sensor.py | 71 +++++ .../components/ecowitt/config_flow.py | 79 ++++++ homeassistant/components/ecowitt/const.py | 7 + .../components/ecowitt/diagnostics.py | 39 +++ homeassistant/components/ecowitt/entity.py | 46 ++++ .../components/ecowitt/manifest.json | 9 + homeassistant/components/ecowitt/sensor.py | 247 ++++++++++++++++++ homeassistant/components/ecowitt/strings.json | 17 ++ .../components/ecowitt/translations/en.json | 17 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ecowitt/__init__.py | 1 + tests/components/ecowitt/test_config_flow.py | 110 ++++++++ 17 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ecowitt/__init__.py create mode 100644 homeassistant/components/ecowitt/binary_sensor.py create mode 100644 homeassistant/components/ecowitt/config_flow.py create mode 100644 homeassistant/components/ecowitt/const.py create mode 100644 homeassistant/components/ecowitt/diagnostics.py create mode 100644 homeassistant/components/ecowitt/entity.py create mode 100644 homeassistant/components/ecowitt/manifest.json create mode 100644 homeassistant/components/ecowitt/sensor.py create mode 100644 homeassistant/components/ecowitt/strings.json create mode 100644 homeassistant/components/ecowitt/translations/en.json create mode 100644 tests/components/ecowitt/__init__.py create mode 100644 tests/components/ecowitt/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d6a27259871..3ff0d49965c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -260,6 +260,11 @@ omit = homeassistant/components/econet/const.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py + homeassistant/components/ecowitt/__init__.py + homeassistant/components/ecowitt/binary_sensor.py + homeassistant/components/ecowitt/diagnostics.py + homeassistant/components/ecowitt/entity.py + homeassistant/components/ecowitt/sensor.py homeassistant/components/ecovacs/* homeassistant/components/edl21/* homeassistant/components/eddystone_temperature/sensor.py @@ -394,7 +399,7 @@ omit = homeassistant/components/flock/notify.py homeassistant/components/flume/__init__.py homeassistant/components/flume/coordinator.py - homeassistant/components/flume/entity.py + homeassistant/components/flume/entity.py homeassistant/components/flume/sensor.py homeassistant/components/flunearyou/__init__.py homeassistant/components/flunearyou/repairs.py diff --git a/CODEOWNERS b/CODEOWNERS index 575a79d7afa..b135a418566 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -276,6 +276,8 @@ build.json @home-assistant/supervisor /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT +/homeassistant/components/ecowitt/ @pvizeli +/tests/components/ecowitt/ @pvizeli /homeassistant/components/edl21/ @mtdcr /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob diff --git a/homeassistant/components/ecowitt/__init__.py b/homeassistant/components/ecowitt/__init__.py new file mode 100644 index 00000000000..71d42643cfb --- /dev/null +++ b/homeassistant/components/ecowitt/__init__.py @@ -0,0 +1,47 @@ +"""The Ecowitt Weather Station Component.""" +from __future__ import annotations + +from aioecowitt import EcoWittListener + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant + +from .const import CONF_PATH, DOMAIN + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Ecowitt component from UI.""" + hass.data.setdefault(DOMAIN, {}) + + ecowitt = hass.data[DOMAIN][entry.entry_id] = EcoWittListener( + port=entry.data[CONF_PORT], path=entry.data[CONF_PATH] + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + await ecowitt.start() + + # Close on shutdown + async def _stop_ecowitt(_: Event): + """Stop the Ecowitt listener.""" + await ecowitt.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_ecowitt) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + ecowitt = hass.data[DOMAIN][entry.entry_id] + await ecowitt.stop() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py new file mode 100644 index 00000000000..e487009d74b --- /dev/null +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -0,0 +1,71 @@ +"""Support for Ecowitt Weather Stations.""" +import dataclasses +from typing import Final + +from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import EcowittEntity + +ECOWITT_BINARYSENSORS_MAPPING: Final = { + EcoWittSensorTypes.LEAK: BinarySensorEntityDescription( + key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE + ), + EcoWittSensorTypes.BATTERY_BINARY: BinarySensorEntityDescription( + key="BATTERY", device_class=BinarySensorDeviceClass.BATTERY + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add sensors if new.""" + ecowitt: EcoWittListener = hass.data[DOMAIN][entry.entry_id] + + def _new_sensor(sensor: EcoWittSensor) -> None: + """Add new sensor.""" + if sensor.stype not in ECOWITT_BINARYSENSORS_MAPPING: + return + mapping = ECOWITT_BINARYSENSORS_MAPPING[sensor.stype] + + # Setup sensor description + description = dataclasses.replace( + mapping, + key=sensor.key, + name=sensor.name, + ) + + async_add_entities([EcowittBinarySensorEntity(sensor, description)]) + + ecowitt.new_sensor_cb.append(_new_sensor) + entry.async_on_unload(lambda: ecowitt.new_sensor_cb.remove(_new_sensor)) + + # Add all sensors that are already known + for sensor in ecowitt.sensors.values(): + _new_sensor(sensor) + + +class EcowittBinarySensorEntity(EcowittEntity, BinarySensorEntity): + """Representation of a Ecowitt BinarySensor.""" + + def __init__( + self, sensor: EcoWittSensor, description: BinarySensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(sensor) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.ecowitt.value > 0 diff --git a/homeassistant/components/ecowitt/config_flow.py b/homeassistant/components/ecowitt/config_flow.py new file mode 100644 index 00000000000..c3406652665 --- /dev/null +++ b/homeassistant/components/ecowitt/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for ecowitt.""" +from __future__ import annotations + +import logging +import secrets +from typing import Any + +from aioecowitt import EcoWittListener +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +from .const import CONF_PATH, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PATH, default=f"/{secrets.token_urlsafe(16)}"): cv.string, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: + """Validate user input.""" + # Check if the port is in use + try: + listener = EcoWittListener(port=data[CONF_PORT]) + await listener.start() + await listener.stop() + except OSError: + raise InvalidPort from None + + return {"title": f"Ecowitt on port {data[CONF_PORT]}"} + + +class EcowittConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for the Ecowitt.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + # Check if the port is in use by another config entry + self._async_abort_entries_match({CONF_PORT: user_input[CONF_PORT]}) + + try: + info = await validate_input(self.hass, user_input) + except InvalidPort: + errors["base"] = "invalid_port" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidPort(HomeAssistantError): + """Error to indicate there port is not usable.""" diff --git a/homeassistant/components/ecowitt/const.py b/homeassistant/components/ecowitt/const.py new file mode 100644 index 00000000000..a7011f24e0c --- /dev/null +++ b/homeassistant/components/ecowitt/const.py @@ -0,0 +1,7 @@ +"""Constants used by ecowitt component.""" + +DOMAIN = "ecowitt" + +DEFAULT_PORT = 49199 + +CONF_PATH = "path" diff --git a/homeassistant/components/ecowitt/diagnostics.py b/homeassistant/components/ecowitt/diagnostics.py new file mode 100644 index 00000000000..d02a5dadbcc --- /dev/null +++ b/homeassistant/components/ecowitt/diagnostics.py @@ -0,0 +1,39 @@ +"""Provides diagnostics for EcoWitt.""" +from __future__ import annotations + +from typing import Any + +from aioecowitt import EcoWittListener + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + ecowitt: EcoWittListener = hass.data[DOMAIN][entry.entry_id] + station_id = next(item[1] for item in device.identifiers if item[0] == DOMAIN) + + station = ecowitt.stations[station_id] + + data = { + "device": { + "name": station.station, + "model": station.model, + "frequency": station.frequency, + "version": station.version, + }, + "raw": ecowitt.last_values[station_id], + "sensors": { + sensor.key: sensor.value + for sensor in station.sensors + if sensor.station.key == station_id + }, + } + + return data diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py new file mode 100644 index 00000000000..ca5e14b6d7b --- /dev/null +++ b/homeassistant/components/ecowitt/entity.py @@ -0,0 +1,46 @@ +"""The Ecowitt Weather Station Entity.""" +from __future__ import annotations + +import time + +from aioecowitt import EcoWittSensor + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class EcowittEntity(Entity): + """Base class for Ecowitt Weather Station.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, sensor: EcoWittSensor) -> None: + """Construct the entity.""" + self.ecowitt: EcoWittSensor = sensor + + self._attr_unique_id = f"{sensor.station.key}-{sensor.key}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, sensor.station.key), + }, + name=sensor.station.station, + model=sensor.station.model, + sw_version=sensor.station.version, + ) + + async def async_added_to_hass(self): + """Install listener for updates later.""" + + def _update_state(): + """Update the state on callback.""" + self.async_write_ha_state() + + self.ecowitt.update_cb.append(_update_state) + self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) + + @property + def available(self) -> bool: + """Return whether the state is based on actual reading from device.""" + return (self.ecowitt.last_update_m + 5 * 60) > time.monotonic() diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json new file mode 100644 index 00000000000..cafd140828e --- /dev/null +++ b/homeassistant/components/ecowitt/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecowitt", + "name": "Ecowitt Weather Station", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecowitt", + "requirements": ["aioecowitt==2022.08.3"], + "codeowners": ["@pvizeli"], + "iot_class": "local_push" +} diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py new file mode 100644 index 00000000000..843dc700dc0 --- /dev/null +++ b/homeassistant/components/ecowitt/sensor.py @@ -0,0 +1,247 @@ +"""Support for Ecowitt Weather Stations.""" +import dataclasses +from typing import Final + +from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + AREA_SQUARE_METERS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + ELECTRIC_POTENTIAL_VOLT, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILLIMETERS, + LIGHT_LUX, + PERCENTAGE, + POWER_WATT, + PRECIPITATION_INCHES_PER_HOUR, + PRECIPITATION_MILLIMETERS_PER_HOUR, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UV_INDEX, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .entity import EcowittEntity + +_METRIC: Final = ( + EcoWittSensorTypes.TEMPERATURE_C, + EcoWittSensorTypes.RAIN_COUNT_MM, + EcoWittSensorTypes.RAIN_RATE_MM, + EcoWittSensorTypes.LIGHTNING_DISTANCE_KM, + EcoWittSensorTypes.SPEED_KPH, + EcoWittSensorTypes.PRESSURE_HPA, +) +_IMPERIAL: Final = ( + EcoWittSensorTypes.TEMPERATURE_F, + EcoWittSensorTypes.RAIN_COUNT_INCHES, + EcoWittSensorTypes.RAIN_RATE_INCHES, + EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES, + EcoWittSensorTypes.SPEED_MPH, + EcoWittSensorTypes.PRESSURE_INHG, +) + + +ECOWITT_SENSORS_MAPPING: Final = { + EcoWittSensorTypes.HUMIDITY: SensorEntityDescription( + key="HUMIDITY", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.DEGREE: SensorEntityDescription( + key="DEGREE", native_unit_of_measurement=DEGREE + ), + EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription( + key="WATT_METERS_SQUARED", + native_unit_of_measurement=f"{POWER_WATT}/{AREA_SQUARE_METERS}", + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.UV_INDEX: SensorEntityDescription( + key="UV_INDEX", + native_unit_of_measurement=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.PM25: SensorEntityDescription( + key="PM25", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.PM10: SensorEntityDescription( + key="PM10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.BATTERY_PERCENTAGE: SensorEntityDescription( + key="BATTERY_PERCENTAGE", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.BATTERY_VOLTAGE: SensorEntityDescription( + key="BATTERY_VOLTAGE", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( + key="CO2_PPM", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.LUX: SensorEntityDescription( + key="LUX", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.TIMESTAMP: SensorEntityDescription( + key="TIMESTAMP", device_class=SensorDeviceClass.TIMESTAMP + ), + EcoWittSensorTypes.VOLTAGE: SensorEntityDescription( + key="VOLTAGE", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.LIGHTNING_COUNT: SensorEntityDescription( + key="LIGHTNING_COUNT", + native_unit_of_measurement="strikes", + state_class=SensorStateClass.TOTAL, + ), + EcoWittSensorTypes.TEMPERATURE_C: SensorEntityDescription( + key="TEMPERATURE_C", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.TEMPERATURE_F: SensorEntityDescription( + key="TEMPERATURE_F", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.RAIN_COUNT_MM: SensorEntityDescription( + key="RAIN_COUNT_MM", + native_unit_of_measurement=LENGTH_MILLIMETERS, + state_class=SensorStateClass.TOTAL, + ), + EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription( + key="RAIN_COUNT_INCHES", + native_unit_of_measurement=LENGTH_INCHES, + state_class=SensorStateClass.TOTAL, + ), + EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription( + key="RAIN_RATE_MM", + native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription( + key="RAIN_RATE_INCHES", + native_unit_of_measurement=PRECIPITATION_INCHES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( + key="LIGHTNING_DISTANCE_KM", + native_unit_of_measurement=LENGTH_KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES: SensorEntityDescription( + key="LIGHTNING_DISTANCE_MILES", + native_unit_of_measurement=LENGTH_MILES, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.SPEED_KPH: SensorEntityDescription( + key="SPEED_KPH", + native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription( + key="SPEED_MPH", + native_unit_of_measurement=SPEED_MILES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription( + key="PRESSURE_HPA", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.PRESSURE_INHG: SensorEntityDescription( + key="PRESSURE_INHG", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_INHG, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add sensors if new.""" + ecowitt: EcoWittListener = hass.data[DOMAIN][entry.entry_id] + + def _new_sensor(sensor: EcoWittSensor) -> None: + """Add new sensor.""" + if sensor.stype not in ECOWITT_SENSORS_MAPPING: + return + + # Ignore metrics that are not supported by the user's locale + if sensor.stype in _METRIC and not hass.config.units.is_metric: + return + if sensor.stype in _IMPERIAL and hass.config.units.is_metric: + return + mapping = ECOWITT_SENSORS_MAPPING[sensor.stype] + + # Setup sensor description + description = dataclasses.replace( + mapping, + key=sensor.key, + name=sensor.name, + ) + + async_add_entities([EcowittSensorEntity(sensor, description)]) + + ecowitt.new_sensor_cb.append(_new_sensor) + entry.async_on_unload(lambda: ecowitt.new_sensor_cb.remove(_new_sensor)) + + # Add all sensors that are already known + for sensor in ecowitt.sensors.values(): + _new_sensor(sensor) + + +class EcowittSensorEntity(EcowittEntity, SensorEntity): + """Representation of a Ecowitt Sensor.""" + + def __init__( + self, sensor: EcoWittSensor, description: SensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(sensor) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.ecowitt.value diff --git a/homeassistant/components/ecowitt/strings.json b/homeassistant/components/ecowitt/strings.json new file mode 100644 index 00000000000..a4e12e69d57 --- /dev/null +++ b/homeassistant/components/ecowitt/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_port": "Port is already used.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "The following steps must be performed to set up this integration.\n\nUse the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\nPick your station -> Menu Others -> DIY Upload Servers.\nHit next and select 'Customized'\n\nPick the protocol Ecowitt, and put in the ip/hostname of your hass server.\nPath have to match, you can copy with secure token /.\nSave configuration. The Ecowitt should then start attempting to send data to your server.", + "data": { + "port": "Listening port", + "path": "Path with Security token" + } + } + } + } +} diff --git a/homeassistant/components/ecowitt/translations/en.json b/homeassistant/components/ecowitt/translations/en.json new file mode 100644 index 00000000000..041fa9f697b --- /dev/null +++ b/homeassistant/components/ecowitt/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_port": "Port is already used.", + "unknown": "Unknown error." + }, + "step": { + "user": { + "description": "The following steps must be performed to set up this integration.\n\nUse the Ecowitt App (on your phone) or your Ecowitt WebUI over the station IP address.\nPick your station -> Menu Others -> DIY Upload Servers.\nHit next and select 'Customized'\n\nPick the protocol Ecowitt, and put in the ip/hostname of your hass server.\nPath have to match, you can copy with secure token /.\nSave configuration. The Ecowitt should then start attempting to send data to your server.", + "data": { + "port": "Listening port", + "path": "Path with Security token" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 127beed575e..c5437e14562 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -90,6 +90,7 @@ FLOWS = { "eafm", "ecobee", "econet", + "ecowitt", "efergy", "eight_sleep", "elgato", diff --git a/requirements_all.txt b/requirements_all.txt index 48819ec8bf2..7c299ce0893 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -146,6 +146,9 @@ aioeafm==0.1.2 # homeassistant.components.rainforest_eagle aioeagle==1.1.0 +# homeassistant.components.ecowitt +aioecowitt==2022.08.3 + # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3b460e8cf3..6a74fba107c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,6 +133,9 @@ aioeafm==0.1.2 # homeassistant.components.rainforest_eagle aioeagle==1.1.0 +# homeassistant.components.ecowitt +aioecowitt==2022.08.3 + # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/tests/components/ecowitt/__init__.py b/tests/components/ecowitt/__init__.py new file mode 100644 index 00000000000..58e4a991df1 --- /dev/null +++ b/tests/components/ecowitt/__init__.py @@ -0,0 +1 @@ +"""Ecowitt tests.""" diff --git a/tests/components/ecowitt/test_config_flow.py b/tests/components/ecowitt/test_config_flow.py new file mode 100644 index 00000000000..6ddd475121b --- /dev/null +++ b/tests/components/ecowitt/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the Ecowitt Weather Station config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.ecowitt.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_create_entry(hass: HomeAssistant) -> None: + """Test we can create a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.ecowitt.config_flow.EcoWittListener.start" + ), patch( + "homeassistant.components.ecowitt.config_flow.EcoWittListener.stop" + ), patch( + "homeassistant.components.ecowitt.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "port": 49911, + "path": "/ecowitt-station", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Ecowitt on port 49911" + assert result2["data"] == { + "port": 49911, + "path": "/ecowitt-station", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_port(hass: HomeAssistant) -> None: + """Test we handle invalid port.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ecowitt.config_flow.EcoWittListener.start", + side_effect=OSError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "port": 49911, + "path": "/ecowitt-station", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_port"} + + +async def test_already_configured_port(hass: HomeAssistant) -> None: + """Test already configured port.""" + MockConfigEntry(domain=DOMAIN, data={"port": 49911}).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ecowitt.config_flow.EcoWittListener.start", + side_effect=OSError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "port": 49911, + "path": "/ecowitt-station", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + + +async def test_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ecowitt.config_flow.EcoWittListener.start", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "port": 49911, + "path": "/ecowitt-station", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"}