Add ability to add/remove PurpleAir sensors in an existing config entry (#83440)

This commit is contained in:
Aaron Bach 2022-12-18 11:30:05 -07:00 committed by GitHub
parent 168b3b50cd
commit d423cbf8eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 613 additions and 17 deletions

View File

@ -6,10 +6,12 @@ from aiopurpleair.models.sensors import SensorModel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .config_flow import async_remove_sensor_by_device_id
from .const import CONF_LAST_UPDATE_SENSOR_ADD, DOMAIN
from .coordinator import PurpleAirDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@ -23,9 +25,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_handle_entry_update))
return True
async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle an options update."""
if entry.options.get(CONF_LAST_UPDATE_SENSOR_ADD) is True:
# If the last options update was to add a sensor, we reload the config entry:
await hass.config_entries.async_reload(entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
new_entry_options = async_remove_sensor_by_device_id(
hass,
config_entry,
device_entry.id,
# remove_device is set to False because in this instance, the device has
# already been removed:
remove_device=False,
)
return hass.config_entries.async_update_entry(
config_entry, options=new_entry_options
)
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):

View File

@ -2,10 +2,12 @@
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from typing import Any, cast
from aiopurpleair import API
from aiopurpleair.endpoints.sensors import NearbySensorResult
from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError
import voluptuous as vol
@ -14,7 +16,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers import (
aiohttp_client,
config_validation as cv,
device_registry as dr,
)
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@ -22,10 +28,11 @@ from homeassistant.helpers.selector import (
SelectSelectorMode,
)
from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER
from .const import CONF_LAST_UPDATE_SENSOR_ADD, CONF_SENSOR_INDICES, DOMAIN, LOGGER
CONF_DISTANCE = "distance"
CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options"
CONF_SENSOR_DEVICE_ID = "sensor_device_id"
CONF_SENSOR_INDEX = "sensor_index"
DEFAULT_DISTANCE = 5
@ -60,6 +67,20 @@ def async_get_coordinates_schema(hass: HomeAssistant) -> vol.Schema:
)
@callback
def async_get_nearby_sensors_options(
nearby_sensor_results: list[NearbySensorResult],
) -> list[SelectOptionDict]:
"""Return a set of nearby sensors as SelectOptionDict objects."""
return [
SelectOptionDict(
value=str(result.sensor.sensor_index),
label=f"{result.sensor.name} ({round(result.distance, 1)} km away)",
)
for result in nearby_sensor_results
]
@callback
def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Schema:
"""Define a schema for selecting a sensor from a list."""
@ -72,6 +93,75 @@ def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Sche
)
@callback
def async_get_remove_sensor_options(
hass: HomeAssistant, config_entry: ConfigEntry
) -> list[SelectOptionDict]:
"""Return a set of already-configured sensors as SelectOptionDict objects."""
device_registry = dr.async_get(hass)
return [
SelectOptionDict(value=device_entry.id, label=cast(str, device_entry.name))
for device_entry in device_registry.devices.values()
if config_entry.entry_id in device_entry.config_entries
]
@callback
def async_get_remove_sensor_schema(sensors: list[SelectOptionDict]) -> vol.Schema:
"""Define a schema removing a sensor."""
return vol.Schema(
{
vol.Required(CONF_SENSOR_DEVICE_ID): SelectSelector(
SelectSelectorConfig(options=sensors, mode=SelectSelectorMode.DROPDOWN)
)
}
)
@callback
def async_get_sensor_index(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> int:
"""Get the sensor index related to a config and device entry.
Note that this method expects that there will always be a single sensor index per
DeviceEntry.
"""
[sensor_index] = [
sensor_index
for sensor_index in config_entry.options[CONF_SENSOR_INDICES]
if (DOMAIN, str(sensor_index)) in device_entry.identifiers
]
return cast(int, sensor_index)
@callback
def async_remove_sensor_by_device_id(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_id: str,
*,
remove_device: bool = True,
) -> dict[str, Any]:
"""Remove a sensor and return update config entry options."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
assert device_entry
removed_sensor_index = async_get_sensor_index(hass, config_entry, device_entry)
options = deepcopy({**config_entry.options})
options[CONF_LAST_UPDATE_SENSOR_ADD] = False
options[CONF_SENSOR_INDICES].remove(removed_sensor_index)
if remove_device:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
return options
@dataclass
class ValidationResult:
"""Define a validation result."""
@ -146,6 +236,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._flow_data: dict[str, Any] = {}
self._reauth_entry: ConfigEntry | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> PurpleAirOptionsFlowHandler:
"""Define the config flow to handle options."""
return PurpleAirOptionsFlowHandler(config_entry)
async def async_step_by_coordinates(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
@ -170,13 +268,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=validation.errors,
)
self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = [
SelectOptionDict(
value=str(result.sensor.sensor_index),
label=f"{result.sensor.name} ({round(result.distance, 1)} km away)",
)
for result in validation.data
]
self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options(
validation.data
)
return await self.async_step_choose_sensor()
@ -256,3 +350,91 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._flow_data = {CONF_API_KEY: api_key}
return await self.async_step_by_coordinates()
class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a PurpleAir options flow."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize."""
self._flow_data: dict[str, Any] = {}
self.config_entry = config_entry
async def async_step_add_sensor(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Add a sensor."""
if user_input is None:
return self.async_show_form(
step_id="add_sensor",
data_schema=async_get_coordinates_schema(self.hass),
)
validation = await async_validate_coordinates(
self.hass,
self.config_entry.data[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
user_input[CONF_DISTANCE],
)
if validation.errors:
return self.async_show_form(
step_id="add_sensor",
data_schema=async_get_coordinates_schema(self.hass),
errors=validation.errors,
)
self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = async_get_nearby_sensors_options(
validation.data
)
return await self.async_step_choose_sensor()
async def async_step_choose_sensor(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the selection of a sensor."""
if user_input is None:
options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS)
return self.async_show_form(
step_id="choose_sensor",
data_schema=async_get_nearby_sensors_schema(options),
)
sensor_index = int(user_input[CONF_SENSOR_INDEX])
if sensor_index in self.config_entry.options[CONF_SENSOR_INDICES]:
return self.async_abort(reason="already_configured")
options = deepcopy({**self.config_entry.options})
options[CONF_LAST_UPDATE_SENSOR_ADD] = True
options[CONF_SENSOR_INDICES].append(sensor_index)
return self.async_create_entry(title="", data=options)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
return self.async_show_menu(
step_id="init",
menu_options=["add_sensor", "remove_sensor"],
)
async def async_step_remove_sensor(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Add a sensor."""
if user_input is None:
return self.async_show_form(
step_id="remove_sensor",
data_schema=async_get_remove_sensor_schema(
async_get_remove_sensor_options(self.hass, self.config_entry)
),
)
new_entry_options = async_remove_sensor_by_device_id(
self.hass, self.config_entry, user_input[CONF_SENSOR_DEVICE_ID]
)
return self.async_create_entry(title="", data=new_entry_options)

View File

@ -5,5 +5,6 @@ DOMAIN = "purpleair"
LOGGER = logging.getLogger(__package__)
CONF_LAST_UPDATE_SENSOR_ADD = "last_update_sensor_add"
CONF_READ_KEY = "read_key"
CONF_SENSOR_INDICES = "sensor_indices"

View File

@ -49,5 +49,56 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"options": {
"step": {
"add_sensor": {
"title": "Add Sensor",
"description": "[%key:component::purpleair::config::step::by_coordinates::description%]",
"data": {
"latitude": "[%key:component::purpleair::config::step::by_coordinates::data::latitude%]",
"longitude": "[%key:component::purpleair::config::step::by_coordinates::data::longitude%]",
"distance": "[%key:component::purpleair::config::step::by_coordinates::data::distance%]"
},
"data_description": {
"latitude": "[%key:component::purpleair::config::step::by_coordinates::data_description::latitude%]",
"longitude": "[%key:component::purpleair::config::step::by_coordinates::data_description::longitude%]",
"distance": "[%key:component::purpleair::config::step::by_coordinates::data_description::distance%]"
}
},
"choose_sensor": {
"title": "Choose Sensor to Add",
"description": "[%key:component::purpleair::config::step::choose_sensor::description%]",
"data": {
"sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]"
},
"data_description": {
"sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data_description::sensor_index%]"
}
},
"init": {
"menu_options": {
"add_sensor": "Add sensor",
"remove_sensor": "Remove sensor"
}
},
"remove_sensor": {
"title": "Remove Sensor",
"data": {
"sensor_device_id": "Sensor Name"
},
"data_description": {
"sensor_device_id": "The sensor to remove"
}
}
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"no_sensors_near_coordinates": "[%key:component::purpleair::config::error::no_sensors_near_coordinates%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -49,5 +49,56 @@
}
}
}
},
"options": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"invalid_api_key": "Invalid API key",
"no_sensors_near_coordinates": "No sensors found near coordinates (within distance)",
"unknown": "Unexpected error"
},
"step": {
"add_sensor": {
"data": {
"distance": "Search Radius",
"latitude": "Latitude",
"longitude": "Longitude"
},
"data_description": {
"distance": "The radius (in kilometers) of the circle to search within",
"latitude": "The latitude around which to search for sensors",
"longitude": "The longitude around which to search for sensors"
},
"description": "Search for a PurpleAir sensor within a certain distance of a latitude/longitude.",
"title": "Add Sensor"
},
"choose_sensor": {
"data": {
"sensor_index": "Sensor"
},
"data_description": {
"sensor_index": "The sensor to track"
},
"description": "Which of the nearby sensors would you like to track?",
"title": "Choose Sensor to Add"
},
"init": {
"menu_options": {
"add_sensor": "Add sensor",
"remove_sensor": "Remove sensor"
}
},
"remove_sensor": {
"data": {
"sensor_device_id": "Sensor Name"
},
"data_description": {
"sensor_device_id": "The sensor to remove"
},
"title": "Remove Sensor"
}
}
}
}

View File

@ -86,11 +86,7 @@ async def setup_purpleair_fixture(hass, api, config_entry_data):
"""Define a fixture to set up PurpleAir."""
with patch(
"homeassistant.components.purpleair.config_flow.API", return_value=api
), patch(
"homeassistant.components.purpleair.coordinator.API", return_value=api
), patch(
"homeassistant.components.purpleair.PLATFORMS", []
):
), patch("homeassistant.components.purpleair.coordinator.API", return_value=api):
assert await async_setup_component(hass, DOMAIN, config_entry_data)
await hass.async_block_till_done()
yield

View File

@ -57,6 +57,32 @@
0,
0,
0
],
[
567890,
"Test Sensor 2",
0,
"PA-II",
"2.0+BME280+PMSX003-B+PMSX003-A",
"7.02",
-69,
13788,
51.5285582,
-0.2416796,
569,
13,
82,
1000.74,
null,
0.0,
0.0,
0.0,
76,
68,
0,
0,
0,
0
]
]
}

View File

@ -7,6 +7,7 @@ import pytest
from homeassistant import data_entry_flow
from homeassistant.components.purpleair import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.helpers import device_registry as dr
async def test_duplicate_error(hass, config_entry, setup_purpleair):
@ -143,3 +144,129 @@ async def test_reauth(
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert len(hass.config_entries.async_entries()) == 1
@pytest.mark.parametrize(
"get_nearby_sensors_mock,get_nearby_sensors_errors",
[
(AsyncMock(return_value=[]), {"base": "no_sensors_near_coordinates"}),
(AsyncMock(side_effect=Exception), {"base": "unknown"}),
(AsyncMock(side_effect=PurpleAirError), {"base": "unknown"}),
],
)
async def test_options_add_sensor(
hass,
api,
config_entry,
get_nearby_sensors_errors,
get_nearby_sensors_mock,
setup_purpleair,
):
"""Test adding a sensor via the options flow (including errors)."""
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "add_sensor"}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "add_sensor"
# Test errors that can arise when searching for nearby sensors:
with patch.object(api.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"latitude": 51.5285582,
"longitude": -0.2416796,
"distance": 5,
},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "add_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"latitude": 51.5285582,
"longitude": -0.2416796,
"distance": 5,
},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "choose_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"sensor_index": "567890",
},
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"] == {
"last_update_sensor_add": True,
"sensor_indices": [123456, 567890],
}
assert config_entry.options["sensor_indices"] == [123456, 567890]
async def test_options_add_sensor_duplicate(hass, config_entry, setup_purpleair):
"""Test adding a duplicate sensor via the options flow."""
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "add_sensor"}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "add_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"latitude": 51.5285582,
"longitude": -0.2416796,
"distance": 5,
},
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "choose_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"sensor_index": "123456",
},
)
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_options_remove_sensor(hass, config_entry, setup_purpleair):
"""Test removing a sensor via the options flow."""
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "remove_sensor"}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "remove_sensor"
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_device({(DOMAIN, "123456")})
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"sensor_device_id": device_entry.id},
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"] == {
"last_update_sensor_add": False,
"sensor_indices": [],
}
assert config_entry.options["sensor_indices"] == []

View File

@ -187,7 +187,141 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_purpleai
"voc": None,
"voc_a": None,
"voc_b": None,
}
},
"567890": {
"sensor_index": 567890,
"altitude": 569.0,
"analog_input": None,
"channel_flags": None,
"channel_flags_auto": None,
"channel_flags_manual": None,
"channel_state": None,
"confidence": None,
"confidence_auto": None,
"confidence_manual": None,
"date_created_utc": None,
"deciviews": None,
"deciviews_a": None,
"deciviews_b": None,
"firmware_upgrade": None,
"firmware_version": "7.02",
"hardware": "2.0+BME280+PMSX003-B+PMSX003-A",
"humidity": 13.0,
"humidity_a": None,
"humidity_b": None,
"icon": None,
"is_owner": None,
"last_modified_utc": None,
"last_seen_utc": None,
"latitude": REDACTED,
"led_brightness": None,
"location_type": {
"__type": "<enum 'LocationType'>",
"repr": "<LocationType.OUTSIDE: 0>",
},
"longitude": REDACTED,
"memory": None,
"model": "PA-II",
"name": "Test Sensor 2",
"ozone1": None,
"pa_latency": None,
"pm0_3_um_count": 76.0,
"pm0_3_um_count_a": None,
"pm0_3_um_count_b": None,
"pm0_5_um_count": 68.0,
"pm0_5_um_count_a": None,
"pm0_5_um_count_b": None,
"pm10_0": 0.0,
"pm10_0_a": None,
"pm10_0_atm": None,
"pm10_0_atm_a": None,
"pm10_0_atm_b": None,
"pm10_0_b": None,
"pm10_0_cf_1": None,
"pm10_0_cf_1_a": None,
"pm10_0_cf_1_b": None,
"pm10_0_um_count": 0.0,
"pm10_0_um_count_a": None,
"pm10_0_um_count_b": None,
"pm1_0": 0.0,
"pm1_0_a": None,
"pm1_0_atm": None,
"pm1_0_atm_a": None,
"pm1_0_atm_b": None,
"pm1_0_b": None,
"pm1_0_cf_1": None,
"pm1_0_cf_1_a": None,
"pm1_0_cf_1_b": None,
"pm1_0_um_count": 0.0,
"pm1_0_um_count_a": None,
"pm1_0_um_count_b": None,
"pm2_5": 0.0,
"pm2_5_10minute": None,
"pm2_5_10minute_a": None,
"pm2_5_10minute_b": None,
"pm2_5_1week": None,
"pm2_5_1week_a": None,
"pm2_5_1week_b": None,
"pm2_5_24hour": None,
"pm2_5_24hour_a": None,
"pm2_5_24hour_b": None,
"pm2_5_30minute": None,
"pm2_5_30minute_a": None,
"pm2_5_30minute_b": None,
"pm2_5_60minute": None,
"pm2_5_60minute_a": None,
"pm2_5_60minute_b": None,
"pm2_5_6hour": None,
"pm2_5_6hour_a": None,
"pm2_5_6hour_b": None,
"pm2_5_a": None,
"pm2_5_alt": None,
"pm2_5_alt_a": None,
"pm2_5_alt_b": None,
"pm2_5_atm": None,
"pm2_5_atm_a": None,
"pm2_5_atm_b": None,
"pm2_5_b": None,
"pm2_5_cf_1": None,
"pm2_5_cf_1_a": None,
"pm2_5_cf_1_b": None,
"pm2_5_um_count": 0.0,
"pm2_5_um_count_a": None,
"pm2_5_um_count_b": None,
"pm5_0_um_count": 0.0,
"pm5_0_um_count_a": None,
"pm5_0_um_count_b": None,
"position_rating": None,
"pressure": 1000.74,
"pressure_a": None,
"pressure_b": None,
"primary_id_a": None,
"primary_id_b": None,
"primary_key_a": None,
"primary_key_b": None,
"private": None,
"rssi": -69,
"scattering_coefficient": None,
"scattering_coefficient_a": None,
"scattering_coefficient_b": None,
"secondary_id_a": None,
"secondary_id_b": None,
"secondary_key_a": None,
"secondary_key_b": None,
"stats": None,
"stats_a": None,
"stats_b": None,
"temperature": 82.0,
"temperature_a": None,
"temperature_b": None,
"uptime": 13788,
"visual_range": None,
"visual_range_a": None,
"visual_range_b": None,
"voc": None,
"voc_a": None,
"voc_b": None,
},
},
"api_version": "V1.0.11-0.0.41",
"firmware_default_version": "7.02",