Add preview to statistics (#122590)

This commit is contained in:
G Johansson 2024-09-27 21:09:42 +02:00 committed by GitHub
parent 2e1732fadf
commit 2ff88e7baf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 387 additions and 5 deletions

View File

@ -3,14 +3,17 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from datetime import timedelta
from typing import Any, cast from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
from homeassistant.core import split_entity_id from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler, SchemaCommonFlowHandler,
SchemaConfigFlowHandler, SchemaConfigFlowHandler,
@ -44,6 +47,7 @@ from .sensor import (
DEFAULT_PRECISION, DEFAULT_PRECISION,
STATS_BINARY_SUPPORT, STATS_BINARY_SUPPORT,
STATS_NUMERIC_SUPPORT, STATS_NUMERIC_SUPPORT,
StatisticsSensor,
) )
@ -129,12 +133,14 @@ CONFIG_FLOW = {
"options": SchemaFlowFormStep( "options": SchemaFlowFormStep(
schema=DATA_SCHEMA_OPTIONS, schema=DATA_SCHEMA_OPTIONS,
validate_user_input=validate_options, validate_user_input=validate_options,
preview="statistics",
), ),
} }
OPTIONS_FLOW = { OPTIONS_FLOW = {
"init": SchemaFlowFormStep( "init": SchemaFlowFormStep(
DATA_SCHEMA_OPTIONS, DATA_SCHEMA_OPTIONS,
validate_user_input=validate_options, validate_user_input=validate_options,
preview="statistics",
), ),
} }
@ -148,3 +154,86 @@ class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
def async_config_entry_title(self, options: Mapping[str, Any]) -> str: def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title.""" """Return config entry title."""
return cast(str, options[CONF_NAME]) return cast(str, options[CONF_NAME])
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
@websocket_api.websocket_command(
{
vol.Required("type"): "statistics/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@callback
def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a preview."""
if msg["flow_type"] == "config_flow":
flow_status = hass.config_entries.flow.async_get(msg["flow_id"])
flow_sets = hass.config_entries.flow._handler_progress_index.get( # noqa: SLF001
flow_status["handler"]
)
options = {}
assert flow_sets
for active_flow in flow_sets:
options = active_flow._common_handler.options # type: ignore [attr-defined] # noqa: SLF001
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
entity_id = options[CONF_ENTITY_ID]
name = options[CONF_NAME]
state_characteristic = options[CONF_STATE_CHARACTERISTIC]
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
if not config_entry:
raise HomeAssistantError("Config entry not found")
entity_id = config_entry.options[CONF_ENTITY_ID]
name = config_entry.options[CONF_NAME]
state_characteristic = config_entry.options[CONF_STATE_CHARACTERISTIC]
@callback
def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None:
"""Forward config entry state events to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"], {"attributes": attributes, "state": state}
)
)
sampling_size = msg["user_input"].get(CONF_SAMPLES_MAX_BUFFER_SIZE)
if sampling_size:
sampling_size = int(sampling_size)
max_age = None
if max_age_input := msg["user_input"].get(CONF_MAX_AGE):
max_age = timedelta(
hours=max_age_input["hours"],
minutes=max_age_input["minutes"],
seconds=max_age_input["seconds"],
)
preview_entity = StatisticsSensor(
hass,
entity_id,
name,
None,
state_characteristic,
sampling_size,
max_age,
msg["user_input"].get(CONF_KEEP_LAST_SAMPLE),
msg["user_input"].get(CONF_PRECISION),
msg["user_input"].get(CONF_PERCENTILE),
)
preview_entity.hass = hass
connection.send_result(msg["id"])
connection.subscriptions[msg["id"]] = preview_entity.async_start_preview(
async_preview_updated
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import deque
from collections.abc import Callable from collections.abc import Callable, Mapping
import contextlib import contextlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
@ -371,6 +371,29 @@ class StatisticsSensor(SensorEntity):
) )
self._update_listener: CALLBACK_TYPE | None = None self._update_listener: CALLBACK_TYPE | None = None
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
@callback
def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
# abort early if there is no entity_id
# as without we can't track changes
# or either size or max_age is not set
if not self._source_entity_id or (
self._samples_max_buffer_size is None and self._samples_max_age is None
):
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
return self._call_on_remove_callbacks
self._preview_callback = preview_callback
self._async_stats_sensor_startup(self.hass)
return self._call_on_remove_callbacks
@callback @callback
def _async_stats_sensor_state_listener( def _async_stats_sensor_state_listener(
@ -382,6 +405,12 @@ class StatisticsSensor(SensorEntity):
return return
self._add_state_to_queue(new_state) self._add_state_to_queue(new_state)
self._async_purge_update_and_schedule() self._async_purge_update_and_schedule()
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
# only write state to the state machine if we are not in preview mode
if not self._preview_callback:
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
@ -604,6 +633,8 @@ class StatisticsSensor(SensorEntity):
_LOGGER.debug("%s: executing scheduled update", self.entity_id) _LOGGER.debug("%s: executing scheduled update", self.entity_id)
self._async_cancel_update_listener() self._async_cancel_update_listener()
self._async_purge_update_and_schedule() self._async_purge_update_and_schedule()
# only write state to the state machine if we are not in preview mode
if not self._preview_callback:
self.async_write_ha_state() self.async_write_ha_state()
def _fetch_states_from_database(self) -> list[State]: def _fetch_states_from_database(self) -> list[State]:
@ -648,6 +679,12 @@ class StatisticsSensor(SensorEntity):
self._add_state_to_queue(state) self._add_state_to_queue(state)
self._async_purge_update_and_schedule() self._async_purge_update_and_schedule()
# only write state to the state machine if we are not in preview mode
if self._preview_callback:
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
else:
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug("%s: initializing from database completed", self.entity_id) _LOGGER.debug("%s: initializing from database completed", self.entity_id)

View File

@ -0,0 +1,49 @@
# serializer version: 1
# name: test_config_flow_preview_success[missing_size_and_age]
dict({
'attributes': dict({
'friendly_name': 'Statistical characteristic',
'icon': 'mdi:calculator',
'state_class': 'measurement',
}),
'state': 'unavailable',
})
# ---
# name: test_config_flow_preview_success[success]
dict({
'attributes': dict({
'buffer_usage_ratio': 0.1,
'friendly_name': 'Statistical characteristic',
'icon': 'mdi:calculator',
'source_value_valid': True,
'state_class': 'measurement',
}),
'state': '16.0',
})
# ---
# name: test_options_flow_preview
dict({
'attributes': dict({
'age_coverage_ratio': 0.0,
'buffer_usage_ratio': 0.05,
'friendly_name': 'Statistical characteristic',
'icon': 'mdi:calculator',
'source_value_valid': True,
'state_class': 'measurement',
}),
'state': '16.0',
})
# ---
# name: test_options_flow_preview[updated]
dict({
'attributes': dict({
'age_coverage_ratio': 0.0,
'buffer_usage_ratio': 0.1,
'friendly_name': 'Statistical characteristic',
'icon': 'mdi:calculator',
'source_value_valid': True,
'state_class': 'measurement',
}),
'state': '20.0',
})
# ---

View File

@ -4,7 +4,11 @@ from __future__ import annotations
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
from syrupy import SnapshotAssertion
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.recorder import Recorder
from homeassistant.components.statistics import DOMAIN from homeassistant.components.statistics import DOMAIN
from homeassistant.components.statistics.sensor import ( from homeassistant.components.statistics.sensor import (
CONF_KEEP_LAST_SAMPLE, CONF_KEEP_LAST_SAMPLE,
@ -16,12 +20,14 @@ from homeassistant.components.statistics.sensor import (
DEFAULT_NAME, DEFAULT_NAME,
STAT_AVERAGE_LINEAR, STAT_AVERAGE_LINEAR,
STAT_COUNT, STAT_COUNT,
STAT_VALUE_MAX,
) )
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
@ -271,3 +277,204 @@ async def test_entry_already_exist(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"user_input",
[
(
{
CONF_SAMPLES_MAX_BUFFER_SIZE: 10.0,
CONF_KEEP_LAST_SAMPLE: False,
CONF_PERCENTILE: 50,
CONF_PRECISION: 2,
}
),
(
{
CONF_KEEP_LAST_SAMPLE: False,
CONF_PERCENTILE: 50,
CONF_PRECISION: 2,
}
),
],
ids=("success", "missing_size_and_age"),
)
async def test_config_flow_preview_success(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
user_input: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test the config flow preview."""
client = await hass_ws_client(hass)
# add state for the tests
hass.states.async_set("sensor.test_monitored", "16")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] is None
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_VALUE_MAX,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "options"
assert result["errors"] is None
assert result["preview"] == "statistics"
await client.send_json_auto_id(
{
"type": "statistics/start_preview",
"flow_id": result["flow_id"],
"flow_type": "config_flow",
"user_input": user_input,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
msg = await client.receive_json()
assert msg["event"] == snapshot
assert len(hass.states.async_all()) == 1
async def test_options_flow_preview(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the options flow preview."""
client = await hass_ws_client(hass)
# add state for the tests
hass.states.async_set("sensor.test_monitored", "16")
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
CONF_NAME: DEFAULT_NAME,
CONF_ENTITY_ID: "sensor.test_monitored",
CONF_STATE_CHARACTERISTIC: STAT_VALUE_MAX,
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,
},
title=DEFAULT_NAME,
)
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"] == FlowResultType.FORM
assert result["errors"] is None
assert result["preview"] == "statistics"
await client.send_json_auto_id(
{
"type": "statistics/start_preview",
"flow_id": result["flow_id"],
"flow_type": "options_flow",
"user_input": {
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,
},
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
msg = await client.receive_json()
assert msg["event"] == snapshot
assert len(hass.states.async_all()) == 2
# add state for the tests
hass.states.async_set("sensor.test_monitored", "20")
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["event"] == snapshot(name="updated")
async def test_options_flow_sensor_preview_config_entry_removed(
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test the option flow preview where the config entry is removed."""
client = await hass_ws_client(hass)
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
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,
},
title=DEFAULT_NAME,
)
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"] == FlowResultType.FORM
assert result["errors"] is None
assert result["preview"] == "statistics"
await hass.config_entries.async_remove(config_entry.entry_id)
await client.send_json_auto_id(
{
"type": "statistics/start_preview",
"flow_id": result["flow_id"],
"flow_type": "options_flow",
"user_input": {
CONF_SAMPLES_MAX_BUFFER_SIZE: 25.0,
CONF_MAX_AGE: {"hours": 8, "minutes": 0, "seconds": 0},
CONF_KEEP_LAST_SAMPLE: False,
CONF_PERCENTILE: 50.0,
CONF_PRECISION: 2.0,
},
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"] == {
"code": "home_assistant_error",
"message": "Config entry not found",
}