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
parent
b21451b3d1
commit
577b664c3b
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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={})
|
|
@ -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"
|
|
@ -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"]
|
||||
}
|
|
@ -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())
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -529,6 +529,7 @@ FLOWS = {
|
|||
"waqi",
|
||||
"watttime",
|
||||
"waze_travel_time",
|
||||
"weatherflow",
|
||||
"weatherkit",
|
||||
"webostv",
|
||||
"wemo",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the WeatherFlow integration."""
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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"] == {}
|
Loading…
Reference in New Issue