diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index a6419c2fb4d..70739c618f7 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,6 +1,27 @@ """The statistics component.""" +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Statistics 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 Statistics 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/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py new file mode 100644 index 00000000000..773c3d1c364 --- /dev/null +++ b/homeassistant/components/statistics/config_flow.py @@ -0,0 +1,150 @@ +"""Config flow for statistics.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import split_entity_id +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + DurationSelector, + DurationSelectorConfig, + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from . import DOMAIN +from .sensor import ( + CONF_KEEP_LAST_SAMPLE, + CONF_MAX_AGE, + CONF_PERCENTILE, + CONF_PRECISION, + CONF_SAMPLES_MAX_BUFFER_SIZE, + CONF_STATE_CHARACTERISTIC, + DEFAULT_NAME, + DEFAULT_PRECISION, + STATS_BINARY_SUPPORT, + STATS_NUMERIC_SUPPORT, +) + + +async def get_state_characteristics(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return schema with state characteristics.""" + is_binary = ( + split_entity_id(handler.options[CONF_ENTITY_ID])[0] == BINARY_SENSOR_DOMAIN + ) + if is_binary: + options = STATS_BINARY_SUPPORT + else: + options = STATS_NUMERIC_SUPPORT + + return vol.Schema( + { + vol.Required(CONF_STATE_CHARACTERISTIC): SelectSelector( + SelectSelectorConfig( + options=list(options), + translation_key=CONF_STATE_CHARACTERISTIC, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + if ( + user_input.get(CONF_SAMPLES_MAX_BUFFER_SIZE) is None + and user_input.get(CONF_MAX_AGE) is None + ): + raise SchemaFlowError("missing_max_age_or_sampling_size") + + if ( + user_input.get(CONF_KEEP_LAST_SAMPLE) is True + and user_input.get(CONF_MAX_AGE) is None + ): + raise SchemaFlowError("missing_keep_last_sample") + + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(domain=[BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN]) + ), + } +) +DATA_SCHEMA_OPTIONS = vol.Schema( + { + vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_MAX_AGE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + vol.Optional(CONF_KEEP_LAST_SAMPLE, default=False): BooleanSelector(), + vol.Optional(CONF_PERCENTILE, default=50): NumberSelector( + NumberSelectorConfig(min=1, max=99, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_SETUP, + next_step="state_characteristic", + ), + "state_characteristic": SchemaFlowFormStep( + schema=get_state_characteristics, next_step="options" + ), + "options": SchemaFlowFormStep( + schema=DATA_SCHEMA_OPTIONS, + validate_user_input=validate_options, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + DATA_SCHEMA_OPTIONS, + validate_user_input=validate_options, + ), +} + + +class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Statistics.""" + + 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/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 04b5277ecf5..24d4b4914cb 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -3,7 +3,9 @@ "name": "Statistics", "after_dependencies": ["recorder"], "codeowners": ["@ThomDietrich"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/statistics", + "integration_type": "helper", "iot_class": "local_polling", "quality_scale": "internal" } diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index eb4df4d98b2..8d28254ad61 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -22,6 +22,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -282,6 +283,42 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Statistics sensor entry.""" + sampling_size = entry.options.get(CONF_SAMPLES_MAX_BUFFER_SIZE) + if sampling_size: + sampling_size = int(sampling_size) + + max_age = None + if max_age_input := entry.options.get(CONF_MAX_AGE): + max_age = timedelta( + hours=max_age_input["hours"], + minutes=max_age_input["minutes"], + seconds=max_age_input["seconds"], + ) + + async_add_entities( + [ + StatisticsSensor( + source_entity_id=entry.options[CONF_ENTITY_ID], + name=entry.options[CONF_NAME], + unique_id=entry.entry_id, + state_characteristic=entry.options[CONF_STATE_CHARACTERISTIC], + samples_max_buffer_size=sampling_size, + samples_max_age=max_age, + samples_keep_last=entry.options[CONF_KEEP_LAST_SAMPLE], + precision=int(entry.options[CONF_PRECISION]), + percentile=int(entry.options[CONF_PERCENTILE]), + ) + ], + True, + ) + + class StatisticsSensor(SensorEntity): """Representation of a Statistics sensor.""" diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index 6d7bda36fae..5f32b203bfd 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -1,4 +1,115 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "missing_max_age_or_sampling_size": "The sensor configuration must provide 'max_age' and/or 'sampling_size'", + "missing_keep_last_sample": "The sensor configuration must provide 'max_age' if 'keep_last_sample' is True" + }, + "step": { + "user": { + "description": "Add a statistics sensor", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to get statistics from." + } + }, + "state_characteristic": { + "description": "Read the documention for further details on available options and how to use them.", + "data": { + "state_characteristic": "State_characteristic" + }, + "data_description": { + "state_characteristic": "The characteristic that should be used as the state of the statistics sensor." + } + }, + "options": { + "description": "Read the documention for further details on how to configure the statistics sensor using these options.", + "data": { + "sampling_size": "Sampling size", + "max_age": "Max age", + "keep_last_sample": "Keep last sample", + "percentile": "Percentile", + "precision": "Precision" + }, + "data_description": { + "sampling_size": "Maximum number of source sensor measurements stored.", + "max_age": "Maximum age of source sensor measurements stored.", + "keep_last_sample": "Defines whether the most recent sampled value should be preserved regardless of the 'max age' setting.", + "percentile": "Only relevant in combination with the 'percentile' characteristic. Must be a value between 1 and 99.", + "precision": "Defines the number of decimal places of the calculated sensor value." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "missing_max_age_or_sampling_size": "[%key:component::statistics::config::error::missing_max_age_or_sampling_size%]", + "missing_keep_last_sample": "[%key:component::statistics::config::error::missing_keep_last_sample%]" + }, + "step": { + "init": { + "description": "[%key:component::statistics::config::step::options::description%]", + "data": { + "sampling_size": "[%key:component::statistics::config::step::options::data::sampling_size%]", + "max_age": "[%key:component::statistics::config::step::options::data::max_age%]", + "keep_last_sample": "[%key:component::statistics::config::step::options::data::keep_last_sample%]", + "percentile": "[%key:component::statistics::config::step::options::data::percentile%]", + "precision": "[%key:component::statistics::config::step::options::data::precision%]" + }, + "data_description": { + "sampling_size": "[%key:component::statistics::config::step::options::data_description::sampling_size%]", + "max_age": "[%key:component::statistics::config::step::options::data_description::max_age%]", + "keep_last_sample": "[%key:component::statistics::config::step::options::data_description::keep_last_sample%]", + "percentile": "[%key:component::statistics::config::step::options::data_description::percentile%]", + "precision": "[%key:component::statistics::config::step::options::data_description::precision%]" + } + } + } + }, + "selector": { + "state_characteristic": { + "options": { + "average_linear": "Average linear", + "average_step": "Average step", + "average_timeless": "Average timeless", + "change": "Change", + "change_sample": "Change sample", + "change_second": "Change second", + "count": "Count", + "count_on": "Count on", + "count_off": "Count off", + "datetime_newest": "Newest datetime", + "datetime_oldest": "Oldest datetime", + "datetime_value_max": "Max value datetime", + "datetime_value_min": "Min value datetime", + "distance_95_percent_of_values": "Distance 95% of values", + "distance_99_percent_of_values": "Distance 99% of values", + "distance_absolute": "Absolute distance", + "mean": "Mean", + "mean_circular": "Mean circular", + "median": "Median", + "noisiness": "Noisiness", + "percentile": "Percentile", + "standard_deviation": "Standard deviation", + "sum": "Sum", + "sum_differences": "Sum of differences", + "sum_differences_nonnegative": "Sum of differences non-negative", + "total": "Total", + "value_max": "Max value", + "value_min": "Min value", + "variance": "Variance" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e5eeeb29403..23a13bcbfd8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ FLOWS = { "integration", "min_max", "random", + "statistics", "switch_as_x", "template", "threshold", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e98df79d096..3371c8de0fa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5772,12 +5772,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "statistics": { - "name": "Statistics", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "statsd": { "name": "StatsD", "integration_type": "hub", @@ -7213,6 +7207,12 @@ "integration_type": "helper", "config_flow": false }, + "statistics": { + "name": "Statistics", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "switch_as_x": { "integration_type": "helper", "config_flow": true, diff --git a/tests/components/statistics/conftest.py b/tests/components/statistics/conftest.py new file mode 100644 index 00000000000..e62488c4cf6 --- /dev/null +++ b/tests/components/statistics/conftest.py @@ -0,0 +1,90 @@ +"""Fixtures for the Statistics 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.statistics import DOMAIN +from homeassistant.components.statistics.sensor import ( + CONF_KEEP_LAST_SAMPLE, + CONF_MAX_AGE, + CONF_PERCENTILE, + CONF_PRECISION, + CONF_SAMPLES_MAX_BUFFER_SIZE, + CONF_STATE_CHARACTERISTIC, + DEFAULT_NAME, + STAT_AVERAGE_LINEAR, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant + +from .test_sensor import VALUES_NUMERIC + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.statistics.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_ENTITY_ID: "sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 5, "seconds": 5}, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 2.0, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any] +) -> MockConfigEntry: + """Set up the Statistics integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py new file mode 100644 index 00000000000..7c9ed5bed47 --- /dev/null +++ b/tests/components/statistics/test_config_flow.py @@ -0,0 +1,273 @@ +"""Test the Scrape config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.statistics import DOMAIN +from homeassistant.components.statistics.sensor import ( + CONF_KEEP_LAST_SAMPLE, + CONF_MAX_AGE, + CONF_PERCENTILE, + CONF_PRECISION, + CONF_SAMPLES_MAX_BUFFER_SIZE, + CONF_STATE_CHARACTERISTIC, + DEFAULT_NAME, + STAT_AVERAGE_LINEAR, + STAT_COUNT, +) +from homeassistant.const import CONF_ENTITY_ID, 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": config_entries.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_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 2.0, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_binary_sensor( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form for binary sensor.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.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_ENTITY_ID: "binary_sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE_CHARACTERISTIC: STAT_COUNT, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "binary_sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_COUNT, + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 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_SAMPLES_MAX_BUFFER_SIZE: 25.0, + CONF_MAX_AGE: {"hours": 16, "minutes": 0, "seconds": 0}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + CONF_SAMPLES_MAX_BUFFER_SIZE: 25.0, + CONF_MAX_AGE: {"hours": 16, "minutes": 0, "seconds": 0}, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 2.0, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.statistical_characteristic") + assert state is not None + + +async def test_validation_options( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test validation.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.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_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "options" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_max_age_or_sampling_size"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_KEEP_LAST_SAMPLE: True, CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0}, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "options" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_keep_last_sample"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0}, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 2.0, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +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": config_entries.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_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE_CHARACTERISTIC: STAT_AVERAGE_LINEAR, + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_MAX_AGE: {"hours": 8, "minutes": 5, "seconds": 5}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py new file mode 100644 index 00000000000..6cb943c0687 --- /dev/null +++ b/tests/components/statistics/test_init.py @@ -0,0 +1,17 @@ +"""Test Statistics 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/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 5a716fd8ce8..269c17e34b9 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -19,10 +19,20 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.components.statistics import DOMAIN as STATISTICS_DOMAIN -from homeassistant.components.statistics.sensor import StatisticsSensor +from homeassistant.components.statistics.sensor import ( + CONF_KEEP_LAST_SAMPLE, + CONF_PERCENTILE, + CONF_PRECISION, + CONF_SAMPLES_MAX_BUFFER_SIZE, + CONF_STATE_CHARACTERISTIC, + STAT_MEAN, + StatisticsSensor, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, DEGREE, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -35,7 +45,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed, get_fixture_path +from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] @@ -171,6 +181,35 @@ async def test_sensor_defaults_numeric(hass: HomeAssistant) -> None: assert new_state.attributes.get("source_value_valid") is False +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: "test", + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_STATE_CHARACTERISTIC: STAT_MEAN, + CONF_SAMPLES_MAX_BUFFER_SIZE: 20.0, + CONF_KEEP_LAST_SAMPLE: False, + CONF_PERCENTILE: 50.0, + CONF_PRECISION: 2.0, + } + ], +) +async def test_sensor_loaded_from_config_entry( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test the sensor loaded from a config entry.""" + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + assert state.attributes.get("source_value_valid") is True + assert "age_coverage_ratio" not in state.attributes + + async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: """Test the general behavior of the sensor, with binary source sensor.""" assert await async_setup_component(