From be5d6425dc0ef5c644d62569282876cee55c659e Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 15 May 2024 09:13:26 +0200 Subject: [PATCH] Add options flow to the airq integration (#109337) * Add support for options to airq integration Expose to the user the following configuration: 1. A choice between fetching from the device either: a. the averaged (previous and the new default behaviour) or b. noisy momentary sensor reading 2. A toggle to clip (spuriously) negative sensor values (default functionality, previously unexposed) To those ends: - Introduce an `OptionsFlowHandler` alongside with a listener `AirQCoordinator.async_set_options` - Introduce constants to handle represent options - Add tests and strings * Drop OptionsFlowHandler in favour of SchemaOptionsFlowHandler Modify `AirQCoordinator.__init__` to accommodate the change in option handling, and drop `async_set_options` which slipped through the previous commit. * Ruff formatting --- homeassistant/components/airq/__init__.py | 14 +++++++- homeassistant/components/airq/config_flow.py | 28 +++++++++++++-- homeassistant/components/airq/const.py | 2 ++ homeassistant/components/airq/coordinator.py | 9 ++++- homeassistant/components/airq/strings.json | 15 ++++++++ tests/components/airq/test_config_flow.py | 38 +++++++++++++++++++- 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 219a72042ef..ab64915c8ae 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -16,7 +17,12 @@ AirQConfigEntry = ConfigEntry[AirQCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool: """Set up air-Q from a config entry.""" - coordinator = AirQCoordinator(hass, entry) + coordinator = AirQCoordinator( + hass, + entry, + clip_negative=entry.options.get(CONF_CLIP_NEGATIVE, True), + return_average=entry.options.get(CONF_RETURN_AVERAGE, True), + ) # Query the device for the first time and initialise coordinator.data await coordinator.async_config_entry_first_refresh() @@ -24,6 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -31,3 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 9e51552a309..0c57b399b1b 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -9,11 +9,17 @@ from aioairq import AirQ, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import BooleanSelector -from .const import DOMAIN +from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -23,6 +29,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): str, } ) +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=vol.Schema( + { + vol.Optional(CONF_RETURN_AVERAGE, default=True): BooleanSelector(), + vol.Optional(CONF_CLIP_NEGATIVE, default=True): BooleanSelector(), + } + ) + ), +} class AirQConfigFlow(ConfigFlow, domain=DOMAIN): @@ -72,3 +88,11 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SchemaOptionsFlowHandler: + """Return the options flow.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 845fa7f1de8..7a5abe47a8d 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -2,6 +2,8 @@ from typing import Final +CONF_RETURN_AVERAGE: Final = "return_average" +CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index b03ce36d776..362b65b5828 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -26,6 +26,8 @@ class AirQCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, entry: ConfigEntry, + clip_negative: bool = True, + return_average: bool = True, ) -> None: """Initialise a custom coordinator.""" super().__init__( @@ -44,6 +46,8 @@ class AirQCoordinator(DataUpdateCoordinator): manufacturer=MANUFACTURER, identifiers={(DOMAIN, self.device_id)}, ) + self.clip_negative = clip_negative + self.return_average = return_average async def _async_update_data(self) -> dict: """Fetch the data from the device.""" @@ -57,4 +61,7 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - return await self.airq.get_latest_data() # type: ignore[no-any-return] + return await self.airq.get_latest_data( # type: ignore[no-any-return] + return_average=self.return_average, + clip_negative_values=self.clip_negative, + ) diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 8628ede4116..26b944467e6 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -19,6 +19,21 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "options": { + "step": { + "init": { + "title": "Configure air-Q integration", + "data": { + "return_average": "Show values averaged by the device", + "clip_negatives": "Clip negative values" + }, + "data_description": { + "return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)", + "clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0" + } + } + } + }, "entity": { "sensor": { "acetaldehyde": { diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 8c85e017367..d70c1526510 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -7,7 +7,11 @@ from aiohttp.client_exceptions import ClientConnectionError import pytest from homeassistant import config_entries -from homeassistant.components.airq.const import DOMAIN +from homeassistant.components.airq.const import ( + CONF_CLIP_NEGATIVE, + CONF_RETURN_AVERAGE, + DOMAIN, +) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +31,10 @@ TEST_DEVICE_INFO = DeviceInfo( sw_version="sw", hw_version="hw", ) +DEFAULT_OPTIONS = { + CONF_CLIP_NEGATIVE: True, + CONF_RETURN_AVERAGE: True, +} async def test_form(hass: HomeAssistant) -> None: @@ -103,3 +111,31 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "user_input", [{}, {CONF_RETURN_AVERAGE: False}, {CONF_CLIP_NEGATIVE: False}] +) +async def test_options_flow(hass: HomeAssistant, user_input) -> None: + """Test that the options flow works.""" + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert entry.options == {} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=user_input + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == entry.options == DEFAULT_OPTIONS | user_input