Allow history_stats to configure state_class: total_increasing (#148637)

This commit is contained in:
karwosts
2026-02-19 01:16:47 -08:00
committed by GitHub
parent 676c42d578
commit 86d7fdfe1e
8 changed files with 154 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
from homeassistant.core import HomeAssistant
@@ -105,6 +106,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
)
if config_entry.minor_version < 3:
# Set the state class to measurement for backward compatibility
options[CONF_STATE_CLASS] = SensorStateClass.MEASUREMENT
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=3
)
_LOGGER.debug(
"Migration to version %s.%s successful",

View File

@@ -9,6 +9,7 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -39,6 +40,7 @@ from .const import (
CONF_PERIOD_KEYS,
CONF_START,
CONF_TYPE_KEYS,
CONF_TYPE_RATIO,
CONF_TYPE_TIME,
DEFAULT_NAME,
DOMAIN,
@@ -101,10 +103,19 @@ async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for options step."""
entity_id = handler.options[CONF_ENTITY_ID]
return _get_options_schema_with_entity_id(entity_id)
conf_type = handler.options[CONF_TYPE]
return _get_options_schema_with_entity_id(entity_id, conf_type)
def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
state_class_options = (
[SensorStateClass.MEASUREMENT]
if type == CONF_TYPE_RATIO
else [
SensorStateClass.MEASUREMENT,
SensorStateClass.TOTAL_INCREASING,
]
)
return vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): EntitySelector(
@@ -130,6 +141,13 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
vol.Optional(CONF_DURATION): DurationSelector(
DurationSelectorConfig(enable_day=True, allow_negative=False)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=state_class_options,
translation_key=CONF_STATE_CLASS,
mode=SelectSelectorMode.DROPDOWN,
),
),
}
)
@@ -158,7 +176,7 @@ OPTIONS_FLOW = {
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for History stats."""
MINOR_VERSION = 2
MINOR_VERSION = 3
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
@@ -201,6 +219,7 @@ async def ws_start_preview(
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
entity_id = options[CONF_ENTITY_ID]
name = options[CONF_NAME]
conf_type = options[CONF_TYPE]
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
@@ -208,6 +227,7 @@ async def ws_start_preview(
raise HomeAssistantError("Config entry not found")
entity_id = config_entry.options[CONF_ENTITY_ID]
name = config_entry.options[CONF_NAME]
conf_type = config_entry.options[CONF_TYPE]
@callback
def async_preview_updated(
@@ -233,7 +253,7 @@ async def ws_start_preview(
validated_data: Any = None
try:
validated_data = (_get_options_schema_with_entity_id(entity_id))(
validated_data = (_get_options_schema_with_entity_id(entity_id, conf_type))(
msg["user_input"]
)
except vol.Invalid as ex:
@@ -255,6 +275,7 @@ async def ws_start_preview(
start = validated_data.get(CONF_START)
end = validated_data.get(CONF_END)
duration = validated_data.get(CONF_DURATION)
state_class = validated_data.get(CONF_STATE_CLASS)
history_stats = HistoryStats(
hass,
@@ -274,6 +295,7 @@ async def ws_start_preview(
name=name,
unique_id=None,
source_entity_id=entity_id,
state_class=state_class,
)
preview_entity.hass = hass

View File

@@ -10,6 +10,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
@@ -72,6 +73,16 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
return conf
def no_ratio_total[_T: dict[str, Any]](conf: _T) -> _T:
"""Ensure state_class:total_increasing not used with type:ratio."""
if (
conf.get(CONF_TYPE) == CONF_TYPE_RATIO
and conf.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
):
raise vol.Invalid("State class total_increasing not to be used with type ratio")
return conf
PLATFORM_SCHEMA = vol.All(
SENSOR_PLATFORM_SCHEMA.extend(
{
@@ -83,9 +94,15 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(
CONF_STATE_CLASS, default=SensorStateClass.MEASUREMENT
): vol.In(
[None, SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL_INCREASING]
),
}
),
exactly_two_period_keys,
no_ratio_total,
)
@@ -106,6 +123,9 @@ async def async_setup_platform(
sensor_type: str = config[CONF_TYPE]
name: str = config[CONF_NAME]
unique_id: str | None = config.get(CONF_UNIQUE_ID)
state_class: SensorStateClass | None = config.get(
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
)
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
@@ -121,6 +141,7 @@ async def async_setup_platform(
name=name,
unique_id=unique_id,
source_entity_id=entity_id,
state_class=state_class,
)
]
)
@@ -136,6 +157,7 @@ async def async_setup_entry(
sensor_type: str = entry.options[CONF_TYPE]
coordinator = entry.runtime_data
entity_id: str = entry.options[CONF_ENTITY_ID]
state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS)
async_add_entities(
[
HistoryStatsSensor(
@@ -145,6 +167,7 @@ async def async_setup_entry(
name=entry.title,
unique_id=entry.entry_id,
source_entity_id=entity_id,
state_class=state_class,
)
]
)
@@ -185,8 +208,6 @@ class HistoryStatsSensorBase(
class HistoryStatsSensor(HistoryStatsSensorBase):
"""A HistoryStats sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
hass: HomeAssistant,
@@ -196,6 +217,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
name: str,
unique_id: str | None,
source_entity_id: str,
state_class: SensorStateClass | None,
) -> None:
"""Initialize the HistoryStats sensor."""
super().__init__(coordinator, name)
@@ -204,6 +226,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
) = None
self._attr_native_unit_of_measurement = UNITS[sensor_type]
self._type = sensor_type
self._attr_state_class = state_class
self._attr_unique_id = unique_id
if source_entity_id: # Guard against empty source_entity_id in preview mode
self.device_entry = async_entity_id_to_device(

View File

@@ -14,6 +14,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
"start": "Start",
"state": "[%key:component::history_stats::config::step::user::data::state%]",
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
"type": "[%key:component::history_stats::config::step::user::data::type%]"
},
"data_description": {
@@ -22,6 +23,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
"start": "When to start the measure (timestamp or datetime). Can be a template.",
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
"state_class": "The state class for statistics calculation.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
@@ -68,6 +70,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
"start": "[%key:component::history_stats::config::step::options::data::start%]",
"state": "[%key:component::history_stats::config::step::user::data::state%]",
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
"type": "[%key:component::history_stats::config::step::user::data::type%]"
},
"data_description": {
@@ -76,6 +79,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
"start": "[%key:component::history_stats::config::step::options::data_description::start%]",
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "[%key:component::history_stats::config::step::options::description%]"
@@ -83,6 +87,12 @@
}
},
"selector": {
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"type": {
"options": {
"count": "Count",

View File

@@ -15,6 +15,7 @@ from homeassistant.components.history_stats.const import (
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, State
@@ -48,6 +49,7 @@ async def get_config_to_integration_load() -> dict[str, Any]:
CONF_TYPE: "count",
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
CONF_END: "{{ utcnow() }}",
CONF_STATE_CLASS: "measurement",
}

View File

@@ -17,6 +17,7 @@ from homeassistant.components.history_stats.const import (
DOMAIN,
)
from homeassistant.components.recorder import Recorder
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, State
from homeassistant.data_entry_flow import FlowResultType
@@ -91,6 +92,7 @@ async def test_options_flow(
user_input={
CONF_END: "{{ utcnow() }}",
CONF_DURATION: {"hours": 8, "minutes": 0, "seconds": 0, "days": 20},
CONF_STATE_CLASS: "total_increasing",
},
)
await hass.async_block_till_done()
@@ -103,6 +105,7 @@ async def test_options_flow(
CONF_TYPE: "count",
CONF_END: "{{ utcnow() }}",
CONF_DURATION: {"hours": 8, "minutes": 0, "seconds": 0, "days": 20},
CONF_STATE_CLASS: "total_increasing",
}
await hass.async_block_till_done()
@@ -387,6 +390,7 @@ async def test_options_flow_preview(
CONF_STATE: ["on"],
CONF_END: "{{ now() }}",
CONF_START: "{{ today_at() }}",
CONF_STATE_CLASS: "measurement",
},
title=DEFAULT_NAME,
)
@@ -422,6 +426,7 @@ async def test_options_flow_preview(
CONF_STATE: ["on"],
CONF_END: end,
CONF_START: "{{ today_at() }}",
CONF_STATE_CLASS: "measurement",
},
}
)

View File

@@ -16,6 +16,7 @@ from homeassistant.components.history_stats.const import (
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import Event, HomeAssistant, callback
@@ -419,7 +420,58 @@ async def test_migration_1_1(
assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id
assert history_stats_config_entry.version == 1
assert history_stats_config_entry.minor_version == 2
assert (
history_stats_config_entry.minor_version
== HistoryStatsConfigFlowHandler.MINOR_VERSION
)
@pytest.mark.usefixtures("recorder_mock")
async def test_migration_1_2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
sensor_entity_entry: er.RegistryEntry,
sensor_device: dr.DeviceEntry,
) -> None:
"""Test migration from v1.2 sets state_class to measurement."""
history_stats_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
CONF_NAME: DEFAULT_NAME,
CONF_ENTITY_ID: sensor_entity_entry.entity_id,
CONF_STATE: ["on"],
CONF_TYPE: "count",
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
CONF_END: "{{ utcnow() }}",
},
title="My history stats",
version=1,
minor_version=2,
)
history_stats_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
await hass.async_block_till_done()
assert history_stats_config_entry.state is ConfigEntryState.LOADED
assert (
history_stats_config_entry.options.get(CONF_STATE_CLASS)
== SensorStateClass.MEASUREMENT
)
assert history_stats_config_entry.version == 1
assert (
history_stats_config_entry.minor_version
== HistoryStatsConfigFlowHandler.MINOR_VERSION
)
assert hass.states.get("sensor.my_history_stats") is not None
assert (
hass.states.get("sensor.my_history_stats").attributes.get(CONF_STATE_CLASS)
== SensorStateClass.MEASUREMENT
)
@pytest.mark.usefixtures("recorder_mock")

View File

@@ -120,6 +120,16 @@ async def test_setup_multiple_states(
"end": "{{ utcnow() }}",
"duration": "01:00",
},
{
"platform": "history_stats",
"entity_id": "binary_sensor.test_id",
"name": "Test",
"state": "on",
"start": "{{ as_timestamp(utcnow()) - 3600 }}",
"end": "{{ utcnow() }}",
"type": "ratio",
"state_class": "total_increasing",
},
],
)
@pytest.mark.usefixtures("hass")
@@ -321,6 +331,7 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) ->
"start": "{{ as_timestamp(utcnow()) - 3600 }}",
"end": "{{ utcnow() }}",
"type": "time",
"state_class": "measurement",
},
{
"platform": "history_stats",
@@ -330,6 +341,7 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) ->
"start": "{{ as_timestamp(utcnow()) - 3600 }}",
"end": "{{ utcnow() }}",
"type": "time",
"state_class": "total_increasing",
},
{
"platform": "history_stats",
@@ -362,6 +374,20 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) ->
assert hass.states.get("sensor.sensor3").state == "2"
assert hass.states.get("sensor.sensor4").state == "50.0"
assert (
hass.states.get("sensor.sensor1").attributes.get("state_class") == "measurement"
)
assert (
hass.states.get("sensor.sensor2").attributes.get("state_class")
== "total_increasing"
)
assert (
hass.states.get("sensor.sensor3").attributes.get("state_class") == "measurement"
)
assert (
hass.states.get("sensor.sensor4").attributes.get("state_class") == "measurement"
)
async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test the history statistics sensor measure."""