diff --git a/.coveragerc b/.coveragerc index ed621cbff10..2f899999f41 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1507,6 +1507,9 @@ omit = homeassistant/components/watson_tts/tts.py homeassistant/components/watttime/__init__.py homeassistant/components/watttime/sensor.py + homeassistant/components/weatherflow/__init__.py + homeassistant/components/weatherflow/const.py + homeassistant/components/weatherflow/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py homeassistant/components/wiffi/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a6823b0fa45..eed0f633df3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1417,6 +1417,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherflow/ @natekspencer @jeeftor +/tests/components/weatherflow/ @natekspencer @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py new file mode 100644 index 00000000000..c64450babe7 --- /dev/null +++ b/homeassistant/components/weatherflow/__init__.py @@ -0,0 +1,77 @@ +"""Get data from Smart Weather station via UDP.""" +from __future__ import annotations + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice +from pyweatherflowudp.errors import ListenerError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started + +from .const import DOMAIN, LOGGER, format_dispatch_call + +PLATFORMS = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WeatherFlow from a config entry.""" + + client = WeatherFlowListener() + + @callback + def _async_device_discovered(device: WeatherFlowDevice) -> None: + LOGGER.debug("Found a device: %s", device) + + @callback + def _async_add_device_if_started(device: WeatherFlowDevice): + async_at_started( + hass, + callback( + lambda _: async_dispatcher_send( + hass, format_dispatch_call(entry), device + ) + ), + ) + + entry.async_on_unload( + device.on( + EVENT_LOAD_COMPLETE, + lambda _: _async_add_device_if_started(device), + ) + ) + + entry.async_on_unload(client.on(EVENT_DEVICE_DISCOVERED, _async_device_discovered)) + + try: + await client.start_listening() + except ListenerError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_handle_ha_shutdown(event: Event) -> None: + """Handle HA shutdown.""" + await client.stop_listening() + + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_handle_ha_shutdown) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client: WeatherFlowListener = hass.data[DOMAIN].pop(entry.entry_id, None) + if client: + await client.stop_listening() + + return unload_ok diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py new file mode 100644 index 00000000000..5ce737810b0 --- /dev/null +++ b/homeassistant/components/weatherflow/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for WeatherFlow.""" +from __future__ import annotations + +import asyncio +from asyncio import Future +from asyncio.exceptions import CancelledError +from typing import Any + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.errors import AddressInUseError, EndpointError, ListenerError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) + + +async def _async_can_discover_devices() -> bool: + """Return if there are devices that can be discovered.""" + future_event: Future[None] = asyncio.get_running_loop().create_future() + + @callback + def _async_found(_): + """Handle a discovered device - only need to do this once so.""" + + if not future_event.done(): + future_event.set_result(None) + + async with WeatherFlowListener() as client, asyncio.timeout(10): + try: + client.on(EVENT_DEVICE_DISCOVERED, _async_found) + await future_event + except asyncio.TimeoutError: + return False + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WeatherFlow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + # Only allow a single instance of integration since the listener + # will pick up all devices on the network and we don't want to + # create multiple entries. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + found = False + errors = {} + try: + found = await _async_can_discover_devices() + except AddressInUseError: + errors["base"] = ERROR_MSG_ADDRESS_IN_USE + except (ListenerError, EndpointError, CancelledError): + errors["base"] = ERROR_MSG_CANNOT_CONNECT + + if not found and not errors: + errors["base"] = ERROR_MSG_NO_DEVICE_FOUND + + if errors: + return self.async_show_form(step_id="user", errors=errors) + + return self.async_create_entry(title="WeatherFlow", data={}) diff --git a/homeassistant/components/weatherflow/const.py b/homeassistant/components/weatherflow/const.py new file mode 100644 index 00000000000..fdacc6ef1eb --- /dev/null +++ b/homeassistant/components/weatherflow/const.py @@ -0,0 +1,18 @@ +"""Constants for the WeatherFlow integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry + +DOMAIN = "weatherflow" +LOGGER = logging.getLogger(__package__) + + +def format_dispatch_call(config_entry: ConfigEntry) -> str: + """Construct a dispatch call from a ConfigEntry.""" + return f"{config_entry.domain}_{config_entry.entry_id}_add" + + +ERROR_MSG_ADDRESS_IN_USE = "address_in_use" +ERROR_MSG_CANNOT_CONNECT = "cannot_connect" +ERROR_MSG_NO_DEVICE_FOUND = "no_device_found" diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json new file mode 100644 index 00000000000..e2671d74cda --- /dev/null +++ b/homeassistant/components/weatherflow/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "weatherflow", + "name": "WeatherFlow", + "codeowners": ["@natekspencer", "@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherflow", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["pyweatherflowudp"], + "requirements": ["pyweatherflowudp==1.4.2"] +} diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py new file mode 100644 index 00000000000..dfc8e585f1b --- /dev/null +++ b/homeassistant/components/weatherflow/sensor.py @@ -0,0 +1,386 @@ +"""Sensors for the weatherflow integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + +from pyweatherflowudp.const import EVENT_RAPID_WIND +from pyweatherflowudp.device import ( + EVENT_OBSERVATION, + EVENT_STATUS_UPDATE, + WeatherFlowDevice, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, + EntityCategory, + UnitOfElectricPotential, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DOMAIN, LOGGER, format_dispatch_call + + +@dataclass +class WeatherFlowSensorRequiredKeysMixin: + """Mixin for required keys.""" + + raw_data_conv_fn: Callable[[WeatherFlowDevice], datetime | StateType] + + +def precipitation_raw_conversion_fn(raw_data: Enum): + """Parse parse precipitation type.""" + if raw_data.name.lower() == "unknown": + return None + return raw_data.name.lower() + + +@dataclass +class WeatherFlowSensorEntityDescription( + SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin +): + """Describes WeatherFlow sensor entity.""" + + event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) + imperial_suggested_unit: None | str = None + + def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: + """Return the parsed sensor value.""" + raw_sensor_data = getattr(device, self.key) + return self.raw_data_conv_fn(raw_sensor_data) + + +SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( + WeatherFlowSensorEntityDescription( + key="air_density", + translation_key="air_density", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + ), + WeatherFlowSensorEntityDescription( + key="air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="dew_point_temperature", + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="feels_like_temperature", + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wet_bulb_temperature", + translation_key="wet_bulb_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="battery", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_average_distance", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + translation_key="lightning_average_distance", + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_count", + translation_key="lightning_count", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.TOTAL, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="precipitation_type", + translation_key="precipitation_type", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "hail", "rain_hail", "unknown"], + icon="mdi:weather-rainy", + raw_data_conv_fn=precipitation_raw_conversion_fn, + ), + WeatherFlowSensorEntityDescription( + key="rain_accumulation_previous_minute", + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.PRECIPITATION, + imperial_suggested_unit=UnitOfPrecipitationDepth.INCHES, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rain_rate", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="relative_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="station_pressure", + translation_key="station_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + imperial_suggested_unit=UnitOfPressure.INHG, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="solar_radiation", + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="up_since", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="uv", + translation_key="uv_index", + native_unit_of_measurement=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="vapor_pressure", + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + imperial_suggested_unit=UnitOfPressure.INHG, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + ## Wind Sensors + WeatherFlowSensorEntityDescription( + key="wind_gust", + translation_key="wind_gust", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_lull", + translation_key="wind_lull", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + icon="mdi:weather-windy", + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed_average", + translation_key="wind_speed_average", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction", + translation_key="wind_direction", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction_average", + translation_key="wind_direction_average", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WeatherFlow sensors using config entry.""" + + @callback + def async_add_sensor(device: WeatherFlowDevice) -> None: + """Add WeatherFlow sensor.""" + LOGGER.debug("Adding sensors for %s", device) + + sensors: list[WeatherFlowSensorEntity] = [ + WeatherFlowSensorEntity( + device=device, + description=description, + is_metric=(hass.config.units == METRIC_SYSTEM), + ) + for description in SENSORS + if hasattr(device, description.key) + ] + + async_add_entities(sensors) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + format_dispatch_call(config_entry), + async_add_sensor, + ) + ) + + +class WeatherFlowSensorEntity(SensorEntity): + """Defines a WeatherFlow sensor entity.""" + + entity_description: WeatherFlowSensorEntityDescription + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: WeatherFlowDevice, + description: WeatherFlowSensorEntityDescription, + is_metric: bool = True, + ) -> None: + """Initialize a WeatherFlow sensor entity.""" + self.device = device + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial_number)}, + manufacturer="WeatherFlow", + model=device.model, + name=device.serial_number, + sw_version=device.firmware_revision, + ) + + self._attr_unique_id = f"{device.serial_number}_{description.key}" + + # In the case of the USA - we may want to have a suggested US unit which differs from the internal suggested units + if description.imperial_suggested_unit is not None and not is_metric: + self._attr_suggested_unit_of_measurement = ( + description.imperial_suggested_unit + ) + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return self.device.last_report + return None + + @property + def native_value(self) -> datetime | StateType: + """Return the state of the sensor.""" + return self.entity_description.get_native_value(self.device) + + async def async_added_to_hass(self) -> None: + """Subscribe to events.""" + for event in self.entity_description.event_subscriptions: + self.async_on_remove( + self.device.on(event, lambda _: self.async_write_ha_state()) + ) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json new file mode 100644 index 00000000000..8f7a98abe04 --- /dev/null +++ b/homeassistant/components/weatherflow/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherFlow discovery", + "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "address_in_use": "Unable to open local UDP port 50222.", + "cannot_connect": "UDP discovery error." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + }, + "entity": { + "sensor": { + "air_density": { + "name": "Air density" + }, + "dew_point": { + "name": "Dew point" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "feels_like": { + "name": "Feels like" + }, + "lightning_average_distance": { + "name": "Lightning average distance" + }, + "lightning_count": { + "name": "Lightning count" + }, + "precipitation_type": { + "name": "Precipitation type", + "state": { + "none": "None", + "rain": "Rain", + "hail": "Hail", + "rain_hail": "Rain and hail" + } + }, + "station_pressure": { + "name": "Air pressure" + }, + "uptime": { + "name": "Uptime" + }, + "uv_index": { + "name": "UV index" + }, + "vapor_pressure": { + "name": "Vapor pressure" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_speed_average": { + "name": "Wind speed average" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_direction_average": { + "name": "Wind direction average" + }, + "wind_gust": { + "name": "Wind gust" + }, + "wind_lull": { + "name": "Wind lull" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 552e7cf991c..ef22ac4f653 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -529,6 +529,7 @@ FLOWS = { "waqi", "watttime", "waze_travel_time", + "weatherflow", "weatherkit", "webostv", "wemo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8215554784f..1d9c2208ad0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6347,6 +6347,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "weatherflow": { + "name": "WeatherFlow", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "webhook": { "name": "Webhook", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 55d279ae8cc..dd549ea51d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2248,6 +2248,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.2 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6d3352c69b..dd81cfb85d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1674,6 +1674,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.2 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/weatherflow/__init__.py b/tests/components/weatherflow/__init__.py new file mode 100644 index 00000000000..e7dd3dc0958 --- /dev/null +++ b/tests/components/weatherflow/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeatherFlow integration.""" diff --git a/tests/components/weatherflow/conftest.py b/tests/components/weatherflow/conftest.py new file mode 100644 index 00000000000..0bf6b69b9a7 --- /dev/null +++ b/tests/components/weatherflow/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for Weatherflow integration tests.""" +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED +from pyweatherflowudp.device import WeatherFlowDevice + +from homeassistant.components.weatherflow.const import DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.weatherflow.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data={}) + + +@pytest.fixture +def mock_has_devices() -> Generator[AsyncMock, None, None]: + """Return a mock has_devices function.""" + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + return_value=True, + ) as mock_has_devices: + yield mock_has_devices + + +@pytest.fixture +def mock_stop() -> Generator[AsyncMock, None, None]: + """Return a fixture to handle the stop of udp.""" + + async def mock_stop_listening(self): + self._udp_task.cancel() + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.stop_listening", + autospec=True, + side_effect=mock_stop_listening, + ) as mock_function: + yield mock_function + + +@pytest.fixture +def mock_start() -> Generator[AsyncMock, None, None]: + """Return fixture for starting upd.""" + + device = WeatherFlowDevice( + serial_number="HB-00000001", + data=load_json_object_fixture("weatherflow/device.json"), + ) + + async def device_discovery_task(self): + await asyncio.gather( + await asyncio.sleep(0.1), self.emit(EVENT_DEVICE_DISCOVERED, "HB-00000001") + ) + + async def mock_start_listening(self): + """Mock listening function.""" + self._devices["HB-00000001"] = device + self._udp_task = asyncio.create_task(device_discovery_task(self)) + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.start_listening", + autospec=True, + side_effect=mock_start_listening, + ) as mock_function: + yield mock_function diff --git a/tests/components/weatherflow/fixtures/device.json b/tests/components/weatherflow/fixtures/device.json new file mode 100644 index 00000000000..a9653c71cb0 --- /dev/null +++ b/tests/components/weatherflow/fixtures/device.json @@ -0,0 +1,13 @@ +{ + "serial_number": "ST-00000001", + "type": "device_status", + "hub_sn": "HB-00000001", + "timestamp": 1510855923, + "uptime": 2189, + "voltage": 3.5, + "firmware_revision": 17, + "rssi": -17, + "hub_rssi": -87, + "sensor_status": 0, + "debug": 0 +} diff --git a/tests/components/weatherflow/test_config_flow.py b/tests/components/weatherflow/test_config_flow.py new file mode 100644 index 00000000000..4188c737230 --- /dev/null +++ b/tests/components/weatherflow/test_config_flow.py @@ -0,0 +1,91 @@ +"""Tests for WeatherFlow.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.errors import AddressInUseError + +from homeassistant import config_entries +from homeassistant.components.weatherflow.const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_has_devices: AsyncMock, +) -> None: + """Test more than one instance.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_devices_with_mocks( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test getting user input.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (asyncio.TimeoutError, ERROR_MSG_NO_DEVICE_FOUND), + (asyncio.exceptions.CancelledError, ERROR_MSG_CANNOT_CONNECT), + (AddressInUseError, ERROR_MSG_ADDRESS_IN_USE), + ], +) +async def test_devices_with_various_mocks_errors( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test the various on error states - then finally complete the test.""" + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == error_msg + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {}