1 """Validate the energy preferences provide valid data."""
3 from __future__
import annotations
5 from collections.abc
import Mapping, Sequence
20 from .const
import DOMAIN
22 ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
23 ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
24 sensor.SensorDeviceClass.ENERGY: (
25 UnitOfEnergy.GIGA_JOULE,
26 UnitOfEnergy.KILO_WATT_HOUR,
27 UnitOfEnergy.MEGA_JOULE,
28 UnitOfEnergy.MEGA_WATT_HOUR,
29 UnitOfEnergy.WATT_HOUR,
33 f
"/{unit}" for units
in ENERGY_USAGE_UNITS.values()
for unit
in units
35 ENERGY_UNIT_ERROR =
"entity_unexpected_unit_energy"
36 ENERGY_PRICE_UNIT_ERROR =
"entity_unexpected_unit_energy_price"
37 GAS_USAGE_DEVICE_CLASSES = (
38 sensor.SensorDeviceClass.ENERGY,
39 sensor.SensorDeviceClass.GAS,
41 GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = {
42 sensor.SensorDeviceClass.ENERGY: (
43 UnitOfEnergy.GIGA_JOULE,
44 UnitOfEnergy.KILO_WATT_HOUR,
45 UnitOfEnergy.MEGA_JOULE,
46 UnitOfEnergy.MEGA_WATT_HOUR,
47 UnitOfEnergy.WATT_HOUR,
49 sensor.SensorDeviceClass.GAS: (
50 UnitOfVolume.CENTUM_CUBIC_FEET,
51 UnitOfVolume.CUBIC_FEET,
52 UnitOfVolume.CUBIC_METERS,
56 f
"/{unit}" for units
in GAS_USAGE_UNITS.values()
for unit
in units
58 GAS_UNIT_ERROR =
"entity_unexpected_unit_gas"
59 GAS_PRICE_UNIT_ERROR =
"entity_unexpected_unit_gas_price"
60 WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,)
61 WATER_USAGE_UNITS: dict[str, tuple[UnitOfVolume, ...]] = {
62 sensor.SensorDeviceClass.WATER: (
63 UnitOfVolume.CENTUM_CUBIC_FEET,
64 UnitOfVolume.CUBIC_FEET,
65 UnitOfVolume.CUBIC_METERS,
71 f
"/{unit}" for units
in WATER_USAGE_UNITS.values()
for unit
in units
73 WATER_UNIT_ERROR =
"entity_unexpected_unit_water"
74 WATER_PRICE_UNIT_ERROR =
"entity_unexpected_unit_water_price"
78 currency = hass.config.currency
79 if issue_type == ENERGY_UNIT_ERROR:
81 "energy_units":
", ".join(
82 ENERGY_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]
85 if issue_type == ENERGY_PRICE_UNIT_ERROR:
87 "price_units":
", ".join(
88 f
"{currency}{unit}" for unit
in ENERGY_PRICE_UNITS
91 if issue_type == GAS_UNIT_ERROR:
93 "energy_units":
", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
94 "gas_units":
", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.GAS]),
96 if issue_type == GAS_PRICE_UNIT_ERROR:
98 "price_units":
", ".join(f
"{currency}{unit}" for unit
in GAS_PRICE_UNITS),
100 if issue_type == WATER_UNIT_ERROR:
102 "water_units":
", ".join(WATER_USAGE_UNITS[sensor.SensorDeviceClass.WATER]),
104 if issue_type == WATER_PRICE_UNIT_ERROR:
106 "price_units":
", ".join(f
"{currency}{unit}" for unit
in WATER_PRICE_UNITS),
111 @dataclasses.dataclass(slots=True)
113 """Error or warning message."""
116 affected_entities: set[tuple[str, float | str |
None]] = dataclasses.field(
119 translation_placeholders: dict[str, str] |
None =
None
122 @dataclasses.dataclass(slots=True)
124 """Container for validation issues."""
126 issues: dict[str, ValidationIssue] = dataclasses.field(default_factory=dict)
129 """Container for validiation issues."""
136 affected_entity: str,
137 detail: float | str |
None =
None,
139 """Add an issue for an entity."""
140 if not (issue := self.
issuesissues.
get(issue_type)):
143 issue.affected_entities.add((affected_entity, detail))
146 @dataclasses.dataclass(slots=True)
148 """Dictionary holding validation information."""
150 energy_sources: list[ValidationIssues] = dataclasses.field(default_factory=list)
151 device_consumption: list[ValidationIssues] = dataclasses.field(default_factory=list)
154 """Return dictionary version."""
157 [dataclasses.asdict(issue)
for issue
in issues.issues.values()]
158 for issues
in self.energy_sources
160 "device_consumption": [
161 [dataclasses.asdict(issue)
for issue
in issues.issues.values()]
162 for issues
in self.device_consumption
172 allowed_device_classes: Sequence[str],
173 allowed_units: Mapping[str, Sequence[str]],
175 issues: ValidationIssues,
177 """Validate a statistic."""
178 if stat_id
not in metadata:
179 issues.add_issue(hass,
"statistics_not_defined", stat_id)
183 if not has_entity_source:
188 if not recorder.is_entity_recorded(hass, entity_id):
189 issues.add_issue(hass,
"recorder_untracked", entity_id)
192 if (state := hass.states.get(entity_id))
is None:
193 issues.add_issue(hass,
"entity_not_defined", entity_id)
196 if state.state
in (STATE_UNAVAILABLE, STATE_UNKNOWN):
197 issues.add_issue(hass,
"entity_unavailable", entity_id, state.state)
201 current_value: float |
None =
float(state.state)
203 issues.add_issue(hass,
"entity_state_non_numeric", entity_id, state.state)
206 if current_value
is not None and current_value < 0:
207 issues.add_issue(hass,
"entity_negative_state", entity_id, current_value)
209 device_class = state.attributes.get(ATTR_DEVICE_CLASS)
210 if device_class
not in allowed_device_classes:
212 hass,
"entity_unexpected_device_class", entity_id, device_class
215 unit = state.attributes.get(
"unit_of_measurement")
217 if device_class
and unit
not in allowed_units.get(device_class, []):
218 issues.add_issue(hass, unit_error, entity_id, unit)
220 state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
222 allowed_state_classes = [
223 sensor.SensorStateClass.MEASUREMENT,
224 sensor.SensorStateClass.TOTAL,
225 sensor.SensorStateClass.TOTAL_INCREASING,
227 if state_class
not in allowed_state_classes:
228 issues.add_issue(hass,
"entity_unexpected_state_class", entity_id, state_class)
231 state_class == sensor.SensorStateClass.MEASUREMENT
232 and sensor.ATTR_LAST_RESET
not in state.attributes
235 hass,
"entity_state_class_measurement_no_last_reset", entity_id
243 issues: ValidationIssues,
244 allowed_units: tuple[str, ...],
247 """Validate that the price entity is correct."""
248 if (state := hass.states.get(entity_id))
is None:
249 issues.add_issue(hass,
"entity_not_defined", entity_id)
255 issues.add_issue(hass,
"entity_state_non_numeric", entity_id, state.state)
258 unit = state.attributes.get(
"unit_of_measurement")
260 if unit
is None or not unit.endswith(allowed_units):
261 issues.add_issue(hass, unit_error, entity_id, unit)
269 issues: ValidationIssues,
271 """Validate that the cost stat is correct."""
272 if stat_id
not in metadata:
273 issues.add_issue(hass,
"statistics_not_defined", stat_id)
280 if not recorder.is_entity_recorded(hass, stat_id):
281 issues.add_issue(hass,
"recorder_untracked", stat_id)
283 if (state := hass.states.get(stat_id))
is None:
284 issues.add_issue(hass,
"entity_not_defined", stat_id)
287 state_class = state.attributes.get(
"state_class")
289 supported_state_classes = [
290 sensor.SensorStateClass.MEASUREMENT,
291 sensor.SensorStateClass.TOTAL,
292 sensor.SensorStateClass.TOTAL_INCREASING,
294 if state_class
not in supported_state_classes:
295 issues.add_issue(hass,
"entity_unexpected_state_class", stat_id, state_class)
298 state_class == sensor.SensorStateClass.MEASUREMENT
299 and sensor.ATTR_LAST_RESET
not in state.attributes
301 issues.add_issue(hass,
"entity_state_class_measurement_no_last_reset", stat_id)
306 hass: HomeAssistant, energy_entity_id: str, issues: ValidationIssues
308 """Validate that the auto generated cost entity is correct."""
309 if energy_entity_id
not in hass.data[DOMAIN][
"cost_sensors"]:
313 cost_entity_id = hass.data[DOMAIN][
"cost_sensors"][energy_entity_id]
314 if not recorder.is_entity_recorded(hass, cost_entity_id):
315 issues.add_issue(hass,
"recorder_untracked", cost_entity_id)
319 """Validate the energy configuration."""
323 wanted_statistics_metadata: set[str] = set()
327 if manager.data
is None:
331 for source
in manager.data[
"energy_sources"]:
333 result.energy_sources.append(source_result)
335 if source[
"type"] ==
"grid":
337 for flow
in source[
"flow_from"]:
338 wanted_statistics_metadata.add(flow[
"stat_energy_from"])
339 validate_calls.append(
341 _async_validate_usage_stat,
344 flow[
"stat_energy_from"],
345 ENERGY_USAGE_DEVICE_CLASSES,
352 if (stat_cost := flow.get(
"stat_cost"))
is not None:
353 wanted_statistics_metadata.add(stat_cost)
354 validate_calls.append(
356 _async_validate_cost_stat,
364 entity_energy_price := flow.get(
"entity_energy_price")
366 validate_calls.append(
368 _async_validate_price_entity,
373 ENERGY_PRICE_UNIT_ERROR,
378 flow.get(
"entity_energy_price")
is not None
379 or flow.get(
"number_energy_price")
is not None
381 validate_calls.append(
383 _async_validate_auto_generated_cost_entity,
385 flow[
"stat_energy_from"],
390 for flow
in source[
"flow_to"]:
391 wanted_statistics_metadata.add(flow[
"stat_energy_to"])
392 validate_calls.append(
394 _async_validate_usage_stat,
397 flow[
"stat_energy_to"],
398 ENERGY_USAGE_DEVICE_CLASSES,
405 if (stat_compensation := flow.get(
"stat_compensation"))
is not None:
406 wanted_statistics_metadata.add(stat_compensation)
407 validate_calls.append(
409 _async_validate_cost_stat,
417 entity_energy_price := flow.get(
"entity_energy_price")
419 validate_calls.append(
421 _async_validate_price_entity,
426 ENERGY_PRICE_UNIT_ERROR,
431 flow.get(
"entity_energy_price")
is not None
432 or flow.get(
"number_energy_price")
is not None
434 validate_calls.append(
436 _async_validate_auto_generated_cost_entity,
438 flow[
"stat_energy_to"],
443 elif source[
"type"] ==
"gas":
444 wanted_statistics_metadata.add(source[
"stat_energy_from"])
445 validate_calls.append(
447 _async_validate_usage_stat,
450 source[
"stat_energy_from"],
451 GAS_USAGE_DEVICE_CLASSES,
458 if (stat_cost := source.get(
"stat_cost"))
is not None:
459 wanted_statistics_metadata.add(stat_cost)
460 validate_calls.append(
462 _async_validate_cost_stat,
469 elif (entity_energy_price := source.get(
"entity_energy_price"))
is not None:
470 validate_calls.append(
472 _async_validate_price_entity,
477 GAS_PRICE_UNIT_ERROR,
482 source.get(
"entity_energy_price")
is not None
483 or source.get(
"number_energy_price")
is not None
485 validate_calls.append(
487 _async_validate_auto_generated_cost_entity,
489 source[
"stat_energy_from"],
494 elif source[
"type"] ==
"water":
495 wanted_statistics_metadata.add(source[
"stat_energy_from"])
496 validate_calls.append(
498 _async_validate_usage_stat,
501 source[
"stat_energy_from"],
502 WATER_USAGE_DEVICE_CLASSES,
509 if (stat_cost := source.get(
"stat_cost"))
is not None:
510 wanted_statistics_metadata.add(stat_cost)
511 validate_calls.append(
513 _async_validate_cost_stat,
520 elif (entity_energy_price := source.get(
"entity_energy_price"))
is not None:
521 validate_calls.append(
523 _async_validate_price_entity,
528 WATER_PRICE_UNIT_ERROR,
533 source.get(
"entity_energy_price")
is not None
534 or source.get(
"number_energy_price")
is not None
536 validate_calls.append(
538 _async_validate_auto_generated_cost_entity,
540 source[
"stat_energy_from"],
545 elif source[
"type"] ==
"solar":
546 wanted_statistics_metadata.add(source[
"stat_energy_from"])
547 validate_calls.append(
549 _async_validate_usage_stat,
552 source[
"stat_energy_from"],
553 ENERGY_USAGE_DEVICE_CLASSES,
560 elif source[
"type"] ==
"battery":
561 wanted_statistics_metadata.add(source[
"stat_energy_from"])
562 validate_calls.append(
564 _async_validate_usage_stat,
567 source[
"stat_energy_from"],
568 ENERGY_USAGE_DEVICE_CLASSES,
574 wanted_statistics_metadata.add(source[
"stat_energy_to"])
575 validate_calls.append(
577 _async_validate_usage_stat,
580 source[
"stat_energy_to"],
581 ENERGY_USAGE_DEVICE_CLASSES,
588 for device
in manager.data[
"device_consumption"]:
590 result.device_consumption.append(device_result)
591 wanted_statistics_metadata.add(device[
"stat_consumption"])
592 validate_calls.append(
594 _async_validate_usage_stat,
597 device[
"stat_consumption"],
598 ENERGY_USAGE_DEVICE_CLASSES,
606 statistics_metadata.update(
607 await recorder.get_instance(hass).async_add_executor_job(
609 recorder.statistics.get_metadata,
611 statistic_ids=set(wanted_statistics_metadata),
617 for call
in validate_calls:
None add_issue(self, HomeAssistant hass, str issue_type, str affected_entity, float|str|None detail=None)
web.Response get(self, web.Request request, str config_key)
EnergyPreferencesValidation async_validate(HomeAssistant hass)
None _async_validate_usage_stat(HomeAssistant hass, dict[str, tuple[int, recorder.models.StatisticMetaData]] metadata, str stat_id, Sequence[str] allowed_device_classes, Mapping[str, Sequence[str]] allowed_units, str unit_error, ValidationIssues issues)
None _async_validate_cost_stat(HomeAssistant hass, dict[str, tuple[int, recorder.models.StatisticMetaData]] metadata, str stat_id, ValidationIssues issues)
None _async_validate_auto_generated_cost_entity(HomeAssistant hass, str energy_entity_id, ValidationIssues issues)
None _async_validate_price_entity(HomeAssistant hass, str entity_id, ValidationIssues issues, tuple[str,...] allowed_units, str unit_error)
dict[str, str]|None _get_placeholders(HomeAssistant hass, str issue_type)
bool valid_entity_id(str entity_id)