From 0dd8ffd1f541574ddf7835af89d63b934a1dc224 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Tue, 16 Apr 2024 00:46:15 -0700 Subject: [PATCH] Add a new "Ambient Weather Network" integration (#105779) * Adding a new "Ambient Weather Network" integration. * Rebase and update code coverage. * Addressed some reviewer comments. * Remove mnemonics and replace with station names. * Remove climate-utils * Remove support for virtual stations. * Rebase * Address feedback * Remove redundant errors * Reviewer feedback * Add icons.json * More icons * Reviewer feedback * Fix test * Make sensor tests more robust * Make coordinator more robust * Change update coordinator to raise UpdateFailed * Recover from no station found error * Dynamically set device name * Address feedback * Disable some sensors by default * Reviewer feedback * Change from hub to service * Rebase * Address reviewer feedback * Reviewer feedback * Manually rerun ruff on all files --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/ambient_network/__init__.py | 35 + .../components/ambient_network/config_flow.py | 152 ++++ .../components/ambient_network/const.py | 16 + .../components/ambient_network/coordinator.py | 65 ++ .../components/ambient_network/entity.py | 50 + .../components/ambient_network/helper.py | 31 + .../components/ambient_network/icons.json | 21 + .../components/ambient_network/manifest.json | 11 + .../components/ambient_network/sensor.py | 315 +++++++ .../components/ambient_network/strings.json | 87 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/ambient_network/__init__.py | 1 + tests/components/ambient_network/conftest.py | 91 ++ .../fixtures/device_details_response_a.json | 34 + .../fixtures/device_details_response_b.json | 7 + .../fixtures/device_details_response_c.json | 33 + .../devices_by_location_response.json | 364 ++++++++ .../snapshots/test_sensor.ambr | 856 ++++++++++++++++++ .../ambient_network/test_config_flow.py | 85 ++ .../components/ambient_network/test_sensor.py | 123 +++ 26 files changed, 2399 insertions(+) create mode 100644 homeassistant/components/ambient_network/__init__.py create mode 100644 homeassistant/components/ambient_network/config_flow.py create mode 100644 homeassistant/components/ambient_network/const.py create mode 100644 homeassistant/components/ambient_network/coordinator.py create mode 100644 homeassistant/components/ambient_network/entity.py create mode 100644 homeassistant/components/ambient_network/helper.py create mode 100644 homeassistant/components/ambient_network/icons.json create mode 100644 homeassistant/components/ambient_network/manifest.json create mode 100644 homeassistant/components/ambient_network/sensor.py create mode 100644 homeassistant/components/ambient_network/strings.json create mode 100644 tests/components/ambient_network/__init__.py create mode 100644 tests/components/ambient_network/conftest.py create mode 100644 tests/components/ambient_network/fixtures/device_details_response_a.json create mode 100644 tests/components/ambient_network/fixtures/device_details_response_b.json create mode 100644 tests/components/ambient_network/fixtures/device_details_response_c.json create mode 100644 tests/components/ambient_network/fixtures/devices_by_location_response.json create mode 100644 tests/components/ambient_network/snapshots/test_sensor.ambr create mode 100644 tests/components/ambient_network/test_config_flow.py create mode 100644 tests/components/ambient_network/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 63a867e9c50..5985938885f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -66,6 +66,7 @@ homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambiclimate.* +homeassistant.components.ambient_network.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* diff --git a/CODEOWNERS b/CODEOWNERS index d93a8f6b9d3..83d5539a15c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,8 @@ build.json @home-assistant/supervisor /tests/components/amberelectric/ @madpilot /homeassistant/components/ambiclimate/ @danielhiversen /tests/components/ambiclimate/ @danielhiversen +/homeassistant/components/ambient_network/ @thomaskistler +/tests/components/ambient_network/ @thomaskistler /homeassistant/components/ambient_station/ @bachya /tests/components/ambient_station/ @bachya /homeassistant/components/amcrest/ @flacjacket diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py new file mode 100644 index 00000000000..b286fb7fbc9 --- /dev/null +++ b/homeassistant/components/ambient_network/__init__.py @@ -0,0 +1,35 @@ +"""The Ambient Weather Network integration.""" + +from __future__ import annotations + +from aioambient.open_api import OpenAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Ambient Weather Network from a config entry.""" + + api = OpenAPI() + coordinator = AmbientNetworkDataUpdateCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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) + + return unload_ok diff --git a/homeassistant/components/ambient_network/config_flow.py b/homeassistant/components/ambient_network/config_flow.py new file mode 100644 index 00000000000..d29134db1c9 --- /dev/null +++ b/homeassistant/components/ambient_network/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from aioambient import OpenAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_MAC, + CONF_RADIUS, + UnitOfLength, +) +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import API_STATION_INDOOR, API_STATION_INFO, API_STATION_MAC_ADDRESS, DOMAIN +from .helper import get_station_name + +CONF_USER = "user" +CONF_STATION = "station" + +# One mile +CONF_RADIUS_DEFAULT = 1609.34 + + +class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the Ambient Weather Network integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Construct the config flow.""" + + self._longitude = 0.0 + self._latitude = 0.0 + self._radius = 0.0 + self._stations: dict[str, dict[str, Any]] = {} + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step to select the location.""" + + errors: dict[str, str] | None = None + if user_input: + self._latitude = user_input[CONF_LOCATION][CONF_LATITUDE] + self._longitude = user_input[CONF_LOCATION][CONF_LONGITUDE] + self._radius = user_input[CONF_LOCATION][CONF_RADIUS] + + client: OpenAPI = OpenAPI() + self._stations = { + x[API_STATION_MAC_ADDRESS]: x + for x in await client.get_devices_by_location( + self._latitude, + self._longitude, + radius=DistanceConverter.convert( + self._radius, + UnitOfLength.METERS, + UnitOfLength.MILES, + ), + ) + } + + # Filter out indoor stations + self._stations = dict( + filter( + lambda item: not item[1] + .get(API_STATION_INFO, {}) + .get(API_STATION_INDOOR, False), + self._stations.items(), + ) + ) + + if self._stations: + return await self.async_step_station() + + errors = {"base": "no_stations_found"} + + schema: vol.Schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_LOCATION, + ): LocationSelector(LocationSelectorConfig(radius=True)), + } + ), + { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: CONF_RADIUS_DEFAULT, + } + if not errors + else { + CONF_LATITUDE: self._latitude, + CONF_LONGITUDE: self._longitude, + CONF_RADIUS: self._radius, + } + }, + ) + + return self.async_show_form( + step_id=CONF_USER, data_schema=schema, errors=errors if errors else {} + ) + + async def async_step_station( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the second step to select the station.""" + + if user_input: + mac_address = user_input[CONF_STATION] + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=get_station_name(self._stations[mac_address]), + data={CONF_MAC: mac_address}, + ) + + options: list[SelectOptionDict] = [ + SelectOptionDict( + label=get_station_name(station), + value=mac_address, + ) + for mac_address, station in self._stations.items() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION): SelectSelector( + SelectSelectorConfig(options=options, multiple=False, sort=True), + ) + } + ) + + return self.async_show_form( + step_id=CONF_STATION, + data_schema=schema, + ) diff --git a/homeassistant/components/ambient_network/const.py b/homeassistant/components/ambient_network/const.py new file mode 100644 index 00000000000..402e5f81097 --- /dev/null +++ b/homeassistant/components/ambient_network/const.py @@ -0,0 +1,16 @@ +"""Constants for the Ambient Weather Network integration.""" + +import logging + +DOMAIN = "ambient_network" + +API_LAST_DATA = "lastData" +API_STATION_COORDS = "coords" +API_STATION_INDOOR = "indoor" +API_STATION_INFO = "info" +API_STATION_LOCATION = "location" +API_STATION_NAME = "name" +API_STATION_MAC_ADDRESS = "macAddress" +API_STATION_TYPE = "stationtype" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py new file mode 100644 index 00000000000..f26ddd47b24 --- /dev/null +++ b/homeassistant/components/ambient_network/coordinator.py @@ -0,0 +1,65 @@ +"""DataUpdateCoordinator for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, cast + +from aioambient import OpenAPI +from aioambient.errors import RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_LAST_DATA, DOMAIN, LOGGER +from .helper import get_station_name + +SCAN_INTERVAL = timedelta(minutes=5) + + +class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The Ambient Network Data Update Coordinator.""" + + config_entry: ConfigEntry + station_name: str + + def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None: + """Initialize the coordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the Ambient Network.""" + + try: + response = await self.api.get_device_details( + self.config_entry.data[CONF_MAC] + ) + except RequestError as ex: + raise UpdateFailed("Cannot connect to Ambient Network") from ex + + self.station_name = get_station_name(response) + + if (last_data := response.get(API_LAST_DATA)) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report any data" + ) + + # Eliminate data if the station hasn't been updated for a while. + if (created_at := last_data.get("created_at")) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report a time stamp" + ) + + # Eliminate data that has been generated more than an hour ago. The station is + # probably offline. + if int(created_at / 1000) < int( + (datetime.now() - timedelta(hours=1)).timestamp() + ): + raise UpdateFailed( + f"Station '{self.config_entry.title}' reported stale data" + ) + + return cast(dict[str, Any], last_data) diff --git a/homeassistant/components/ambient_network/entity.py b/homeassistant/components/ambient_network/entity.py new file mode 100644 index 00000000000..ad0241ea3de --- /dev/null +++ b/homeassistant/components/ambient_network/entity.py @@ -0,0 +1,50 @@ +"""Base entity class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + + +class AmbientNetworkEntity(CoordinatorEntity[AmbientNetworkDataUpdateCoordinator]): + """Entity class for Ambient network devices.""" + + _attr_attribution = "Data provided by ambientnetwork.net" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: EntityDescription, + mac_address: str, + ) -> None: + """Initialize the Ambient network entity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{mac_address}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.station_name, + identifiers={(DOMAIN, mac_address)}, + manufacturer="Ambient Weather", + ) + self._update_attrs() + + @abstractmethod + def _update_attrs(self) -> None: + """Update state attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/ambient_network/helper.py b/homeassistant/components/ambient_network/helper.py new file mode 100644 index 00000000000..fbde45ee756 --- /dev/null +++ b/homeassistant/components/ambient_network/helper.py @@ -0,0 +1,31 @@ +"""Helper class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from .const import ( + API_LAST_DATA, + API_STATION_COORDS, + API_STATION_INFO, + API_STATION_LOCATION, + API_STATION_NAME, + API_STATION_TYPE, +) + + +def get_station_name(station: dict[str, Any]) -> str: + """Pick a station name. + + Station names can be empty, in which case we construct the name from + the location and device type. + """ + if name := station.get(API_STATION_INFO, {}).get(API_STATION_NAME): + return str(name) + location = ( + station.get(API_STATION_INFO, {}) + .get(API_STATION_COORDS, {}) + .get(API_STATION_LOCATION) + ) + station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE) + return f"{location}{'' if location is None or station_type is None else ' '}{station_type}" diff --git a/homeassistant/components/ambient_network/icons.json b/homeassistant/components/ambient_network/icons.json new file mode 100644 index 00000000000..a7abebce187 --- /dev/null +++ b/homeassistant/components/ambient_network/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "last_rain": { + "default": "mdi:water" + }, + "lightning_strikes_per_day": { + "default": "mdi:lightning-bolt" + }, + "lightning_strikes_per_hour": { + "default": "mdi:lightning-bolt" + }, + "lightning_distance": { + "default": "mdi:lightning-bolt" + }, + "wind_direction": { + "default": "mdi:compass-outline" + } + } + } +} diff --git a/homeassistant/components/ambient_network/manifest.json b/homeassistant/components/ambient_network/manifest.json new file mode 100644 index 00000000000..553adb240b0 --- /dev/null +++ b/homeassistant/components/ambient_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ambient_network", + "name": "Ambient Weather Network", + "codeowners": ["@thomaskistler"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambient_network", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aioambient"], + "requirements": ["aioambient==2024.01.0"] +} diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py new file mode 100644 index 00000000000..c28b69229d8 --- /dev/null +++ b/homeassistant/components/ambient_network/sensor.py @@ -0,0 +1,315 @@ +"""Support for Ambient Weather Network sensors.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONF_MAC, + DEGREE, + PERCENTAGE, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator +from .entity import AmbientNetworkEntity + +TYPE_AQI_PM25 = "aqi_pm25" +TYPE_AQI_PM25_24H = "aqi_pm25_24h" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_LASTRAIN = "lastRain" +TYPE_LIGHTNING_DISTANCE = "lightning_distance" +TYPE_LIGHTNING_PER_DAY = "lightning_day" +TYPE_LIGHTNING_PER_HOUR = "lightning_hour" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_SOLARRADIATION = "solarradiation" +TYPE_TEMPF = "tempf" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_AQI_PM25, + translation_key="pm25_aqi", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key=TYPE_AQI_PM25_24H, + translation_key="pm25_aqi_24h_average", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + translation_key="absolute_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + translation_key="relative_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + translation_key="daily_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + translation_key="hourly_rain", + native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + translation_key="last_rain", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_DAY, + translation_key="lightning_strikes_per_day", + native_unit_of_measurement="strikes", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_HOUR, + translation_key="lightning_strikes_per_hour", + native_unit_of_measurement="strikes/hour", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_DISTANCE, + translation_key="lightning_distance", + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + translation_key="max_daily_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + translation_key="monthly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + translation_key="pm25_24h_average", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_UV, + translation_key="uv_index", + native_unit_of_measurement="index", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + translation_key="weekly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + translation_key="wind_direction", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + translation_key="wind_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + translation_key="yearly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ambient Network sensor entities.""" + + coordinator: AmbientNetworkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator.config_entry is not None: + async_add_entities( + AmbientNetworkSensor( + coordinator, + description, + coordinator.config_entry.data[CONF_MAC], + ) + for description in SENSOR_DESCRIPTIONS + if coordinator.data.get(description.key) is not None + ) + + +class AmbientNetworkSensor(AmbientNetworkEntity, SensorEntity): + """A sensor implementation for an Ambient Weather Network sensor.""" + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: SensorEntityDescription, + mac_address: str, + ) -> None: + """Initialize a sensor object.""" + + super().__init__(coordinator, description, mac_address) + + def _update_attrs(self) -> None: + """Update sensor attributes.""" + + value = self.coordinator.data.get(self.entity_description.key) + + # Treatments for special units. + if value is not None and self.device_class == SensorDeviceClass.TIMESTAMP: + value = datetime.fromtimestamp(value / 1000, tz=dt_util.DEFAULT_TIME_ZONE) + + self._attr_available = value is not None + self._attr_native_value = value diff --git a/homeassistant/components/ambient_network/strings.json b/homeassistant/components/ambient_network/strings.json new file mode 100644 index 00000000000..7d18c40d902 --- /dev/null +++ b/homeassistant/components/ambient_network/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "step": { + "user": { + "title": "Select region", + "description": "Choose the region you want to survey in order to locate Ambient personal weather stations." + }, + "station": { + "title": "Select station", + "description": "Select the weather station you want to add to Home Assistant.", + "data": { + "station": "Station" + } + } + }, + "error": { + "no_stations_found": "Did not find any stations in the selected region." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "pm25_24h_average": { + "name": "PM2.5 (24 hour average)" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm25_aqi_24h_average": { + "name": "PM2.5 AQI (24 hour average)" + }, + "absolute_pressure": { + "name": "Absolute pressure" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "daily_rain": { + "name": "Daily rain" + }, + "dew_point": { + "name": "Dew point" + }, + "feels_like": { + "name": "Feels like" + }, + "hourly_rain": { + "name": "Hourly rain" + }, + "last_rain": { + "name": "Last rain" + }, + "lightning_strikes_per_day": { + "name": "Lightning strikes per day" + }, + "lightning_strikes_per_hour": { + "name": "Lightning strikes per hour" + }, + "lightning_distance": { + "name": "Lightning distance" + }, + "max_daily_gust": { + "name": "Max daily gust" + }, + "monthly_rain": { + "name": "Monthly rain" + }, + "uv_index": { + "name": "UV index" + }, + "weekly_rain": { + "name": "Weekly rain" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "yearly_rain": { + "name": "Yearly rain" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d1fe540c1b4..30d580ad1ea 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -42,6 +42,7 @@ FLOWS = { "alarmdecoder", "amberelectric", "ambiclimate", + "ambient_network", "ambient_station", "analytics_insights", "android_ip_webcam", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1b964ceae34..fa2cec4d77a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -244,6 +244,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ambient_network": { + "name": "Ambient Weather Network", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ambient_station": { "name": "Ambient Weather Station", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 546ae52f972..216d43322a4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -421,6 +421,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambient_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 92c2533dc4d..64d67ada712 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,6 +190,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 216edd0c5da..d9fd0586fa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,6 +169,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/tests/components/ambient_network/__init__.py b/tests/components/ambient_network/__init__.py new file mode 100644 index 00000000000..2971b77ddd8 --- /dev/null +++ b/tests/components/ambient_network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ambient Weather Network integration.""" diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py new file mode 100644 index 00000000000..3afadbfa722 --- /dev/null +++ b/tests/components/ambient_network/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Ambient Weather Network integration tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components import ambient_network +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ambient_network.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="devices_by_location", scope="package") +def devices_by_location_fixture() -> list[dict[str, Any]]: + """Return result of OpenAPI get_devices_by_location() call.""" + return load_json_array_fixture( + "devices_by_location_response.json", "ambient_network" + ) + + +def mock_device_details_callable(mac_address: str) -> dict[str, Any]: + """Return result of OpenAPI get_device_details() call.""" + return load_json_object_fixture( + f"device_details_response_{mac_address[0].lower()}.json", "ambient_network" + ) + + +@pytest.fixture(name="open_api") +def mock_open_api() -> OpenAPI: + """Mock OpenAPI object.""" + return Mock( + get_device_details=AsyncMock(side_effect=mock_device_details_callable), + ) + + +@pytest.fixture(name="aioambient") +async def mock_aioambient(open_api: OpenAPI): + """Mock aioambient library.""" + with ( + patch( + "homeassistant.components.ambient_network.config_flow.OpenAPI", + return_value=open_api, + ), + patch( + "homeassistant.components.ambient_network.OpenAPI", + return_value=open_api, + ), + ): + yield + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(request) -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=ambient_network.DOMAIN, + title=f"Station {request.param[0]}", + data={"mac": request.param}, + ) + + +async def setup_platform( + expected_outcome: bool, + hass: HomeAssistant, + config_entry: MockConfigEntry, +): + """Load the Ambient Network integration with the provided OpenAPI and config entry.""" + + config_entry.add_to_hass(hass) + assert ( + await hass.config_entries.async_setup(config_entry.entry_id) == expected_outcome + ) + await hass.async_block_till_done() + + return diff --git a/tests/components/ambient_network/fixtures/device_details_response_a.json b/tests/components/ambient_network/fixtures/device_details_response_a.json new file mode 100644 index 00000000000..40491e2631c --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_a.json @@ -0,0 +1,34 @@ +{ + "_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_b.json b/tests/components/ambient_network/fixtures/device_details_response_b.json new file mode 100644 index 00000000000..8249f6f0c30 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_b.json @@ -0,0 +1,7 @@ +{ + "_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "info": { + "name": "Station B" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_c.json b/tests/components/ambient_network/fixtures/device_details_response_c.json new file mode 100644 index 00000000000..8e171f35374 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_c.json @@ -0,0 +1,33 @@ +{ + "_id": "cccccccccccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station C" + } +} diff --git a/tests/components/ambient_network/fixtures/devices_by_location_response.json b/tests/components/ambient_network/fixtures/devices_by_location_response.json new file mode 100644 index 00000000000..848ba0a7b87 --- /dev/null +++ b/tests/components/ambient_network/fixtures/devices_by_location_response.json @@ -0,0 +1,364 @@ +[ + { + "_id": "aaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 237.0, + "location": "Location A", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "aaaaaaaaaaaaaaaaaaaaaaaa" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "bbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "lastData": { + "stationtype": "AMBWeatherV4.2.6", + "dateutc": 1700716980000, + "baromrelin": 29.342, + "baromabsin": 29.342, + "tempf": 35.8, + "humidity": 88, + "winddir": 237, + "winddir_avg10m": 221, + "windspeedmph": 0, + "windspdmph_avg10m": 0, + "windgustmph": 1.3, + "maxdailygust": 12.3, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.024, + "monthlyrainin": 0.331, + "yearlyrainin": 12.382, + "solarradiation": 0, + "uv": 0, + "soilhum2": 0, + "type": "weather-data", + "created_at": 1700717004020, + "dateutc5": 1700716800000, + "lastRain": 1700445000000, + "discreets": { + "humidity1": [41, 42, 43] + }, + "tz": "America/Chicago" + }, + "info": { + "name": "Station B", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location B", + "elevation": 226.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "bbbbbbbbbbbbbbbbbbbbbbbb" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "cccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": {}, + "info": { + "name": "Station C", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 242.0, + "location": "Location C", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "cccccccccccccccccccccccc" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "dddddddddddddddddddddddd", + "macAddress": "DD:DD:DD:DD:DD:DD", + "lastData": { + "stationtype": "AMBWeatherPro_V5.1.3", + "dateutc": 1700716920000, + "tempf": 38.1, + "humidity": 85, + "windspeedmph": 0, + "windgustmph": 0, + "maxdailygust": 0, + "winddir": 89, + "uv": 0, + "solarradiation": 0, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.028, + "monthlyrainin": 0.327, + "yearlyrainin": 12.76, + "totalrainin": 12.76, + "baromrelin": 29.731, + "baromabsin": 29.338, + "type": "weather-data", + "created_at": 1700716969446, + "dateutc5": 1700716800000, + "lastRain": 1700449500000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station D", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "address": "", + "location": "Location D", + "elevation": 221.0, + "address_components": [ + { + "long_name": "1234", + "short_name": "1234", + "types": ["street_number"] + }, + { + "long_name": "D Street", + "short_name": "D St.", + "types": ["route"] + }, + { + "long_name": "D Town", + "short_name": "D Town", + "types": ["locality", "political"] + }, + { + "long_name": "D County", + "short_name": "D County", + "types": ["administrative_area_level_2", "political"] + }, + { + "long_name": "Delaware", + "short_name": "DE", + "types": ["administrative_area_level_1", "political"] + }, + { + "long_name": "United States", + "short_name": "US", + "types": ["country", "political"] + }, + { + "long_name": "12345", + "short_name": "12345", + "types": ["postal_code"] + } + ], + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "dddddddddddddddddddddddd" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "eeeeeeeeeeeeeeeeeeeeeeee", + "macAddress": "EE:EE:EE:EE:EE:EE", + "lastData": { + "stationtype": "AMBWeatherV4.3.4", + "dateutc": 1700716920000, + "baromrelin": 29.238, + "baromabsin": 29.238, + "tempf": 45, + "humidity": 55, + "winddir": 98, + "winddir_avg10m": 185, + "windspeedmph": 1.1, + "windspdmph_avg10m": 1.3, + "windgustmph": 3.4, + "maxdailygust": 12.5, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.059, + "monthlyrainin": 0.39, + "yearlyrainin": 31.268, + "lightning_day": 1, + "lightning_time": 1700700515000, + "lightning_distance": 8.7, + "batt_lightning": 0, + "solarradiation": 0, + "uv": 0, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1700716954726, + "dateutc5": 1700716800000, + "lastRain": 1700445300000, + "lightnings": [ + [1700713320000, 0], + [1700713380000, 0], + [1700713440000, 0], + [1700713500000, 0], + [1700713560000, 0], + [1700713620000, 0], + [1700713680000, 0], + [1700713740000, 0], + [1700713800000, 0], + [1700713860000, 0], + [1700713920000, 0], + [1700713980000, 0], + [1700714040000, 0], + [1700714100000, 0], + [1700714160000, 0], + [1700714220000, 0], + [1700714280000, 0], + [1700714340000, 0], + [1700714400000, 0], + [1700714460000, 0], + [1700714520000, 0], + [1700714580000, 0], + [1700714640000, 0], + [1700714700000, 0], + [1700714760000, 0], + [1700714820000, 0], + [1700714880000, 0], + [1700714940000, 0], + [1700715000000, 0], + [1700715060000, 0], + [1700715120000, 0], + [1700715180000, 0], + [1700715240000, 0], + [1700715300000, 0], + [1700715360000, 0], + [1700715420000, 0], + [1700715480000, 0], + [1700715540000, 0], + [1700715600000, 0], + [1700715660000, 0], + [1700715720000, 0], + [1700715780000, 0], + [1700715840000, 0], + [1700715900000, 0], + [1700715960000, 0], + [1700716020000, 0], + [1700716080000, 0], + [1700716140000, 0], + [1700716200000, 0], + [1700716260000, 0], + [1700716320000, 0], + [1700716380000, 0], + [1700716440000, 0], + [1700716500000, 0], + [1700716560000, 0], + [1700716620000, 0], + [1700716680000, 0], + [1700716740000, 0], + [1700716800000, 0], + [1700716860000, 0], + [1700716920000, 0] + ], + "lightning_hour": 0, + "tz": "America/Chicago" + }, + "info": { + "name": "Station E", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location E", + "elevation": 236.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "eeeeeeeeeeeeeeeeeeeeeeee" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "ffffffffffffffffffffffff", + "macAddress": "FF:FF:FF:FF:FF:FF", + "lastData": {}, + "info": { + "name": "", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location F", + "elevation": 242.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "ffffffffffffffffffffffff" + }, + "tz": { + "name": "America/Chicago" + } + } +] diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..377018c54be --- /dev/null +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -0,0 +1,856 @@ +# serializer version: 1 +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_absolute_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Absolute pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'absolute_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromabsin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_daily_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dailyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station A Daily rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_daily_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dewPoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.7777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'AA:AA:AA:AA:AA:AA_feelsLike', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4444444444444', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_hourly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hourly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_hourlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Station A Hourly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_hourly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'humidity', + 'friendly_name': 'Station A Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.station_a_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_irradiance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irradiance', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_solarradiation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_last_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_lastRain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_max_daily_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max daily gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_daily_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_maxdailygust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Max daily gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_max_daily_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.72523008', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_monthly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_monthlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_relative_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromrelin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station A Relative pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_relative_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1001.89694313129', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_tempf', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.2777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'AA:AA:AA:AA:AA:AA_uv', + 'unit_of_measurement': 'index', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station A UV index', + 'state_class': , + 'unit_of_measurement': 'index', + }), + 'context': , + 'entity_id': 'sensor.station_a_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_weekly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_weeklyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'AA:AA:AA:AA:AA:AA_winddir', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_windgustmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.75768448', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_windspeedmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.03347968', + }) +# --- diff --git a/tests/components/ambient_network/test_config_flow.py b/tests/components/ambient_network/test_config_flow.py new file mode 100644 index 00000000000..d9093de7234 --- /dev/null +++ b/tests/components/ambient_network/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Ambient Weather Network config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components.ambient_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_happy_path( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + open_api: OpenAPI, + aioambient: AsyncMock, + devices_by_location: list[dict[str, Any]], + config_entry: ConfigEntry, +) -> None: + """Test the happy path.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=devices_by_location), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "station" + + stations_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], + { + "station": "AA:AA:AA:AA:AA:AA", + }, + ) + + assert stations_result["type"] == FlowResultType.CREATE_ENTRY + assert stations_result["title"] == config_entry.title + assert stations_result["data"] == config_entry.data + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_station_found( + hass: HomeAssistant, + aioambient: AsyncMock, + open_api: OpenAPI, +) -> None: + """Test that we abort when we cannot find a station in the area.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=[]), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "user" + assert user_result["errors"] == {"base": "no_stations_found"} diff --git a/tests/components/ambient_network/test_sensor.py b/tests/components/ambient_network/test_sensor.py new file mode 100644 index 00000000000..b556c0c9c7c --- /dev/null +++ b/tests/components/ambient_network/test_sensor.py @@ -0,0 +1,123 @@ +"""Test Ambient Weather Network sensors.""" + +from datetime import datetime, timedelta +from unittest.mock import patch + +from aioambient import OpenAPI +from aioambient.errors import RequestError +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform + +from tests.common import async_fire_time_changed + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensors under normal operation.""" + await setup_platform(True, hass, config_entry) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + + +@freeze_time("2023-11-09") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_with_stale_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the data is stale.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_a_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["BB:BB:BB:BB:BB:BB"], indirect=True) +async def test_sensors_with_no_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the last data is absent.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_b_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["CC:CC:CC:CC:CC:CC"], indirect=True) +async def test_sensors_with_no_update_time( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the update time is missing.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_c_absolute_pressure") + assert sensor is None + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_disappearing( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + caplog, +) -> None: + """Test that we log errors properly.""" + + initial_datetime = datetime(year=2023, month=11, day=8) + with freeze_time(initial_datetime) as frozen_datetime: + # Normal state, sensor is available. + await setup_platform(True, hass, config_entry) + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + + # Sensor becomes unavailable if the network is unavailable. Log message + # should only show up once. + for _ in range(5): + with patch.object( + open_api, "get_device_details", side_effect=RequestError() + ): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert sensor.state == "unavailable" + assert caplog.text.count("Cannot connect to Ambient Network") == 1 + + # Network comes back. Sensor should start reporting again. Log message + # should only show up once. + for _ in range(5): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + assert caplog.text.count("Fetching ambient_network data recovered") == 1