Add Weatherflow Cloud wind support via websocket (#125611)

* rebase off of dev

* update tests

* update tests

* addressing PR finally

* API to back

* adding a return type

* need to test

* removed teh extra check on available

* some changes

* ready for re-review

* change assertions

* remove icon function

* update ambr

* ruff

* update snapshot and push

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* enhnaced tests

* better coverage

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* remove comments

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jeef 2025-06-30 07:26:17 -06:00 committed by GitHub
parent 741a3d5009
commit ea70229426
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1334 additions and 142 deletions

View File

@ -2,30 +2,107 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import asyncio
from dataclasses import dataclass
from .const import DOMAIN
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
from weatherflow4py.api import WeatherFlowRestAPI
from weatherflow4py.ws import WeatherFlowWebsocketAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from .coordinator import (
WeatherFlowCloudUpdateCoordinatorREST,
WeatherFlowObservationCoordinator,
WeatherFlowWindCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER]
@dataclass
class WeatherFlowCoordinators:
"""Data Class for Entry Data."""
rest: WeatherFlowCloudUpdateCoordinatorREST
wind: WeatherFlowWindCoordinator
observation: WeatherFlowObservationCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WeatherFlowCloud from a config entry."""
data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry)
await data_coordinator.async_config_entry_first_refresh()
LOGGER.debug("Initializing WeatherFlowCloudDataUpdateCoordinatorREST coordinator")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator
rest_api = WeatherFlowRestAPI(
api_token=entry.data[CONF_API_TOKEN], session=async_get_clientsession(hass)
)
stations = await rest_api.async_get_stations()
# Define Rest Coordinator
rest_data_coordinator = WeatherFlowCloudUpdateCoordinatorREST(
hass=hass, config_entry=entry, rest_api=rest_api, stations=stations
)
# Initialize the stations
await rest_data_coordinator.async_config_entry_first_refresh()
# Construct Websocket Coordinators
LOGGER.debug("Initializing websocket coordinators")
websocket_device_ids = rest_data_coordinator.device_ids
# Build API once
websocket_api = WeatherFlowWebsocketAPI(
access_token=entry.data[CONF_API_TOKEN], device_ids=websocket_device_ids
)
websocket_observation_coordinator = WeatherFlowObservationCoordinator(
hass=hass,
config_entry=entry,
rest_api=rest_api,
websocket_api=websocket_api,
stations=stations,
)
websocket_wind_coordinator = WeatherFlowWindCoordinator(
hass=hass,
config_entry=entry,
rest_api=rest_api,
websocket_api=websocket_api,
stations=stations,
)
# Run setup method
await asyncio.gather(
websocket_wind_coordinator.async_setup(),
websocket_observation_coordinator.async_setup(),
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = WeatherFlowCoordinators(
rest_data_coordinator,
websocket_wind_coordinator,
websocket_observation_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Websocket disconnect handler
async def _async_disconnect_websocket() -> None:
await websocket_api.stop_all_listeners()
await websocket_api.close()
# Register a websocket shutdown handler
entry.async_on_unload(_async_disconnect_websocket)
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):
hass.data[DOMAIN].pop(entry.entry_id)

View File

@ -49,10 +49,11 @@ class WeatherFlowCloudConfigFlow(ConfigFlow, domain=DOMAIN):
errors = await _validate_api_token(api_token)
if not errors:
# Update the existing entry and abort
existing_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
existing_entry,
data={CONF_API_TOKEN: api_token},
reload_even_if_entry_is_unchanged=False,
reason="reauth_successful",
)
return self.async_show_form(

View File

@ -5,7 +5,7 @@ import logging
DOMAIN = "weatherflow_cloud"
LOGGER = logging.getLogger(__package__)
ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api"
ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest API"
MANUFACTURER = "WeatherFlow"
STATE_MAP = {
@ -29,3 +29,6 @@ STATE_MAP = {
"thunderstorm": "lightning",
"windy": "windy",
}
WEBSOCKET_API = "Websocket API"
REST_API = "REST API"

View File

@ -1,46 +1,207 @@
"""Data coordinator for WeatherFlow Cloud Data."""
"""Improved coordinator design with better type safety."""
from abc import ABC, abstractmethod
from datetime import timedelta
from typing import Generic, TypeVar
from aiohttp import ClientResponseError
from weatherflow4py.api import WeatherFlowRestAPI
from weatherflow4py.models.rest.stations import StationsResponseREST
from weatherflow4py.models.rest.unified import WeatherFlowDataREST
from weatherflow4py.models.ws.obs import WebsocketObservation
from weatherflow4py.models.ws.types import EventType
from weatherflow4py.models.ws.websocket_request import (
ListenStartMessage,
RapidWindListenStartMessage,
)
from weatherflow4py.models.ws.websocket_response import (
EventDataRapidWind,
ObservationTempestWS,
RapidWindWS,
)
from weatherflow4py.ws import WeatherFlowWebsocketAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.ssl import client_context
from .const import DOMAIN, LOGGER
T = TypeVar("T")
class WeatherFlowCloudDataUpdateCoordinator(
DataUpdateCoordinator[dict[int, WeatherFlowDataREST]]
):
"""Class to manage fetching REST Based WeatherFlow Forecast data."""
config_entry: ConfigEntry
class BaseWeatherFlowCoordinator(DataUpdateCoordinator[dict[int, T]], ABC, Generic[T]):
"""Base class for WeatherFlow coordinators."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
rest_api: WeatherFlowRestAPI,
stations: StationsResponseREST,
update_interval: timedelta | None = None,
always_update: bool = False,
) -> None:
"""Initialize Coordinator."""
self._token = rest_api.api_token
self._rest_api = rest_api
self.stations = stations
self.device_to_station_map = stations.device_station_map
self.device_ids = list(stations.device_station_map.keys())
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize global WeatherFlow forecast data updater."""
self.weather_api = WeatherFlowRestAPI(
api_token=config_entry.data[CONF_API_TOKEN]
)
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
always_update=always_update,
update_interval=update_interval,
)
@abstractmethod
def get_station_name(self, station_id: int) -> str:
"""Get station name for the given station ID."""
class WeatherFlowCloudUpdateCoordinatorREST(
BaseWeatherFlowCoordinator[WeatherFlowDataREST]
):
"""Class to manage fetching REST Based WeatherFlow Forecast data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
rest_api: WeatherFlowRestAPI,
stations: StationsResponseREST,
) -> None:
"""Initialize global WeatherFlow forecast data updater."""
super().__init__(
hass,
config_entry,
rest_api,
stations,
update_interval=timedelta(seconds=60),
always_update=True,
)
async def _async_update_data(self) -> dict[int, WeatherFlowDataREST]:
"""Fetch data from WeatherFlow Forecast."""
"""Update rest data."""
try:
async with self.weather_api:
return await self.weather_api.get_all_data()
async with self._rest_api:
return await self._rest_api.get_all_data()
except ClientResponseError as err:
if err.status == 401:
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(f"Update failed: {err}") from err
def get_station(self, station_id: int) -> WeatherFlowDataREST:
"""Return station for id."""
return self.data[station_id]
def get_station_name(self, station_id: int) -> str:
"""Return station name for id."""
return self.data[station_id].station.name
class BaseWebsocketCoordinator(
BaseWeatherFlowCoordinator[dict[int, T | None]], ABC, Generic[T]
):
"""Base class for websocket coordinators."""
_event_type: EventType
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
rest_api: WeatherFlowRestAPI,
websocket_api: WeatherFlowWebsocketAPI,
stations: StationsResponseREST,
) -> None:
"""Initialize Coordinator."""
super().__init__(
hass=hass, config_entry=config_entry, rest_api=rest_api, stations=stations
)
self.websocket_api = websocket_api
# Configure the websocket data structure
self._ws_data: dict[int, dict[int, T | None]] = {
station: dict.fromkeys(devices)
for station, devices in self.stations.station_device_map.items()
}
async def async_setup(self) -> None:
"""Set up the websocket connection."""
await self.websocket_api.connect(client_context())
self.websocket_api.register_callback(
message_type=self._event_type,
callback=self._handle_websocket_message,
)
# Subscribe to messages for all devices
for device_id in self.device_ids:
message = self._create_listen_message(device_id)
await self.websocket_api.send_message(message)
@abstractmethod
def _create_listen_message(self, device_id: int):
"""Create the appropriate listen message for this coordinator type."""
@abstractmethod
async def _handle_websocket_message(self, data) -> None:
"""Handle incoming websocket data."""
def get_station(self, station_id: int):
"""Return station for id."""
return self.stations.stations[station_id]
def get_station_name(self, station_id: int) -> str:
"""Return station name for id."""
return self.stations.station_map[station_id].name or ""
class WeatherFlowWindCoordinator(BaseWebsocketCoordinator[EventDataRapidWind]):
"""Coordinator specifically for rapid wind data."""
_event_type = EventType.RAPID_WIND
def _create_listen_message(self, device_id: int) -> RapidWindListenStartMessage:
"""Create rapid wind listen message."""
return RapidWindListenStartMessage(device_id=str(device_id))
async def _handle_websocket_message(self, data: RapidWindWS) -> None:
"""Handle rapid wind websocket data."""
device_id = data.device_id
station_id = self.device_to_station_map[device_id]
# Extract the observation data from the RapidWindWS message
self._ws_data[station_id][device_id] = data.ob
self.async_set_updated_data(self._ws_data)
class WeatherFlowObservationCoordinator(BaseWebsocketCoordinator[WebsocketObservation]):
"""Coordinator specifically for observation data."""
_event_type = EventType.OBSERVATION
def _create_listen_message(self, device_id: int) -> ListenStartMessage:
"""Create observation listen message."""
return ListenStartMessage(device_id=str(device_id))
async def _handle_websocket_message(self, data: ObservationTempestWS) -> None:
"""Handle observation websocket data."""
device_id = data.device_id
station_id = self.device_to_station_map[device_id]
# For observations, the data IS the observation
self._ws_data[station_id][device_id] = data
self.async_set_updated_data(self._ws_data)
# Type aliases for better readability
type WeatherFlowWindCallback = WeatherFlowWindCoordinator
type WeatherFlowObservationCallback = WeatherFlowObservationCoordinator

