Add config flow for min_max sensor (#67807)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Erik Montnemery 2022-03-28 09:39:54 +02:00 committed by GitHub
parent 1746677b61
commit 66d892237d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 402 additions and 18 deletions

View file

@ -115,7 +115,11 @@ class GroupConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN):
@callback
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
"""Return config entry title.
The options parameter contains config entry options, which is the union of user
input from the config flow steps.
"""
return cast(str, options["name"]) if "name" in options else ""
@callback

View file

@ -1,6 +1,26 @@
"""The min_max component."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
DOMAIN = "min_max"
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Min/Max from a config entry."""
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,58 @@
"""Config flow for Min/Max integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
import voluptuous as vol
from homeassistant.const import CONF_TYPE
from homeassistant.helpers import selector
from homeassistant.helpers.helper_config_entry_flow import (
HelperConfigFlowHandler,
HelperFlowFormStep,
HelperFlowMenuStep,
)
from .const import CONF_ENTITY_IDS, CONF_ROUND_DIGITS, DOMAIN
_STATISTIC_MEASURES = ["last", "max", "mean", "min", "median"]
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_ENTITY_IDS): selector.selector(
{"entity": {"domain": "sensor", "multiple": True}}
),
vol.Required(CONF_TYPE): selector.selector(
{"select": {"options": _STATISTIC_MEASURES}}
),
vol.Required(CONF_ROUND_DIGITS, default=2): selector.selector(
{"number": {"min": 0, "max": 6, "mode": "box"}}
),
}
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("name"): selector.selector({"text": {}}),
}
).extend(OPTIONS_SCHEMA.schema)
CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = {
"user": HelperFlowFormStep(CONFIG_SCHEMA)
}
OPTIONS_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = {
"init": HelperFlowFormStep(OPTIONS_SCHEMA)
}
class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow for Min/Max."""
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["name"]) if "name" in options else ""

View file

@ -0,0 +1,6 @@
"""Constants for the Min/Max integration."""
DOMAIN = "min_max"
CONF_ENTITY_IDS = "entity_ids"
CONF_ROUND_DIGITS = "round_digits"

View file

@ -1,8 +1,12 @@
{
"domain": "min_max",
"integration_type": "helper",
"name": "Min/Max",
"documentation": "https://www.home-assistant.io/integrations/min_max",
"codeowners": ["@fabaff"],
"codeowners": [
"@fabaff"
],
"quality_scale": "internal",
"iot_class": "local_push"
"iot_class": "local_push",
"config_flow": true
}

View file

