Skip to content

Commit 8cd2c1b

Browse files
MindFreezeCopilot
andauthored
Add power configuration to Energy dashboard (home-assistant#153809)
Co-authored-by: Copilot <[email protected]>
1 parent 4471178 commit 8cd2c1b

File tree

6 files changed

+656
-58
lines changed

6 files changed

+656
-58
lines changed

homeassistant/components/energy/data.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
from collections import Counter
77
from collections.abc import Awaitable, Callable
8-
from typing import Literal, TypedDict
8+
from typing import Literal, NotRequired, TypedDict
99

1010
import voluptuous as vol
1111

@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
2929
class FlowFromGridSourceType(TypedDict):
3030
"""Dictionary describing the 'from' stat for the grid source."""
3131

32-
# statistic_id of a an energy meter (kWh)
32+
# statistic_id of an energy meter (kWh)
3333
stat_energy_from: str
3434

3535
# statistic_id of costs ($) incurred from the energy meter
@@ -58,13 +58,22 @@ class FlowToGridSourceType(TypedDict):
5858
number_energy_price: float | None # Price for energy ($/kWh)
5959

6060

61+
class GridPowerSourceType(TypedDict):
62+
"""Dictionary holding the source of grid power consumption."""
63+
64+
# statistic_id of a power meter (kW)
65+
# negative values indicate grid return
66+
stat_rate: str
67+
68+
6169
class GridSourceType(TypedDict):
6270
"""Dictionary holding the source of grid energy consumption."""
6371

6472
type: Literal["grid"]
6573

6674
flow_from: list[FlowFromGridSourceType]
6775
flow_to: list[FlowToGridSourceType]
76+
power: NotRequired[list[GridPowerSourceType]]
6877

6978
cost_adjustment_day: float
7079

@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
7584
type: Literal["solar"]
7685

7786
stat_energy_from: str
87+
stat_rate: NotRequired[str]
7888
config_entry_solar_forecast: list[str] | None
7989

8090

@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
8595

8696
stat_energy_from: str
8797
stat_energy_to: str
98+
# positive when discharging, negative when charging
99+
stat_rate: NotRequired[str]
88100

89101

90102
class GasSourceType(TypedDict):
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
136148
# This is an ever increasing value
137149
stat_consumption: str
138150

151+
# Instantaneous rate of flow: W, L/min or m³/h
152+
stat_rate: NotRequired[str]
153+
139154
# An optional custom name for display in energy graphs
140155
name: str | None
141156

142157
# An optional statistic_id identifying a device
143158
# that includes this device's consumption in its total
144-
included_in_stat: str | None
159+
included_in_stat: NotRequired[str]
145160

146161

147162
class EnergyPreferences(TypedDict):
@@ -194,6 +209,12 @@ def _flow_from_ensure_single_price(
194209
}
195210
)
196211

212+
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
213+
{
214+
vol.Required("stat_rate"): str,
215+
}
216+
)
217+
197218

198219
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
199220
"""Generate a validator that ensures a value is only used once."""
@@ -224,13 +245,18 @@ def validate_uniqueness(
224245
[FLOW_TO_GRID_SOURCE_SCHEMA],
225246
_generate_unique_value_validator("stat_energy_to"),
226247
),
248+
vol.Optional("power"): vol.All(
249+
[GRID_POWER_SOURCE_SCHEMA],
250+
_generate_unique_value_validator("stat_rate"),
251+
),
227252
vol.Required("cost_adjustment_day"): vol.Coerce(float),
228253
}
229254
)
230255
SOLAR_SOURCE_SCHEMA = vol.Schema(
231256
{
232257
vol.Required("type"): "solar",
233258
vol.Required("stat_energy_from"): str,
259+
vol.Optional("stat_rate"): str,
234260
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
235261
}
236262
)
@@ -239,6 +265,7 @@ def validate_uniqueness(
239265
vol.Required("type"): "battery",
240266
vol.Required("stat_energy_from"): str,
241267
vol.Required("stat_energy_to"): str,
268+
vol.Optional("stat_rate"): str,
242269
}
243270
)
244271
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +321,7 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]:
294321
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
295322
{
296323
vol.Required("stat_consumption"): str,
324+
vol.Optional("stat_rate"): str,
297325
vol.Optional("name"): str,
298326
vol.Optional("included_in_stat"): str,
299327
}

homeassistant/components/energy/validate.py

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
STATE_UNAVAILABLE,
1313
STATE_UNKNOWN,
1414
UnitOfEnergy,
15+
UnitOfPower,
1516
UnitOfVolume,
1617
)
1718
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -23,12 +24,17 @@
2324
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
2425
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
2526
}
27+
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
28+
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
29+
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
30+
}
2631