View File

@ -1,23 +1,21 @@
"""Base entity class for WeatherFlow Cloud integration."""
from weatherflow4py.models.rest.unified import WeatherFlowDataREST
"""Entity definition."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
from .coordinator import BaseWeatherFlowCoordinator
class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordinator]):
"""Base entity class to use for everything."""
class WeatherFlowCloudEntity[T](CoordinatorEntity[BaseWeatherFlowCoordinator[T]]):
"""Base entity class for WeatherFlow Cloud integration."""
_attr_attribution = ATTR_ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,
coordinator: WeatherFlowCloudDataUpdateCoordinator,
coordinator: BaseWeatherFlowCoordinator[T],
station_id: int,
) -> None:
"""Class initializer."""
@ -25,14 +23,9 @@ class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordin
self.station_id = station_id
self._attr_device_info = DeviceInfo(
name=self.station.station.name,
name=coordinator.get_station_name(station_id),
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(station_id))},
manufacturer=MANUFACTURER,
configuration_url=f"https://tempestwx.com/station/{station_id}/grid",
)
@property
def station(self) -> WeatherFlowDataREST:
"""Individual Station data."""
return self.coordinator.data[self.station_id]

View File

@ -1,11 +1,17 @@
{
"entity": {
"sensor": {
"air_density": {
"default": "mdi:format-line-weight"
},
"air_temperature": {
"default": "mdi:thermometer"
},
"air_density": {
"default": "mdi:format-line-weight"
"barometric_pressure": {
"default": "mdi:gauge"
},
"dew_point": {
"default": "mdi:water-percent"
},
"feels_like": {
"default": "mdi:thermometer"
@ -13,12 +19,6 @@
"heat_index": {
"default": "mdi:sun-thermometer"
},
"wet_bulb_temperature": {
"default": "mdi:thermometer-water"
},
"wet_bulb_globe_temperature": {
"default": "mdi:thermometer-water"
},
"lightning_strike_count": {
"default": "mdi:lightning-bolt"
},
@ -34,8 +34,43 @@
"lightning_strike_last_epoch": {
"default": "mdi:lightning-bolt"
},
"sea_level_pressure": {
"default": "mdi:gauge"
},
"wet_bulb_globe_temperature": {
"default": "mdi:thermometer-water"
},
"wet_bulb_temperature": {
"default": "mdi:thermometer-water"
},
"wind_avg": {
"default": "mdi:weather-windy"
},
"wind_chill": {
"default": "mdi:snowflake-thermometer"
},
"wind_direction": {
"default": "mdi:compass",
"range": {
"0": "mdi:arrow-up",
"22.5": "mdi:arrow-top-right",
"67.5": "mdi:arrow-right",
"112.5": "mdi:arrow-bottom-right",
"157.5": "mdi:arrow-down",
"202.5": "mdi:arrow-bottom-left",
"247.5": "mdi:arrow-left",
"292.5": "mdi:arrow-top-left",
"337.5": "mdi:arrow-up"
}
},
"wind_gust": {
"default": "mdi:weather-dust"
},
"wind_lull": {
"default": "mdi:weather-windy-variant"
},
"wind_sample_interval": {
"default": "mdi:timer-outline"
}
}
}

View File

@ -2,11 +2,17 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import date, datetime
from decimal import Decimal
from weatherflow4py.models.rest.observation import Observation
from weatherflow4py.models.ws.websocket_response import (
EventDataRapidWind,
WebsocketObservation,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -15,13 +21,22 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature
from homeassistant.const import (
EntityCategory,
UnitOfLength,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import UTC
from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators
from .const import DOMAIN
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator
from .entity import WeatherFlowCloudEntity
@ -34,6 +49,87 @@ class WeatherFlowCloudSensorEntityDescription(
value_fn: Callable[[Observation], StateType | datetime]
@dataclass(frozen=True, kw_only=True)
class WeatherFlowCloudSensorEntityDescriptionWebsocketWind(
SensorEntityDescription,
):
"""Describes a weatherflow sensor."""
value_fn: Callable[[EventDataRapidWind], StateType | datetime]
@dataclass(frozen=True, kw_only=True)
class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation(
SensorEntityDescription,
):
"""Describes a weatherflow sensor."""
value_fn: Callable[[WebsocketObservation], StateType | datetime]
WEBSOCKET_WIND_SENSORS: tuple[
WeatherFlowCloudSensorEntityDescriptionWebsocketWind, ...
] = (
WeatherFlowCloudSensorEntityDescriptionWebsocketWind(
key="wind_speed",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_display_precision=1,
value_fn=lambda data: data.wind_speed_meters_per_second,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
),
WeatherFlowCloudSensorEntityDescriptionWebsocketWind(
key="wind_direction",
device_class=SensorDeviceClass.WIND_DIRECTION,
translation_key="wind_direction",
value_fn=lambda data: data.wind_direction_degrees,
native_unit_of_measurement="°",
),
)
WEBSOCKET_OBSERVATION_SENSORS: tuple[
WeatherFlowCloudSensorEntityDescriptionWebsocketObservation, ...
] = (
WeatherFlowCloudSensorEntityDescriptionWebsocketObservation(
key="wind_lull",
translation_key="wind_lull",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_display_precision=1,
value_fn=lambda data: data.wind_lull,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
),
WeatherFlowCloudSensorEntityDescriptionWebsocketObservation(
key="wind_gust",
translation_key="wind_gust",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_display_precision=1,
value_fn=lambda data: data.wind_gust,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
),
WeatherFlowCloudSensorEntityDescriptionWebsocketObservation(
key="wind_avg",
translation_key="wind_avg",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_display_precision=1,
value_fn=lambda data: data.wind_avg,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
),
WeatherFlowCloudSensorEntityDescriptionWebsocketObservation(
key="wind_sample_interval",
translation_key="wind_sample_interval",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.wind_sample_interval,
),
)
WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = (
# Air Sensors
WeatherFlowCloudSensorEntityDescription(
@ -176,35 +272,133 @@ async def async_setup_entry(
) -> None:
"""Set up WeatherFlow sensors based on a config entry."""
coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][entry.entry_id]
rest_coordinator = coordinators.rest
wind_coordinator = coordinators.wind # Now properly typed
observation_coordinator = coordinators.observation # Now properly typed
entities: list[SensorEntity] = [
WeatherFlowCloudSensorREST(rest_coordinator, sensor_description, station_id)
for station_id in rest_coordinator.data
for sensor_description in WF_SENSORS
]
async_add_entities(
WeatherFlowCloudSensor(coordinator, sensor_description, station_id)
for station_id in coordinator.data
for sensor_description in WF_SENSORS
entities.extend(
WeatherFlowWebsocketSensorWind(
coordinator=wind_coordinator,
description=sensor_description,
station_id=station_id,
device_id=device_id,
)
for station_id in wind_coordinator.stations.station_outdoor_device_map
for device_id in wind_coordinator.stations.station_outdoor_device_map[
station_id
]
for sensor_description in WEBSOCKET_WIND_SENSORS
)
entities.extend(
WeatherFlowWebsocketSensorObservation(
coordinator=observation_coordinator,
description=sensor_description,
station_id=station_id,
device_id=device_id,
)
for station_id in observation_coordinator.stations.station_outdoor_device_map
for device_id in observation_coordinator.stations.station_outdoor_device_map[
station_id
]
for sensor_description in WEBSOCKET_OBSERVATION_SENSORS
)
async_add_entities(entities)
class WeatherFlowCloudSensor(WeatherFlowCloudEntity, SensorEntity):
"""Implementation of a WeatherFlow sensor."""
entity_description: WeatherFlowCloudSensorEntityDescription
class WeatherFlowSensorBase(WeatherFlowCloudEntity, SensorEntity, ABC):
"""Common base class."""
def __init__(
self,
coordinator: WeatherFlowCloudDataUpdateCoordinator,
description: WeatherFlowCloudSensorEntityDescription,
coordinator: (
WeatherFlowCloudUpdateCoordinatorREST
| WeatherFlowWindCoordinator
| WeatherFlowObservationCoordinator
),
description: (
WeatherFlowCloudSensorEntityDescription
| WeatherFlowCloudSensorEntityDescriptionWebsocketWind
| WeatherFlowCloudSensorEntityDescriptionWebsocketObservation
),
station_id: int,
device_id: int | None = None,
) -> None:
"""Initialize the sensor."""
# Initialize the Entity Class
"""Initialize a sensor."""
super().__init__(coordinator, station_id)
self.station_id = station_id
self.device_id = device_id
self.entity_description = description
self._attr_unique_id = f"{station_id}_{description.key}"
self._attr_unique_id = self._generate_unique_id()
def _generate_unique_id(self) -> str:
"""Generate a unique ID for the sensor."""
if self.device_id is not None:
return f"{self.station_id}_{self.device_id}_{self.entity_description.key}"
return f"{self.station_id}_{self.entity_description.key}"
@property
def available(self) -> bool:
"""Get if available."""
if not super().available:
return False
if self.device_id is not None:
# Websocket sensors - have Device IDs
return bool(
self.coordinator.data
and self.coordinator.data[self.station_id][self.device_id] is not None
)
return True
class WeatherFlowWebsocketSensorObservation(WeatherFlowSensorBase):
"""Class for Websocket Observations."""
entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketObservation
@property
def native_value(self) -> StateType | date | datetime | Decimal:
"""Return the native value."""
data = self.coordinator.data[self.station_id][self.device_id]
return self.entity_description.value_fn(data)
class WeatherFlowWebsocketSensorWind(WeatherFlowSensorBase):
"""Class for wind over websockets."""
entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketWind
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.station.observation.obs[0])
"""Return the native value."""
# This data is often invalid at starutp.
if self.coordinator.data is not None:
data = self.coordinator.data[self.station_id][self.device_id]
return self.entity_description.value_fn(data)
return None
class WeatherFlowCloudSensorREST(WeatherFlowSensorBase):
"""Class for a REST based sensor."""
entity_description: WeatherFlowCloudSensorEntityDescription
coordinator: WeatherFlowCloudUpdateCoordinatorREST
@property
def native_value(self) -> StateType | datetime:
"""Return the native value."""
return self.entity_description.value_fn(
self.coordinator.data[self.station_id].observation.obs[0]
)

View File

@ -32,13 +32,15 @@
"barometric_pressure": {
"name": "Pressure barometric"
},
"sea_level_pressure": {
"name": "Pressure sea level"
},
"dew_point": {
"name": "Dew point"
},
"feels_like": {
"name": "Feels like"
},
"heat_index": {
"name": "Heat index"
},
"lightning_strike_count": {
"name": "Lightning count"
},
@ -54,33 +56,32 @@
"lightning_strike_last_epoch": {
"name": "Lightning last strike"
},
"sea_level_pressure": {
"name": "Pressure sea level"
},
"wet_bulb_globe_temperature": {
"name": "Wet bulb globe temperature"
},
"wet_bulb_temperature": {
"name": "Wet bulb temperature"
},
"wind_avg": {
"name": "Wind speed (avg)"
},
"wind_chill": {
"name": "Wind chill"
},
"wind_direction": {
"name": "Wind direction"
},
"wind_direction_cardinal": {
"name": "Wind direction (cardinal)"
},
"wind_gust": {
"name": "Wind gust"
},
"wind_lull": {
"name": "Wind lull"
},
"feels_like": {
"name": "Feels like"
},
"heat_index": {
"name": "Heat index"
},
"wet_bulb_temperature": {
"name": "Wet bulb temperature"
},
"wet_bulb_globe_temperature": {
"name": "Wet bulb globe temperature"
"wind_sample_interval": {
"name": "Wind sample interval"
}
}
}

View File

@ -19,8 +19,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators
from .const import DOMAIN, STATE_MAP
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
from .entity import WeatherFlowCloudEntity
@ -30,21 +30,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
[
WeatherFlowWeather(coordinator, station_id=station_id)
for station_id, data in coordinator.data.items()
WeatherFlowWeatherREST(coordinators.rest, station_id=station_id)
for station_id, data in coordinators.rest.data.items()
]
)
class WeatherFlowWeather(
class WeatherFlowWeatherREST(
WeatherFlowCloudEntity,
SingleCoordinatorWeatherEntity[WeatherFlowCloudDataUpdateCoordinator],
SingleCoordinatorWeatherEntity[WeatherFlowCloudUpdateCoordinatorREST],
):
"""Implementation of a WeatherFlow weather condition."""
@ -59,7 +57,7 @@ class WeatherFlowWeather(
def __init__(
self,
coordinator: WeatherFlowCloudDataUpdateCoordinator,
coordinator: WeatherFlowCloudUpdateCoordinatorREST,
station_id: int,
) -> None:
"""Initialise the platform with a data instance and station."""

View File

@ -1,14 +1,16 @@
"""Common fixtures for the WeatherflowCloud tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aiohttp import ClientResponseError
import pytest
from weatherflow4py.api import WeatherFlowRestAPI
from weatherflow4py.models.rest.forecast import WeatherDataForecastREST
from weatherflow4py.models.rest.observation import ObservationStationREST
from weatherflow4py.models.rest.stations import StationsResponseREST
from weatherflow4py.models.rest.unified import WeatherFlowDataREST
from weatherflow4py.ws import WeatherFlowWebsocketAPI
from homeassistant.components.weatherflow_cloud.const import DOMAIN
from homeassistant.const import CONF_API_TOKEN
@ -81,35 +83,88 @@ async def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
def mock_api():
"""Fixture for Mock WeatherFlowRestAPI."""
get_stations_response_data = StationsResponseREST.from_json(
load_fixture("stations.json", DOMAIN)
)
get_forecast_response_data = WeatherDataForecastREST.from_json(
load_fixture("forecast.json", DOMAIN)
)
get_observation_response_data = ObservationStationREST.from_json(
load_fixture("station_observation.json", DOMAIN)
)
def mock_rest_api():
"""Mock rest api."""
fixtures = {
"stations": StationsResponseREST.from_json(
load_fixture("stations.json", DOMAIN)
),
"forecast": WeatherDataForecastREST.from_json(
load_fixture("forecast.json", DOMAIN)
),
"observation": ObservationStationREST.from_json(
load_fixture("station_observation.json", DOMAIN)
),
}
# Create device_station_map
device_station_map = {
device.device_id: station.station_id
for station in fixtures["stations"].stations
for device in station.devices
}
# Prepare mock data
data = {
24432: WeatherFlowDataREST(
weather=get_forecast_response_data,
observation=get_observation_response_data,
station=get_stations_response_data.stations[0],
weather=fixtures["forecast"],
observation=fixtures["observation"],
station=fixtures["stations"].stations[0],
device_observations=None,
)
}
with patch(
"homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI",
autospec=True,
) as mock_api_class:
# Create an instance of AsyncMock for the API
mock_api = AsyncMock()
mock_api.get_all_data.return_value = data
# Patch the class to return our mock_api instance
mock_api_class.return_value = mock_api
mock_api = AsyncMock(spec=WeatherFlowRestAPI)
mock_api.get_all_data.return_value = data
mock_api.async_get_stations.return_value = fixtures["stations"]
mock_api.device_station_map = device_station_map
mock_api.api_token = MOCK_API_TOKEN
# Apply patches
with (
patch(
"homeassistant.components.weatherflow_cloud.WeatherFlowRestAPI",
return_value=mock_api,
) as _,
patch(
"homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI",
return_value=mock_api,
) as _,
):
yield mock_api
@pytest.fixture
def mock_stations_data(mock_rest_api):
"""Mock stations data for coordinator tests."""
return mock_rest_api.async_get_stations.return_value
@pytest.fixture
async def mock_websocket_api():
"""Mock WeatherFlowWebsocketAPI."""
mock_websocket = AsyncMock()
mock_websocket.send = AsyncMock()
mock_websocket.recv = AsyncMock()
mock_ws_instance = AsyncMock(spec=WeatherFlowWebsocketAPI)
mock_ws_instance.connect = AsyncMock()
mock_ws_instance.send_message = AsyncMock()
mock_ws_instance.register_callback = MagicMock()
mock_ws_instance.websocket = mock_websocket
with (
patch(
"homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowWebsocketAPI",
return_value=mock_ws_instance,
),
patch(
"homeassistant.components.weatherflow_cloud.WeatherFlowWebsocketAPI",
return_value=mock_ws_instance,
),
patch(
"weatherflow4py.ws.WeatherFlowWebsocketAPI", return_value=mock_ws_instance
),
):
# mock_connect.return_value = mock_websocket
yield mock_ws_instance

