Skip to content

Commit 86d7fdf

Browse files
authored
Allow history_stats to configure state_class: total_increasing (home-assistant#148637)
1 parent 676c42d commit 86d7fdf

8 files changed

Lines changed: 156 additions & 9 deletions

File tree

homeassistant/components/history_stats/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import timedelta
66
import logging
77

8+
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
89
from homeassistant.config_entries import ConfigEntry
910
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
1011
from homeassistant.core import HomeAssistant
@@ -105,6 +106,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
105106
hass.config_entries.async_update_entry(
106107
config_entry, options=options, minor_version=2
107108
)
109+
if config_entry.minor_version < 3:
110+
# Set the state class to measurement for backward compatibility
111+
options[CONF_STATE_CLASS] = SensorStateClass.MEASUREMENT
112+
hass.config_entries.async_update_entry(
113+
config_entry, options=options, minor_version=3
114+
)
108115

109116
_LOGGER.debug(
110117
"Migration to version %s.%s successful",

homeassistant/components/history_stats/config_flow.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import voluptuous as vol
1010

1111
from homeassistant.components import websocket_api
12+
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
1213
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
1314
from homeassistant.core import HomeAssistant, callback
1415
from homeassistant.exceptions import HomeAssistantError
@@ -39,6 +40,7 @@
3940
CONF_PERIOD_KEYS,
4041
CONF_START,
4142
CONF_TYPE_KEYS,
43+
CONF_TYPE_RATIO,
4244
CONF_TYPE_TIME,
4345
DEFAULT_NAME,
4446
DOMAIN,
@@ -101,10 +103,19 @@ async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
101103
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
102104
"""Return schema for options step."""
103105
entity_id = handler.options[CONF_ENTITY_ID]
104-
return _get_options_schema_with_entity_id(entity_id)
105-
106-
107-
def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
106+
conf_type = handler.options[CONF_TYPE]
107+
return _get_options_schema_with_entity_id(entity_id, conf_type)
108+
109+
110+
def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
111+
state_class_options = (
112+
[SensorStateClass.MEASUREMENT]
113+
if type == CONF_TYPE_RATIO
114+
else [
115+
SensorStateClass.MEASUREMENT,
116+
SensorStateClass.TOTAL_INCREASING,
117+
]
118+
)
108119
return vol.Schema(
109120
{
110121
vol.Optional(CONF_ENTITY_ID): EntitySelector(
@@ -130,6 +141,13 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
130141
vol.Optional(CONF_DURATION): DurationSelector(
131142
DurationSelectorConfig(enable_day=True, allow_negative=False)
132143
),
144+
vol.Optional(CONF_STATE_CLASS): SelectSelector(
145+
SelectSelectorConfig(
146+
options=state_class_options,
147+
translation_key=CONF_STATE_CLASS,
148+
mode=SelectSelectorMode.DROPDOWN,
149+
),
150+
),
133151
}
134152
)
135153

@@ -158,7 +176,7 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
158176
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
159177
"""Handle a config flow for History stats."""
160178

161-
MINOR_VERSION = 2
179+
MINOR_VERSION = 3
162180

163181
config_flow = CONFIG_FLOW
164182
options_flow = OPTIONS_FLOW
@@ -201,13 +219,15 @@ async def ws_start_preview(
201219
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
202220
entity_id = options[CONF_ENTITY_ID]
203221
name = options[CONF_NAME]
222+
conf_type = options[CONF_TYPE]
204223
else:
205224
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
206225
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
207226
if not config_entry:
208227
raise HomeAssistantError("Config entry not found")
209228
entity_id = config_entry.options[CONF_ENTITY_ID]
210229
name = config_entry.options[CONF_NAME]
230+
conf_type = config_entry.options[CONF_TYPE]
211231

212232
@callback
213233
def async_preview_updated(
@@ -233,7 +253,7 @@ def async_preview_updated(
233253

234254
validated_data: Any = None
235255
try:
236-
validated_data = (_get_options_schema_with_entity_id(entity_id))(
256+
validated_data = (_get_options_schema_with_entity_id(entity_id, conf_type))(
237257
msg["user_input"]
238258
)
239259
except vol.Invalid as ex:
@@ -255,6 +275,7 @@ def async_preview_updated(
255275
start = validated_data.get(CONF_START)
256276
end = validated_data.get(CONF_END)
257277
duration = validated_data.get(CONF_DURATION)
278+
state_class = validated_data.get(CONF_STATE_CLASS)
258279

259280
history_stats = HistoryStats(
260281
hass,
@@ -274,6 +295,7 @@ def async_preview_updated(
274295
name=name,
275296
unique_id=None,
276297
source_entity_id=entity_id,
298+
state_class=state_class,
277299
)
278300
preview_entity.hass = hass
279301

homeassistant/components/history_stats/sensor.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import voluptuous as vol
1111

1212
from homeassistant.components.sensor import (
13+
CONF_STATE_CLASS,
1314
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
1415
SensorDeviceClass,
1516
SensorEntity,
@@ -72,6 +73,16 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
7273
return conf
7374

7475

76+
def no_ratio_total[_T: dict[str, Any]](conf: _T) -> _T:
77+
"""Ensure state_class:total_increasing not used with type:ratio."""
78+
if (
79+
conf.get(CONF_TYPE) == CONF_TYPE_RATIO
80+
and conf.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
81+
):
82+
raise vol.Invalid("State class total_increasing not to be used with type ratio")
83+
return conf
84+
85+
7586
PLATFORM_SCHEMA = vol.All(
7687
SENSOR_PLATFORM_SCHEMA.extend(
7788
{
@@ -83,9 +94,15 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
8394
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
8495
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
8596
vol.Optional(CONF_UNIQUE_ID): cv.string,
97+
vol.Optional(
98+
CONF_STATE_CLASS, default=SensorStateClass.MEASUREMENT
99+
): vol.In(
100+
[None, SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL_INCREASING]
101+
),
86102
}
87103
),
88104
exactly_two_period_keys,
105+
no_ratio_total,
89106
)
90107

91108

@@ -106,6 +123,9 @@ async def async_setup_platform(
106123
sensor_type: str = config[CONF_TYPE]
107124
name: str = config[CONF_NAME]
108125
unique_id: str | None = config.get(CONF_UNIQUE_ID)
126+
state_class: SensorStateClass | None = config.get(
127+
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
128+
)
109129

110130
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
111131
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
@@ -121,6 +141,7 @@ async def async_setup_platform(
121141
name=name,
122142
unique_id=unique_id,
123143
source_entity_id=entity_id,
144+
state_class=state_class,
124145
)
125146
]
126147
)
@@ -136,6 +157,7 @@ async def async_setup_entry(
136157
sensor_type: str = entry.options[CONF_TYPE]
137158
coordinator = entry.runtime_data
138159
entity_id: str = entry.options[CONF_ENTITY_ID]
160+
state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS)
139161
async_add_entities(
140162
[
141163
HistoryStatsSensor(
@@ -145,6 +167,7 @@ async def async_setup_entry(
145167
name=entry.title,
146168
unique_id=entry.entry_id,
147169
source_entity_id=entity_id,
170+
state_class=state_class,
148171
)
149172
]
150173
)
@@ -185,8 +208,6 @@ def _process_update(self) -> None:
185208
class HistoryStatsSensor(HistoryStatsSensorBase):
186209
"""A HistoryStats sensor."""
187210

188-
_attr_state_class = SensorStateClass.MEASUREMENT
189-
190211
def __init__(
191212
self,
192213
hass: HomeAssistant,
@@ -196,6 +217,7 @@ def __init__(
196217
name: str,
197218
unique_id: str | None,
198219
source_entity_id: str,
220+
state_class: SensorStateClass | None,
199221
) -> None:
200222
"""Initialize the HistoryStats sensor."""
201223
super().__init__(coordinator, name)
@@ -204,6 +226,7 @@ def __init__(
204226
) = None
205227
self._attr_native_unit_of_measurement = UNITS[sensor_type]
206228
self._type = sensor_type
229+
self._attr_state_class = state_class
207230
self._attr_unique_id = unique_id
208231
if source_entity_id: # Guard against empty source_entity_id in preview mode
209232
self.device_entry = async_entity_id_to_device(

homeassistant/components/history_stats/strings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
1515
"start": "Start",
1616
"state": "[%key:component::history_stats::config::step::user::data::state%]",
17+
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
1718
"type": "[%key:component::history_stats::config::step::user::data::type%]"
1819
},
1920
"data_description": {
@@ -22,6 +23,7 @@
2223
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
2324
"start": "When to start the measure (timestamp or datetime). Can be a template.",
2425
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
26+
"state_class": "The state class for statistics calculation.",
2527
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
2628
},
2729
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
@@ -68,6 +70,7 @@
6870
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
6971
"start": "[%key:component::history_stats::config::step::options::data::start%]",
7072
"state": "[%key:component::history_stats::config::step::user::data::state%]",
73+
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
7174
"type": "[%key:component::history_stats::config::step::user::data::type%]"
7275
},
7376
"data_description": {
@@ -76,13 +79,20 @@
7679
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
7780
"start": "[%key:component::history_stats::config::step::options::data_description::start%]",
7881
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
82+
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
7983
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
8084
},
8185
"description": "[%key:component::history_stats::config::step::options::description%]"
8286
}
8387
}
8488
},
8589
"selector": {
90+
"state_class": {
91+
"options": {
92+
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
93+
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
94+
}
95+
},
8696
"type": {
8797
"options": {
8898
"count": "Count",

tests/components/history_stats/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DEFAULT_NAME,
1616
DOMAIN,
1717
)
18+
from homeassistant.components.sensor import CONF_STATE_CLASS
1819
from homeassistant.config_entries import SOURCE_USER
1920
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
2021
from homeassistant.core import HomeAssistant, State
@@ -48,6 +49,7 @@ async def get_config_to_integration_load() -> dict[str, Any]:
4849
CONF_TYPE: "count",
4950
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
5051
CONF_END: "{{ utcnow() }}",
52+
CONF_STATE_CLASS: "measurement",
5153
}
5254

5355

tests/components/history_stats/test_config_flow.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DOMAIN,
1818
)
1919
from homeassistant.components.recorder import Recorder
20+
from homeassistant.components.sensor import CONF_STATE_CLASS
2021
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
2122
from homeassistant.core import HomeAssistant, State
2223
from homeassistant.data_entry_flow import FlowResultType
@@ -91,6 +92,7 @@ async def test_options_flow(
9192
user_input={
9293
CONF_END: "{{ utcnow() }}",
9394
CONF_DURATION: {"hours": 8, "minutes": 0, "seconds": 0, "days": 20},
95+
CONF_STATE_CLASS: "total_increasing",
9496
},
9597
)
9698
await hass.async_block_till_done()
@@ -103,6 +105,7 @@ async def test_options_flow(
103105
CONF_TYPE: "count",
104106
CONF_END: "{{ utcnow() }}",
105107
CONF_DURATION: {"hours": 8, "minutes": 0, "seconds": 0, "days": 20},
108+
CONF_STATE_CLASS: "total_increasing",
106109
}
107110

108111
await hass.async_block_till_done()
@@ -387,6 +390,7 @@ def _fake_states(*args, **kwargs):
387390
CONF_STATE: ["on"],
388391
CONF_END: "{{ now() }}",
389392
CONF_START: "{{ today_at() }}",
393+
CONF_STATE_CLASS: "measurement",
390394
},
391395
title=DEFAULT_NAME,
392396
)
@@ -422,6 +426,7 @@ def _fake_states(*args, **kwargs):
422426
CONF_STATE: ["on"],
423427
CONF_END: end,
424428
CONF_START: "{{ today_at() }}",
429+
CONF_STATE_CLASS: "measurement",
425430
},
426431
}
427432
)

tests/components/history_stats/test_init.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DEFAULT_NAME,
1717
DOMAIN,
1818
)
19+
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
1920
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
2021
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
2122
from homeassistant.core import Event, HomeAssistant, callback
@@ -419,7 +420,58 @@ async def test_migration_1_1(
419420
assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id
420421

421422
assert history_stats_config_entry.version == 1
422-
assert history_stats_config_entry.minor_version == 2
423+
assert (
424+
history_stats_config_entry.minor_version
425+
== HistoryStatsConfigFlowHandler.MINOR_VERSION
426+
)
427+
428+
429+
@pytest.mark.usefixtures("recorder_mock")
430+
async def test_migration_1_2(
431+
hass: HomeAssistant,
432+
device_registry: dr.DeviceRegistry,
433+
entity_registry: er.EntityRegistry,
434+
sensor_entity_entry: er.RegistryEntry,
435+
sensor_device: dr.DeviceEntry,
436+
) -> None:
437+
"""Test migration from v1.2 sets state_class to measurement."""
438+
439+
history_stats_config_entry = MockConfigEntry(
440+
data={},
441+
domain=DOMAIN,
442+
options={
443+
CONF_NAME: DEFAULT_NAME,
444+
CONF_ENTITY_ID: sensor_entity_entry.entity_id,
445+
CONF_STATE: ["on"],
446+
CONF_TYPE: "count",
447+
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
448+
CONF_END: "{{ utcnow() }}",
449+
},
450+
title="My history stats",
451+
version=1,
452+
minor_version=2,
453+
)
454+
history_stats_config_entry.add_to_hass(hass)
455+
await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
456+
await hass.async_block_till_done()
457+
458+
assert history_stats_config_entry.state is ConfigEntryState.LOADED
459+
460+
assert (
461+
history_stats_config_entry.options.get(CONF_STATE_CLASS)
462+
== SensorStateClass.MEASUREMENT
463+
)
464+
assert history_stats_config_entry.version == 1
465+
assert (
466+
history_stats_config_entry.minor_version
467+
== HistoryStatsConfigFlowHandler.MINOR_VERSION
468+
)
469+
470+
assert hass.states.get("sensor.my_history_stats") is not None
471+
assert (
472+
hass.states.get("sensor.my_history_stats").attributes.get(CONF_STATE_CLASS)
473+
== SensorStateClass.MEASUREMENT
474+
)
423475

424476

425477
@pytest.mark.usefixtures("recorder_mock")

0 commit comments

Comments
 (0)