2732
ENERGY_PRICE_UNITS = tuple(
2833
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
2934
)
3035
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
3136
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
37+
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
3238
GAS_USAGE_DEVICE_CLASSES = (
3339
sensor.SensorDeviceClass.ENERGY,
3440
sensor.SensorDeviceClass.GAS,
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
8288
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
8389
),
8490
}
91+
if issue_type == POWER_UNIT_ERROR:
92+
return {
93+
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
94+
}
8595
if issue_type == GAS_UNIT_ERROR:
8696
return {
8797
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,45 +169,49 @@ def as_dict(self) -> dict:
159169

160170

161171
@callback
162-
def _async_validate_usage_stat(
172+
def _async_validate_stat_common(
163173
hass: HomeAssistant,
164174
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
165175
stat_id: str,
166176
allowed_device_classes: Sequence[str],
167177
allowed_units: Mapping[str, Sequence[str]],
168178
unit_error: str,
169179
issues: ValidationIssues,
170-
) -> None:
171-
"""Validate a statistic."""
180+
check_negative: bool = False,
181+
) -> str | None:
182+
"""Validate common aspects of a statistic.
183+
184+
Returns the entity_id if validation succeeds, None otherwise.
185+
"""
172186
if stat_id not in metadata:
173187
issues.add_issue(hass, "statistics_not_defined", stat_id)
174188

175189
has_entity_source = valid_entity_id(stat_id)
176190

177191
if not has_entity_source:
178-
return
192+
return None
179193

180194
entity_id = stat_id
181195

182196
if not recorder.is_entity_recorded(hass, entity_id):
183197
issues.add_issue(hass, "recorder_untracked", entity_id)
184-
return
198+
return None
185199

186200
if (state := hass.states.get(entity_id)) is None:
187201
issues.add_issue(hass, "entity_not_defined", entity_id)
188-
return
202+
return None
189203

190204
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
191205
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
192-
return
206+
return None
193207

194208
try:
195209
current_value: float | None = float(state.state)
196210
except ValueError:
197211
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
198-
return
212+
return None
199213

200-
if current_value is not None and current_value < 0:
214+
if check_negative and current_value is not None and current_value < 0:
201215
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
202216

203217
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
211225
if device_class and unit not in allowed_units.get(device_class, []):
212226
issues.add_issue(hass, unit_error, entity_id, unit)
213227

228+
return entity_id
229+
230+
231+
@callback
232+
def _async_validate_usage_stat(
233+
hass: HomeAssistant,
234+
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
235+
stat_id: str,
236+
allowed_device_classes: Sequence[str],
237+
allowed_units: Mapping[str, Sequence[str]],
238+
unit_error: str,
239+
issues: ValidationIssues,
240+
) -> None:
241+
"""Validate a statistic."""
242+
entity_id = _async_validate_stat_common(
243+
hass,
244+
metadata,
245+
stat_id,
246+
allowed_device_classes,
247+
allowed_units,
248+
unit_error,
249+
issues,
250+
check_negative=True,
251+
)
252+
253+
if entity_id is None:
254+
return
255+
256+
state = hass.states.get(entity_id)
257+
assert state is not None
214258
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
215259

216260
allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
255299
issues.add_issue(hass, unit_error, entity_id, unit)
256300

257301

302+
@callback
303+
def _async_validate_power_stat(
304+
hass: HomeAssistant,
305+
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
306+
stat_id: str,
307+
allowed_device_classes: Sequence[str],
308+
allowed_units: Mapping[str, Sequence[str]],
309+
unit_error: str,
310+
issues: ValidationIssues,
311+
) -> None:
312+
"""Validate a power statistic."""
313+
entity_id = _async_validate_stat_common(
314+
hass,
315+
metadata,
316+
stat_id,
317+
allowed_device_classes,
318+
allowed_units,
319+
unit_error,
320+
issues,
321+
check_negative=False,
322+
)
323+
324+
if entity_id is None:
325+
return
326+
327+
state = hass.states.get(entity_id)
328+
assert state is not None
329+
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
330+
331+
if state_class != sensor.SensorStateClass.MEASUREMENT:
332+
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
333+
334+
258335
@callback
259336
def _async_validate_cost_stat(
260337
hass: HomeAssistant,
@@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
434511
)
435512
)
436513

514+
for power_stat in source.get("power", []):
515+
wanted_statistics_metadata.add(power_stat["stat_rate"])
516+
validate_calls.append(
517+
functools.partial(
518+
_async_validate_power_stat,
519+
hass,
520+
statistics_metadata,
521+
power_stat["stat_rate"],
522+
POWER_USAGE_DEVICE_CLASSES,
523+
POWER_USAGE_UNITS,
524+
POWER_UNIT_ERROR,
525+
source_result,
526+
)
527+
)
528+
437529
elif source["type"] == "gas":
438530
wanted_statistics_metadata.add(source["stat_energy_from"])
439531
validate_calls.append(
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Fixtures for energy component tests."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from homeassistant.components.energy import async_get_manager
8+
from homeassistant.components.energy.data import EnergyManager
9+
from homeassistant.components.recorder import Recorder
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.setup import async_setup_component
12+
13+
14+
@pytest.fixture
15+
def mock_is_entity_recorded():
16+
"""Mock recorder.is_entity_recorded."""
17+
mocks = {}
18+
19+
with patch(
20+
"homeassistant.components.recorder.is_entity_recorded",
21+
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
22+
):
23+
yield mocks
24+
25+
26+
@pytest.fixture
27+
def mock_get_metadata():
28+
"""Mock recorder.statistics.get_metadata."""
29+
mocks = {}
30+
31+
def _get_metadata(_hass, *, statistic_ids):
32+
result = {}
33+
for statistic_id in statistic_ids:
34+
if statistic_id in mocks:
35+
if mocks[statistic_id] is not None:
36+
result[statistic_id] = mocks[statistic_id]
37+
else:
38+
result[statistic_id] = (1, {})
39+
return result
40+
41+
with patch(
42+
"homeassistant.components.recorder.statistics.get_metadata",
43+
wraps=_get_metadata,
44+
):
45+
yield mocks
46+
47+
48+
@pytest.fixture
49+
async def mock_energy_manager(
50+
recorder_mock: Recorder, hass: HomeAssistant
51+
) -> EnergyManager:
52+
"""Set up energy."""
53+
assert await async_setup_component(hass, "energy", {"energy": {}})
54+
manager = await async_get_manager(hass)
55+
manager.data = manager.default_preferences()
56+
return manager

0 commit comments

Comments
 (0)