diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index adadf41b2b0..c426b942af5 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1 +1,26 @@ """Calculates mold growth indication from temperature and humidity.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Mold indicator from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Mold indicator 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/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py new file mode 100644 index 00000000000..cc8f05c102d --- /dev/null +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for Mold indicator.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_NAME, Platform +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) + +from .const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) + + +async def validate_duplicate( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate already existing entry.""" + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + return user_input + + +DATA_SCHEMA_OPTIONS = vol.Schema( + { + vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ) + } +) + +DATA_SCHEMA_CONFIG = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_INDOOR_TEMP): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE + ) + ), + vol.Required(CONF_INDOOR_HUMIDITY): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.HUMIDITY + ) + ), + vol.Required(CONF_OUTDOOR_TEMP): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE + ) + ), + } +).extend(DATA_SCHEMA_OPTIONS.schema) + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_CONFIG, + validate_user_input=validate_duplicate, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + DATA_SCHEMA_OPTIONS, + validate_user_input=validate_duplicate, + ) +} + + +class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Mold indicator.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/const.py b/homeassistant/components/mold_indicator/const.py new file mode 100644 index 00000000000..15fdf51bce3 --- /dev/null +++ b/homeassistant/components/mold_indicator/const.py @@ -0,0 +1,12 @@ +"""Constants for Mold indicator component.""" + +from __future__ import annotations + +DOMAIN = "mold_indicator" + +CONF_CALIBRATION_FACTOR = "calibration_factor" +CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" +CONF_INDOOR_TEMP = "indoor_temp_sensor" +CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" + +DEFAULT_NAME = "Mold Indicator" diff --git a/homeassistant/components/mold_indicator/manifest.json b/homeassistant/components/mold_indicator/manifest.json index 5ebccb5f92d..b57f1c471ef 100644 --- a/homeassistant/components/mold_indicator/manifest.json +++ b/homeassistant/components/mold_indicator/manifest.json @@ -2,7 +2,9 @@ "domain": "mold_indicator", "name": "Mold Indicator", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mold_indicator", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 2d80bc9f6e1..e96f53a17bb 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -37,17 +38,19 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import METRIC_SYSTEM +from .const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, +) + _LOGGER = logging.getLogger(__name__) ATTR_CRITICAL_TEMP = "estimated_critical_temp" ATTR_DEWPOINT = "dewpoint" -CONF_CALIBRATION_FACTOR = "calibration_factor" -CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" -CONF_INDOOR_TEMP = "indoor_temp_sensor" -CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" - -DEFAULT_NAME = "Mold Indicator" MAGNUS_K2 = 17.62 MAGNUS_K3 = 243.12 @@ -70,7 +73,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up MoldIndicator sensor.""" - name: str = config[CONF_NAME] + name: str = config.get(CONF_NAME, DEFAULT_NAME) indoor_temp_sensor: str = config[CONF_INDOOR_TEMP] outdoor_temp_sensor: str = config[CONF_OUTDOOR_TEMP] indoor_humidity_sensor: str = config[CONF_INDOOR_HUMIDITY] @@ -91,6 +94,33 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mold indicator sensor entry.""" + name: str = entry.options[CONF_NAME] + indoor_temp_sensor: str = entry.options[CONF_INDOOR_TEMP] + outdoor_temp_sensor: str = entry.options[CONF_OUTDOOR_TEMP] + indoor_humidity_sensor: str = entry.options[CONF_INDOOR_HUMIDITY] + calib_factor: float = entry.options[CONF_CALIBRATION_FACTOR] + + async_add_entities( + [ + MoldIndicator( + name, + hass.config.units is METRIC_SYSTEM, + indoor_temp_sensor, + outdoor_temp_sensor, + indoor_humidity_sensor, + calib_factor, + ) + ], + False, + ) + + class MoldIndicator(SensorEntity): """Represents a MoldIndication sensor.""" diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json new file mode 100644 index 00000000000..2e34bcc1ba1 --- /dev/null +++ b/homeassistant/components/mold_indicator/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "description": "Add Mold indicator helper", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "indoor_humidity_sensor": "Indoor humidity sensor", + "indoor_temp_sensor": "Indoor temperature sensor", + "outdoor_temp_sensor": "Outdoor temperature sensor", + "calibration_factor": "Calibration factor" + }, + "data_description": { + "name": "Name for the created entity.", + "indoor_humidity_sensor": "The entity ID of the indoor humidity sensor.", + "indoor_temp_sensor": "The entity ID of the indoor temperature sensor.", + "outdoor_temp_sensor": "The entity ID of the outdoor temperature sensor.", + "calibration_factor": "Needs to be calibrated to the critical point in the room." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "init": { + "description": "Adjust the calibration factor as required", + "data": { + "calibration_factor": "[%key:component::mold_indicator::config::step::user::data::calibration_factor%]" + }, + "data_description": { + "calibration_factor": "[%key:component::mold_indicator::config::step::user::data_description::calibration_factor%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f03c980a2d4..2e38d608bd9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ FLOWS = { "history_stats", "integration", "min_max", + "mold_indicator", "random", "statistics", "switch_as_x", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a01e20909b6..4a6be3f0a1a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3790,12 +3790,6 @@ "config_flow": true, "iot_class": "local_push" }, - "mold_indicator": { - "name": "Mold Indicator", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "monessen": { "name": "Monessen", "integration_type": "virtual", @@ -7307,6 +7301,12 @@ "config_flow": true, "iot_class": "calculated" }, + "mold_indicator": { + "name": "Mold Indicator", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "random": { "name": "Random", "integration_type": "helper", diff --git a/tests/components/mold_indicator/conftest.py b/tests/components/mold_indicator/conftest.py new file mode 100644 index 00000000000..11f07e1db35 --- /dev/null +++ b/tests/components/mold_indicator/conftest.py @@ -0,0 +1,90 @@ +"""Fixtures for the Mold indicator integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically path mold indicator.""" + with patch( + "homeassistant.components.mold_indicator.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any] +) -> MockConfigEntry: + """Set up the Mold indicator integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + title=DEFAULT_NAME, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set( + "sensor.indoor_temp", + "10", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "sensor.outdoor_temp", + "10", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "sensor.indoor_humidity", "0", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} + ) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py new file mode 100644 index 00000000000..7a766be11f5 --- /dev/null +++ b/tests/components/mold_indicator/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Mold indicator config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form for sensor.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CALIBRATION_FACTOR: 3.0, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 3.0, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + # 3 input entities + resulting mold indicator sensor + assert len(hass.states.async_all()) == 4 + + state = hass.states.get("sensor.mold_indicator") + assert state is not None + + +async def test_entry_already_exist( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py new file mode 100644 index 00000000000..5fd6b11c8fe --- /dev/null +++ b/tests/components/mold_indicator/test_init.py @@ -0,0 +1,17 @@ +"""Test Mold indicator component setup process.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py index 2de1d34b403..bb3f7c4fc93 100644 --- a/tests/components/mold_indicator/test_sensor.py +++ b/tests/components/mold_indicator/test_sensor.py @@ -16,6 +16,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def init_sensors_fixture(hass: HomeAssistant) -> None: @@ -52,6 +54,16 @@ async def test_setup(hass: HomeAssistant) -> None: assert moldind.attributes.get("unit_of_measurement") == PERCENTAGE +async def test_setup_from_config_entry( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test the mold indicator sensor setup from a config entry.""" + + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.attributes.get("unit_of_measurement") == PERCENTAGE + + async def test_invalidcalib(hass: HomeAssistant) -> None: """Test invalid sensor values.""" hass.states.async_set(