View File

@ -42,7 +42,7 @@
# name: test_all_entities[sensor.my_home_station_air_density-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'friendly_name': 'My Home Station Air density',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'kg/m³',
@ -98,7 +98,7 @@
# name: test_all_entities[sensor.my_home_station_dew_point-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Dew point',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -155,7 +155,7 @@
# name: test_all_entities[sensor.my_home_station_feels_like-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Feels like',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -212,7 +212,7 @@
# name: test_all_entities[sensor.my_home_station_heat_index-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Heat index',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -266,7 +266,7 @@
# name: test_all_entities[sensor.my_home_station_lightning_count-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'friendly_name': 'My Home Station Lightning count',
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
@ -318,7 +318,7 @@
# name: test_all_entities[sensor.my_home_station_lightning_count_last_1_hr-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'friendly_name': 'My Home Station Lightning count last 1 hr',
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
@ -370,7 +370,7 @@
# name: test_all_entities[sensor.my_home_station_lightning_count_last_3_hr-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'friendly_name': 'My Home Station Lightning count last 3 hr',
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
@ -425,7 +425,7 @@
# name: test_all_entities[sensor.my_home_station_lightning_last_distance-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'distance',
'friendly_name': 'My Home Station Lightning last distance',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -477,7 +477,7 @@
# name: test_all_entities[sensor.my_home_station_lightning_last_strike-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'timestamp',
'friendly_name': 'My Home Station Lightning last strike',
}),
@ -535,7 +535,7 @@
# name: test_all_entities[sensor.my_home_station_pressure_barometric-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'atmospheric_pressure',
'friendly_name': 'My Home Station Pressure barometric',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -595,7 +595,7 @@
# name: test_all_entities[sensor.my_home_station_pressure_sea_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'atmospheric_pressure',
'friendly_name': 'My Home Station Pressure sea level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -652,7 +652,7 @@
# name: test_all_entities[sensor.my_home_station_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -709,7 +709,7 @@
# name: test_all_entities[sensor.my_home_station_wet_bulb_globe_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Wet bulb globe temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -766,7 +766,7 @@
# name: test_all_entities[sensor.my_home_station_wet_bulb_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Wet bulb temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -823,7 +823,7 @@
# name: test_all_entities[sensor.my_home_station_wind_chill-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'temperature',
'friendly_name': 'My Home Station Wind chill',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
@ -837,3 +837,350 @@
'state': '10.5',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_direction-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_home_station_wind_direction',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.WIND_DIRECTION: 'wind_direction'>,
'original_icon': None,
'original_name': 'Wind direction',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_direction',
'unique_id': '24432_123456_wind_direction',
'unit_of_measurement': '°',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_direction-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'wind_direction',
'friendly_name': 'My Home Station Wind direction',
'unit_of_measurement': '°',
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_direction',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_gust-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_home_station_wind_gust',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind gust',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_gust',
'unique_id': '24432_123456_wind_gust',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_gust-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'wind_speed',
'friendly_name': 'My Home Station Wind gust',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_gust',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_lull-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_home_station_wind_lull',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind lull',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_lull',
'unique_id': '24432_123456_wind_lull',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_lull-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'wind_speed',
'friendly_name': 'My Home Station Wind lull',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_lull',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_sample_interval-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.my_home_station_wind_sample_interval',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Wind sample interval',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_sample_interval',
'unique_id': '24432_123456_wind_sample_interval',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_sample_interval-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'friendly_name': 'My Home Station Wind sample interval',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_sample_interval',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_home_station_wind_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind speed',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '24432_123456_wind_speed',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'wind_speed',
'friendly_name': 'My Home Station Wind speed',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_speed_avg-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_home_station_wind_speed_avg',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
}),
'original_device_class': <SensorDeviceClass.WIND_SPEED: 'wind_speed'>,
'original_icon': None,
'original_name': 'Wind speed (avg)',
'platform': 'weatherflow_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wind_avg',
'unique_id': '24432_123456_wind_avg',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_all_entities[sensor.my_home_station_wind_speed_avg-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'device_class': 'wind_speed',
'friendly_name': 'My Home Station Wind speed (avg)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_home_station_wind_speed_avg',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---

View File

@ -37,7 +37,7 @@
# name: test_weather[weather.my_home_station-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api',
'attribution': 'Weather data delivered by WeatherFlow/Tempest API',
'dew_point': -13.0,
'friendly_name': 'My Home Station',
'humidity': 27,

View File

@ -0,0 +1,223 @@
"""Tests for the WeatherFlow Cloud coordinators."""
from unittest.mock import AsyncMock, Mock
from aiohttp import ClientResponseError
import pytest
from weatherflow4py.models.ws.types import EventType
from weatherflow4py.models.ws.websocket_request import (
ListenStartMessage,
RapidWindListenStartMessage,
)
from weatherflow4py.models.ws.websocket_response import (
EventDataRapidWind,
ObservationTempestWS,
RapidWindWS,
)
from homeassistant.components.weatherflow_cloud.coordinator import (
WeatherFlowCloudUpdateCoordinatorREST,
WeatherFlowObservationCoordinator,
WeatherFlowWindCoordinator,
)
from homeassistant.config_entries import ConfigEntryAuthFailed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import UpdateFailed
from tests.common import MockConfigEntry
async def test_wind_coordinator_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test wind coordinator setup."""
coordinator = WeatherFlowWindCoordinator(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
websocket_api=mock_websocket_api,
stations=mock_stations_data,
)
await coordinator.async_setup()
# Verify websocket setup
mock_websocket_api.connect.assert_called_once()
mock_websocket_api.register_callback.assert_called_once_with(
message_type=EventType.RAPID_WIND,
callback=coordinator._handle_websocket_message,
)
# In the refactored code, send_message is called for each device ID
assert mock_websocket_api.send_message.called
# Verify at least one message is of the correct type
call_args_list = mock_websocket_api.send_message.call_args_list
assert any(
isinstance(call.args[0], RapidWindListenStartMessage) for call in call_args_list
)
async def test_observation_coordinator_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test observation coordinator setup."""
coordinator = WeatherFlowObservationCoordinator(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
websocket_api=mock_websocket_api,
stations=mock_stations_data,
)
await coordinator.async_setup()
# Verify websocket setup
mock_websocket_api.connect.assert_called_once()
mock_websocket_api.register_callback.assert_called_once_with(
message_type=EventType.OBSERVATION,
callback=coordinator._handle_websocket_message,
)
# In the refactored code, send_message is called for each device ID
assert mock_websocket_api.send_message.called
# Verify at least one message is of the correct type
call_args_list = mock_websocket_api.send_message.call_args_list
assert any(isinstance(call.args[0], ListenStartMessage) for call in call_args_list)
async def test_wind_coordinator_message_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test wind coordinator message handling."""
coordinator = WeatherFlowWindCoordinator(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
websocket_api=mock_websocket_api,
stations=mock_stations_data,
)
# Create mock wind data
mock_wind_data = Mock(spec=EventDataRapidWind)
mock_message = Mock(spec=RapidWindWS)
# Use a device ID from the actual mock data
# The first device from the first station in the mock data
device_id = mock_stations_data.stations[0].devices[0].device_id
station_id = mock_stations_data.stations[0].station_id
mock_message.device_id = device_id
mock_message.ob = mock_wind_data
# Handle the message
await coordinator._handle_websocket_message(mock_message)
# Verify data was stored correctly
assert coordinator._ws_data[station_id][device_id] == mock_wind_data
async def test_observation_coordinator_message_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test observation coordinator message handling."""
coordinator = WeatherFlowObservationCoordinator(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
websocket_api=mock_websocket_api,
stations=mock_stations_data,
)
# Create mock observation data
mock_message = Mock(spec=ObservationTempestWS)
# Use a device ID from the actual mock data
# The first device from the first station in the mock data
device_id = mock_stations_data.stations[0].devices[0].device_id
station_id = mock_stations_data.stations[0].station_id
mock_message.device_id = device_id
# Handle the message
await coordinator._handle_websocket_message(mock_message)
# Verify data was stored correctly (for observations, the message IS the data)
assert coordinator._ws_data[station_id][device_id] == mock_message
async def test_rest_coordinator_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test REST coordinator handling of 401 auth error."""
# Create the coordinator
coordinator = WeatherFlowCloudUpdateCoordinatorREST(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
stations=mock_stations_data,
)
# Mock a 401 auth error
mock_rest_api.get_all_data.side_effect = ClientResponseError(
request_info=Mock(),
history=Mock(),
status=401,
message="Unauthorized",
)
# Verify the error is properly converted to ConfigEntryAuthFailed
with pytest.raises(ConfigEntryAuthFailed):
await coordinator._async_update_data()
async def test_rest_coordinator_other_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_stations_data: Mock,
) -> None:
"""Test REST coordinator handling of non-auth errors."""
# Create the coordinator
coordinator = WeatherFlowCloudUpdateCoordinatorREST(
hass=hass,
config_entry=mock_config_entry,
rest_api=mock_rest_api,
stations=mock_stations_data,
)
# Mock a 500 server error
mock_rest_api.get_all_data.side_effect = ClientResponseError(
request_info=Mock(),
history=Mock(),
status=500,
message="Internal Server Error",
)
# Verify the error is properly converted to UpdateFailed
with pytest.raises(
UpdateFailed, match="Update failed: 500, message='Internal Server Error'"
):
await coordinator._async_update_data()

View File

@ -1,13 +1,22 @@
"""Tests for the WeatherFlow Cloud sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from weatherflow4py.models.rest.observation import ObservationStationREST
from homeassistant.components.weatherflow_cloud import DOMAIN
from homeassistant.components.weatherflow_cloud.coordinator import (
WeatherFlowObservationCoordinator,
WeatherFlowWindCoordinator,
)
from homeassistant.components.weatherflow_cloud.sensor import (
WeatherFlowWebsocketSensorObservation,
WeatherFlowWebsocketSensorWind,
)
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -17,17 +26,19 @@ from . import setup_integration
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
async_load_fixture,
load_fixture,
snapshot_platform,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_api: AsyncMock,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
) -> None:
"""Test all entities."""
with patch(
@ -38,17 +49,19 @@ async def test_all_entities(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities_with_lightning_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_api: AsyncMock,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test all entities."""
get_observation_response_data = ObservationStationREST.from_json(
await async_load_fixture(hass, "station_observation_error.json", DOMAIN)
load_fixture("station_observation_error.json", DOMAIN)
)
with patch(
@ -62,9 +75,9 @@ async def test_all_entities_with_lightning_error(
)
# Update the data in our API
all_data = await mock_api.get_all_data()
all_data = await mock_rest_api.get_all_data()
all_data[24432].observation = get_observation_response_data
mock_api.get_all_data.return_value = all_data
mock_rest_api.get_all_data.return_value = all_data
# Move time forward
freezer.tick(timedelta(minutes=5))
@ -75,3 +88,92 @@ async def test_all_entities_with_lightning_error(
hass.states.get("sensor.my_home_station_lightning_last_strike").state
== STATE_UNKNOWN
)
async def test_websocket_sensor_observation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
) -> None:
"""Test the WebsocketSensorObservation class works."""
# Set up the integration
with patch(
"homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR]
):
await setup_integration(hass, mock_config_entry)
# Create a mock coordinator with test data
coordinator = MagicMock(spec=WeatherFlowObservationCoordinator)
# Mock the coordinator data structure
test_station_id = 24432
test_device_id = 12345
test_data = {
"temperature": 22.5,
"humidity": 45,
"pressure": 1013.2,
}
coordinator.data = {test_station_id: {test_device_id: test_data}}
# Create a sensor entity description
entity_description = MagicMock()
entity_description.value_fn = lambda data: data["temperature"]
# Create the sensor
sensor = WeatherFlowWebsocketSensorObservation(
coordinator=coordinator,
description=entity_description,
station_id=test_station_id,
device_id=test_device_id,
)
# Test that native_value returns the correct value
assert sensor.native_value == 22.5
async def test_websocket_sensor_wind(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_rest_api: AsyncMock,
mock_websocket_api: AsyncMock,
) -> None:
"""Test the WebsocketSensorWind class works."""
# Set up the integration
with patch(
"homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR]
):
await setup_integration(hass, mock_config_entry)
# Create a mock coordinator with test data
coordinator = MagicMock(spec=WeatherFlowWindCoordinator)
# Mock the coordinator data structure
test_station_id = 24432
test_device_id = 12345
test_data = {
"wind_speed": 5.2,
"wind_direction": 180,
}
coordinator.data = {test_station_id: {test_device_id: test_data}}
# Create a sensor entity description
entity_description = MagicMock()
entity_description.value_fn = lambda data: data["wind_speed"]
# Create the sensor
sensor = WeatherFlowWebsocketSensorWind(
coordinator=coordinator,
description=entity_description,
station_id=test_station_id,
device_id=test_device_id,
)
# Test that native_value returns the correct value
assert sensor.native_value == 5.2
# Test with None data (startup condition)
coordinator.data = None
assert sensor.native_value is None

View File

@ -18,7 +18,9 @@ async def test_weather(
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
mock_api: AsyncMock,
mock_rest_api: AsyncMock,
mock_get_stations: AsyncMock,
mock_websocket_api: AsyncMock,
) -> None:
"""Test all entities."""
with patch(