Add WeatherFlow integration (#75530)

* merge upstream

* Update homeassistant/components/weatherflow/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* feat: Removing unused keys

* feat: Addressing PR to remove DEFAULT_HOST from init

* feat: Addressing PR abort case

* feat: Ensure there is a default host always

* feat: Addressing PR comments and fixing entity names via local testing

* feat: Tested units

* feat: updated variable names to hopefully add some clarity to the function

* feat: added more var names for clarity

* feat: Fixed abort

* Update homeassistant/components/weatherflow/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* feat: Removed an unnecessary line

* feat: Test updates

* feat: Removed unreachable code

* feat: Tons of improvements

* removed debug code

* feat: small tweaks

* feat: Fixed density into HA Compatibility

* feat: Handled the options incorrectly... now fixed

* feat: Handled the options incorrectly... now fixed

* Update homeassistant/components/weatherflow/manifest.json

Co-authored-by: J. Nick Koston <nick@koston.org>

* Cleaned up callback in __init__
Cleaning up config_flow as well

* feat: Cleaned up a stupid test

* feat: Simulating a timeout event

* feat: Triggering timeout in mocking

* feat: trying to pass tests

* refactor: Moved code around so easier to test

* Update homeassistant/components/weatherflow/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* feat: Incremental changes moving along well

* feat: Last fix before re-review

* feat: Hopefully the tests shall pass

* feat: Remove domian from unique id

* feat: Fixed entity name

* feat: Removed unneeded lambda - to make thread safe

* Working version...

* working

* working

* feat: Remove tuff

* feat: Removed dual call

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* feat: updates based on pr

* feat: removed some self refrences

* feat: Mod RSSI

* feat: Removed the custom Air Density (will add in a later PR)

* feat: Significant cleanup of config flow

* feat: Reworked the configflwo with the help of Joostlek

* feat: Updated test coverage

* feat: Removing bakcing lib attribute

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* feat: Updated translations

* feat: Renamed CUSTOM to VOC

* feat: Extreme simplification of config flow

* feat: Pushing incremental changes

* feat: Fixing test coverage

* feat: Added lambda expressions for attributes and removed the custom AirDensity sensor

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* feat: Removed default lambda expression from raw_data_conv_fn

* feat: Added back default variable for lambda

* feat: Updated tests accordingly

* feat: Updated tests

* made sure to patch correct import

* made sure to patch correct import

* feat: Fixed up tests ... added missing asserts

* feat: Dropped model

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Refactor: Updated code

* refactor: Removed commented code

* feat: close out all tests

* feat: Fixing the patch

* feat: Removed a bunch of stuff

* feat: Cleaning up tests even more

* fixed patch and paramaterized a test

* feat: Addressing most recent comments

* updates help of joostlek

* feat: Updated coverage for const

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/weatherflow/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* feat: Addressing more PR issues... probably still a few remain

* using const logger

* Update homeassistant/components/weatherflow/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
pull/101013/head
Jeef 2023-09-27 09:28:05 -06:00 committed by GitHub
parent b21451b3d1
commit 577b664c3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 851 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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={})

View File

@ -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"

View File

@ -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"]
}

View File

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

View File

@ -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"
}
}
}
}

View File

@ -529,6 +529,7 @@ FLOWS = {
"waqi",
"watttime",
"waze_travel_time",
"weatherflow",
"weatherkit",
"webostv",
"wemo",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the WeatherFlow integration."""

View File

@ -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

View File

@ -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
}

View File

@ -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"] == {}