diff --git a/homeassistant/components/mopeka/__init__.py b/homeassistant/components/mopeka/__init__.py index 2538ec3d810..17a87efd6e6 100644 --- a/homeassistant/components/mopeka/__init__.py +++ b/homeassistant/components/mopeka/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from mopeka_iot_ble import MopekaIOTBluetoothDeviceData +from mopeka_iot_ble import MediumType, MopekaIOTBluetoothDeviceData from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( @@ -14,6 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_MEDIUM_TYPE + PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -26,7 +28,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bo """Set up Mopeka BLE device from a config entry.""" address = entry.unique_id assert address is not None - data = MopekaIOTBluetoothDeviceData() + + # Default sensors configured prior to the intorudction of MediumType + medium_type_str = entry.data.get(CONF_MEDIUM_TYPE, MediumType.PROPANE.value) + data = MopekaIOTBluetoothDeviceData(MediumType(medium_type_str)) coordinator = entry.runtime_data = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, @@ -37,9 +42,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True +async def update_listener(hass: HomeAssistant, entry: MopekaConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 1732157ce49..72e9386a47f 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -2,19 +2,43 @@ from __future__ import annotations +from enum import Enum from typing import Any from mopeka_iot_ble import MopekaIOTBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback -from .const import DOMAIN +from .const import CONF_MEDIUM_TYPE, DEFAULT_MEDIUM_TYPE, DOMAIN, MediumType + + +def format_medium_type(medium_type: Enum) -> str: + """Format the medium type for human reading.""" + return medium_type.name.replace("_", " ").title() + + +MEDIUM_TYPES_BY_NAME = { + medium.value: format_medium_type(medium) for medium in MediumType +} + + +def async_generate_schema(medium_type: str | None = None) -> vol.Schema: + """Return the base schema with formatted medium types.""" + return vol.Schema( + { + vol.Required( + CONF_MEDIUM_TYPE, default=medium_type or DEFAULT_MEDIUM_TYPE + ): vol.In(MEDIUM_TYPES_BY_NAME) + } + ) class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -28,6 +52,14 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: DeviceData | None = None self._discovered_devices: dict[str, str] = {} + @callback + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MopekaOptionsFlow: + """Return the options flow for this handler.""" + return MopekaOptionsFlow(config_entry) + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -44,32 +76,39 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm discovery.""" + """Confirm discovery and select medium type.""" assert self._discovered_device is not None device = self._discovered_device assert self._discovery_info is not None discovery_info = self._discovery_info title = device.title or device.get_device_name() or discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + self._discovered_devices[discovery_info.address] = title + return self.async_create_entry( + title=self._discovered_devices[discovery_info.address], + data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, + ) self._set_confirm_only() placeholders = {"name": title} self.context["title_placeholders"] = placeholders return self.async_show_form( - step_id="bluetooth_confirm", description_placeholders=placeholders + step_id="bluetooth_confirm", + description_placeholders=placeholders, + data_schema=async_generate_schema(), ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" + """Handle the user step to pick discovered device and select medium type.""" if user_input is not None: address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=self._discovered_devices[address], data={} + title=self._discovered_devices[address], + data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, ) current_addresses = self._async_current_ids() @@ -89,6 +128,39 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + { + vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices), + **async_generate_schema().schema, + } + ), + ) + + +class MopekaOptionsFlow(config_entries.OptionsFlow): + """Handle options for the Mopeka component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + new_data = { + **self.config_entry.data, + CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE], + } + self.hass.config_entries.async_update_entry( + self.config_entry, data=new_data + ) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="init", + data_schema=async_generate_schema( + self.config_entry.data.get(CONF_MEDIUM_TYPE) ), ) diff --git a/homeassistant/components/mopeka/const.py b/homeassistant/components/mopeka/const.py index 0d78146f5a8..e18828f2364 100644 --- a/homeassistant/components/mopeka/const.py +++ b/homeassistant/components/mopeka/const.py @@ -1,3 +1,11 @@ """Constants for the Mopeka integration.""" +from typing import Final + +from mopeka_iot_ble import MediumType + DOMAIN = "mopeka" + +CONF_MEDIUM_TYPE: Final = "medium_type" + +DEFAULT_MEDIUM_TYPE = MediumType.PROPANE.value diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index 16a80220a20..2455eea2f76 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -5,11 +5,15 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:common::config_flow::data::device%]" + "address": "[%key:common::config_flow::data::device%]", + "medium_type": "Medium Type" } }, "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]", + "data": { + "medium_type": "[%key:component::mopeka::config::step::user::data::medium_type%]" + } } }, "abort": { @@ -18,5 +22,15 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "options": { + "step": { + "init": { + "title": "Configure Mopeka", + "data": { + "medium_type": "[%key:component::mopeka::config::step::user::data::medium_type%]" + } + } + } } } diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index 826fe8db2aa..7a341052f22 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -2,8 +2,10 @@ from unittest.mock import patch +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.components.mopeka.const import DOMAIN +from homeassistant.components.mopeka.const import CONF_MEDIUM_TYPE, DOMAIN, MediumType from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,13 +23,14 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.mopeka.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + result["flow_id"], user_input={CONF_MEDIUM_TYPE: MediumType.PROPANE.value} ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert result2["data"] == {CONF_MEDIUM_TYPE: MediumType.PROPANE.value} assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" @@ -71,7 +74,10 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" @@ -190,8 +196,44 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" # Verify the original one was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_async_step_reconfigure_options(hass: HomeAssistant) -> None: + """Test reconfig options: change MediumType from air to fresh water.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:75:10", + title="TD40/TD200 7510", + data={CONF_MEDIUM_TYPE: MediumType.AIR.value}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.data[CONF_MEDIUM_TYPE] == MediumType.AIR.value + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema: vol.Schema = result["data_schema"] + medium_type_key = next( + iter(key for key in schema.schema if key == CONF_MEDIUM_TYPE) + ) + assert medium_type_key.default() == MediumType.AIR.value + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MEDIUM_TYPE: MediumType.FRESH_WATER.value}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + # Verify the new configuration + assert entry.data[CONF_MEDIUM_TYPE] == MediumType.FRESH_WATER.value