@ -6,6 +6,7 @@ import logging
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
@ -13,14 +14,15 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, PLATFORMS
from . import PLATFORMS
from .const import CONF_ENTITY_IDS, CONF_ROUND_DIGITS, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -46,9 +48,6 @@ ATTR_TO_PROPERTY = [
ATTR_LAST_ENTITY_ID,
]
CONF_ENTITY_IDS = "entity_ids"
CONF_ROUND_DIGITS = "round_digits"
ICON = "mdi:calculator"
SENSOR_TYPES = {
@ -71,6 +70,32 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize min/max/mean config entry."""
registry = er.async_get(hass)
entity_ids = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITY_IDS]
)
sensor_type = config_entry.options[CONF_TYPE]
round_digits = int(config_entry.options[CONF_ROUND_DIGITS])
async_add_entities(
[
MinMaxSensor(
entity_ids,
config_entry.title,
sensor_type,
round_digits,
config_entry.entry_id,
)
]
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -85,7 +110,9 @@ async def async_setup_platform(
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
async_add_entities([MinMaxSensor(entity_ids, name, sensor_type, round_digits)])
async_add_entities(
[MinMaxSensor(entity_ids, name, sensor_type, round_digits, None)]
)
def calc_min(sensor_values):
@ -148,8 +175,9 @@ def calc_median(sensor_values, round_digits):
class MinMaxSensor(SensorEntity):
"""Representation of a min/max sensor."""
def __init__(self, entity_ids, name, sensor_type, round_digits):
def __init__(self, entity_ids, name, sensor_type, round_digits, unique_id):
"""Initialize the min/max sensor."""
self._attr_unique_id = unique_id
self._entity_ids = entity_ids
self._sensor_type = sensor_type
self._round_digits = round_digits
@ -173,6 +201,12 @@ class MinMaxSensor(SensorEntity):
)
)
# Replay current state of source entities
for entity_id in self._entity_ids:
state = self.hass.states.get(entity_id)
state_event = Event("", {"entity_id": entity_id, "new_state": state})
self._async_min_max_sensor_state_listener(state_event, update_state=False)
self._calc_values()
@property
@ -216,16 +250,24 @@ class MinMaxSensor(SensorEntity):
return ICON
@callback
def _async_min_max_sensor_state_listener(self, event):
def _async_min_max_sensor_state_listener(self, event, update_state=True):
"""Handle the sensor state changes."""
new_state = event.data.get("new_state")
entity = event.data.get("entity_id")
if new_state.state is None or new_state.state in [
STATE_UNKNOWN,
STATE_UNAVAILABLE,
]:
if (
new_state is None
or new_state.state is None
or new_state.state
in [
STATE_UNKNOWN,
STATE_UNAVAILABLE,
]
):
self.states[entity] = STATE_UNKNOWN
if not update_state:
return
self._calc_values()
self.async_write_ha_state()
return
@ -252,6 +294,9 @@ class MinMaxSensor(SensorEntity):
"Unable to store state. Only numerical states are supported"
)
if not update_state:
return
self._calc_values()
self.async_write_ha_state()

View file

@ -0,0 +1,28 @@
{
"title": "Min / max / mean / median sensor",
"config": {
"step": {
"user": {
"description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median.",
"data": {
"entity_ids": "Input entities",
"name": "Name",
"round_digits": "Precision",
"type": "Statistic characteristic"
}
}
}
},
"options": {
"step": {
"options": {
"description": "[%key:component::min_max::config::step::user::description%]",
"data": {
"entity_ids": "[%key:component::min_max::config::step::user::data::entity_ids%]",
"round_digits": "[%key:component::min_max::config::step::user::data::round_digits%]",
"type": "[%key:component::min_max::config::step::user::data::type%]"
}
}
}
}
}

View file

@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"data": {
"entity_ids": "Input entities",
"name": "Name",
"round_digits": "Precision",
"type": "Statistic characteristic"
},
"description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median."
}
}
},
"options": {
"step": {
"options": {
"data": {
"entity_ids": "Input entities",
"round_digits": "Precision",
"type": "Statistic characteristic"
},
"description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median."
}
}
},
"title": "Min / max / mean / median sensor"
}

View file

@ -403,6 +403,7 @@ FLOWS = {
],
"helper": [
"derivative",
"min_max",
"tod"
]
}

View file

@ -0,0 +1,135 @@
"""Test the Min/Max config flow."""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.min_max.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
from tests.common import MockConfigEntry
@pytest.mark.parametrize("platform", ("sensor",))
async def test_config_flow(hass: HomeAssistant, platform) -> None:
"""Test the config flow."""
input_sensors = ["sensor.input_one", "sensor.input_two"]
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with patch(
"homeassistant.components.min_max.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"name": "My min_max", "entity_ids": input_sensors, "type": "max"},
)
await hass.async_block_till_done()
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "My min_max"
assert result["data"] == {}
assert result["options"] == {
"entity_ids": input_sensors,
"name": "My min_max",
"round_digits": 2.0,
"type": "max",
}
assert len(mock_setup_entry.mock_calls) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.options == {
"entity_ids": input_sensors,
"name": "My min_max",
"round_digits": 2.0,
"type": "max",
}
assert config_entry.title == "My min_max"
def get_suggested(schema, key):
"""Get suggested value for key in voluptuous schema."""
for k in schema.keys():
if k == key:
if k.description is None or "suggested_value" not in k.description:
return None
return k.description["suggested_value"]
# Wanted key absent from schema
raise Exception
@pytest.mark.parametrize("platform", ("sensor",))
async def test_options(hass: HomeAssistant, platform) -> None:
"""Test reconfiguring."""
hass.states.async_set("sensor.input_one", "10")
hass.states.async_set("sensor.input_two", "20")
hass.states.async_set("sensor.input_three", "33.33")
input_sensors1 = ["sensor.input_one", "sensor.input_two"]
input_sensors2 = ["sensor.input_one", "sensor.input_two", "sensor.input_three"]
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"entity_ids": input_sensors1,
"name": "My min_max",
"round_digits": 0,
"type": "min",
},
title="My min_max",
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "init"
schema = result["data_schema"].schema
assert get_suggested(schema, "entity_ids") == input_sensors1
assert get_suggested(schema, "round_digits") == 0
assert get_suggested(schema, "type") == "min"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entity_ids": input_sensors2,
"round_digits": 1,
"type": "mean",
},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
"entity_ids": input_sensors2,
"name": "My min_max",
"round_digits": 1,
"type": "mean",
}
assert config_entry.data == {}
assert config_entry.options == {
"entity_ids": input_sensors2,
"name": "My min_max",
"round_digits": 1,
"type": "mean",
}
assert config_entry.title == "My min_max"
# Check config entry is reloaded with new options
await hass.async_block_till_done()
# Check the entity was updated, no new entity was created
assert len(hass.states.async_all()) == 4
# Check the state of the entity has changed as expected
state = hass.states.get(f"{platform}.my_min_max")
assert state.state == "21.1"
assert state.attributes["count_sensors"] == 3

View file

@ -0,0 +1,55 @@
"""Test the Min/Max integration."""
import pytest
from homeassistant.components.min_max.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@pytest.mark.parametrize("platform", ("sensor",))
async def test_setup_and_remove_config_entry(
hass: HomeAssistant,
platform: str,
) -> None:
"""Test setting up and removing a config entry."""
hass.states.async_set("sensor.input_one", "10")
hass.states.async_set("sensor.input_two", "20")
input_sensors = ["sensor.input_one", "sensor.input_two"]
registry = er.async_get(hass)
min_max_entity_id = f"{platform}.my_min_max"
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"entity_ids": input_sensors,
"name": "My min_max",
"round_digits": 2.0,
"type": "max",
},
title="My min_max",
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# Check the entity is registered in the entity registry
assert registry.async_get(min_max_entity_id) is not None
# Check the platform is setup correctly
state = hass.states.get(min_max_entity_id)
assert state.state == "20.0"
assert state.attributes["count_sensors"] == 2
# Remove the config entry
assert await hass.config_entries.async_remove(config_entry.entry_id)
await hass.async_block_till_done()
# Check the state and entity registry entry are removed
assert hass.states.get(min_max_entity_id) is None
assert registry.async_get(min_max_entity_id) is None

View file

@ -3,7 +3,7 @@ import statistics
from unittest.mock import patch
from homeassistant import config as hass_config
from homeassistant.components.min_max import DOMAIN
from homeassistant.components.min_max.const import DOMAIN
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
PERCENTAGE,