Home Assistant Unofficial Reference 2024.12.1
validate.py
Go to the documentation of this file.
1 """Validate the energy preferences provide valid data."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping, Sequence
6 import dataclasses
7 import functools
8 
9 from homeassistant.components import recorder, sensor
10 from homeassistant.const import (
11  ATTR_DEVICE_CLASS,
12  STATE_UNAVAILABLE,
13  STATE_UNKNOWN,
14  UnitOfEnergy,
15  UnitOfVolume,
16 )
17 from homeassistant.core import HomeAssistant, callback, valid_entity_id
18 
19 from . import data
20 from .const import DOMAIN
21 
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,
30  )
31 }
32 ENERGY_PRICE_UNITS = tuple(
33  f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
34 )
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,
40 )
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,
48  ),
49  sensor.SensorDeviceClass.GAS: (
50  UnitOfVolume.CENTUM_CUBIC_FEET,
51  UnitOfVolume.CUBIC_FEET,
52  UnitOfVolume.CUBIC_METERS,
53  ),
54 }
55 GAS_PRICE_UNITS = tuple(
56  f"/{unit}" for units in GAS_USAGE_UNITS.values() for unit in units
57 )
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,
66  UnitOfVolume.GALLONS,
67  UnitOfVolume.LITERS,
68  ),
69 }
70 WATER_PRICE_UNITS = tuple(
71  f"/{unit}" for units in WATER_USAGE_UNITS.values() for unit in units
72 )
73 WATER_UNIT_ERROR = "entity_unexpected_unit_water"
74 WATER_PRICE_UNIT_ERROR = "entity_unexpected_unit_water_price"
75 
76 
77 def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] | None:
78  currency = hass.config.currency
79  if issue_type == ENERGY_UNIT_ERROR:
80  return {
81  "energy_units": ", ".join(
82  ENERGY_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]
83  ),
84  }
85  if issue_type == ENERGY_PRICE_UNIT_ERROR:
86  return {
87  "price_units": ", ".join(
88  f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
89  ),
90  }
91  if issue_type == GAS_UNIT_ERROR:
92  return {
93  "energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
94  "gas_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.GAS]),
95  }
96  if issue_type == GAS_PRICE_UNIT_ERROR:
97  return {
98  "price_units": ", ".join(f"{currency}{unit}" for unit in GAS_PRICE_UNITS),
99  }
100  if issue_type == WATER_UNIT_ERROR:
101  return {
102  "water_units": ", ".join(WATER_USAGE_UNITS[sensor.SensorDeviceClass.WATER]),
103  }
104  if issue_type == WATER_PRICE_UNIT_ERROR:
105  return {
106  "price_units": ", ".join(f"{currency}{unit}" for unit in WATER_PRICE_UNITS),
107  }
108  return None
109 
110 
111 @dataclasses.dataclass(slots=True)
113  """Error or warning message."""
114 
115  type: str
116  affected_entities: set[tuple[str, float | str | None]] = dataclasses.field(
117  default_factory=set
118  )
119  translation_placeholders: dict[str, str] | None = None
120 
121 
122 @dataclasses.dataclass(slots=True)
124  """Container for validation issues."""
125 
126  issues: dict[str, ValidationIssue] = dataclasses.field(default_factory=dict)
127 
128  def __init__(self) -> None:
129  """Container for validiation issues."""
130  self.issuesissues = {}
131 
133  self,
134  hass: HomeAssistant,
135  issue_type: str,
136  affected_entity: str,
137  detail: float | str | None = None,
138  ) -> None:
139  """Add an issue for an entity."""
140  if not (issue := self.issuesissues.get(issue_type)):
141  self.issuesissues[issue_type] = issue = ValidationIssue(issue_type)
142  issue.translation_placeholders = _get_placeholders(hass, issue_type)
143  issue.affected_entities.add((affected_entity, detail))
144 
145 
146 @dataclasses.dataclass(slots=True)
148  """Dictionary holding validation information."""
149 
150  energy_sources: list[ValidationIssues] = dataclasses.field(default_factory=list)
151  device_consumption: list[ValidationIssues] = dataclasses.field(default_factory=list)
152 
153  def as_dict(self) -> dict:
154  """Return dictionary version."""
155  return {
156  "energy_sources": [
157  [dataclasses.asdict(issue) for issue in issues.issues.values()]
158  for issues in self.energy_sources
159  ],
160  "device_consumption": [
161  [dataclasses.asdict(issue) for issue in issues.issues.values()]
162  for issues in self.device_consumption
163  ],
164  }
165 
166 
167 @callback
169  hass: HomeAssistant,
170  metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
171  stat_id: str,
172  allowed_device_classes: Sequence[str],
173  allowed_units: Mapping[str, Sequence[str]],
174  unit_error: str,
175  issues: ValidationIssues,
176 ) -> None:
177  """Validate a statistic."""
178  if stat_id not in metadata:
179  issues.add_issue(hass, "statistics_not_defined", stat_id)
180 
181  has_entity_source = valid_entity_id(stat_id)
182 
183  if not has_entity_source:
184  return
185 
186  entity_id = stat_id
187 
188  if not recorder.is_entity_recorded(hass, entity_id):
189  issues.add_issue(hass, "recorder_untracked", entity_id)
190  return
191 
192  if (state := hass.states.get(entity_id)) is None:
193  issues.add_issue(hass, "entity_not_defined", entity_id)
194  return
195 
196  if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
197  issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
198  return
199 
200  try:
201  current_value: float | None = float(state.state)
202  except ValueError:
203  issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
204  return
205 
206  if current_value is not None and current_value < 0:
207  issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
208 
209  device_class = state.attributes.get(ATTR_DEVICE_CLASS)
210  if device_class not in allowed_device_classes:
211  issues.add_issue(
212  hass, "entity_unexpected_device_class", entity_id, device_class
213  )
214  else:
215  unit = state.attributes.get("unit_of_measurement")
216 
217  if device_class and unit not in allowed_units.get(device_class, []):
218  issues.add_issue(hass, unit_error, entity_id, unit)
219 
220  state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
221 
222  allowed_state_classes = [
223  sensor.SensorStateClass.MEASUREMENT,
224  sensor.SensorStateClass.TOTAL,
225  sensor.SensorStateClass.TOTAL_INCREASING,
226  ]
227  if state_class not in allowed_state_classes:
228  issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
229 
230  if (
231  state_class == sensor.SensorStateClass.MEASUREMENT
232  and sensor.ATTR_LAST_RESET not in state.attributes
233  ):
234  issues.add_issue(
235  hass, "entity_state_class_measurement_no_last_reset", entity_id
236  )
237 
238 
239 @callback
241  hass: HomeAssistant,
242  entity_id: str,
243  issues: ValidationIssues,
244  allowed_units: tuple[str, ...],
245  unit_error: str,
246 ) -> None:
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)
250  return
251 
252  try:
253  float(state.state)
254  except ValueError:
255  issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
256  return
257 
258  unit = state.attributes.get("unit_of_measurement")
259 
260  if unit is None or not unit.endswith(allowed_units):
261  issues.add_issue(hass, unit_error, entity_id, unit)
262 
263 
264 @callback
266  hass: HomeAssistant,
267  metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
268  stat_id: str,
269  issues: ValidationIssues,
270 ) -> None:
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)
274 
275  has_entity = valid_entity_id(stat_id)
276 
277  if not has_entity:
278  return
279 
280  if not recorder.is_entity_recorded(hass, stat_id):
281  issues.add_issue(hass, "recorder_untracked", stat_id)
282 
283  if (state := hass.states.get(stat_id)) is None:
284  issues.add_issue(hass, "entity_not_defined", stat_id)
285  return
286 
287  state_class = state.attributes.get("state_class")
288 
289  supported_state_classes = [
290  sensor.SensorStateClass.MEASUREMENT,
291  sensor.SensorStateClass.TOTAL,
292  sensor.SensorStateClass.TOTAL_INCREASING,
293  ]
294  if state_class not in supported_state_classes:
295  issues.add_issue(hass, "entity_unexpected_state_class", stat_id, state_class)
296 
297  if (
298  state_class == sensor.SensorStateClass.MEASUREMENT
299  and sensor.ATTR_LAST_RESET not in state.attributes
300  ):
301  issues.add_issue(hass, "entity_state_class_measurement_no_last_reset", stat_id)
302 
303 
304 @callback
306  hass: HomeAssistant, energy_entity_id: str, issues: ValidationIssues
307 ) -> None:
308  """Validate that the auto generated cost entity is correct."""
309  if energy_entity_id not in hass.data[DOMAIN]["cost_sensors"]:
310  # The cost entity has not been setup
311  return
312 
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)
316 
317 
318 async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
319  """Validate the energy configuration."""
320  manager: data.EnergyManager = await data.async_get_manager(hass)
321  statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
322  validate_calls = []
323  wanted_statistics_metadata: set[str] = set()
324 
325  result = EnergyPreferencesValidation()
326 
327  if manager.data is None:
328  return result
329 
330  # Create a list of validation checks
331  for source in manager.data["energy_sources"]:
332  source_result = ValidationIssues()
333  result.energy_sources.append(source_result)
334 
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(
340  functools.partial(
341  _async_validate_usage_stat,
342  hass,
343  statistics_metadata,
344  flow["stat_energy_from"],
345  ENERGY_USAGE_DEVICE_CLASSES,
346  ENERGY_USAGE_UNITS,
347  ENERGY_UNIT_ERROR,
348  source_result,
349  )
350  )
351 
352  if (stat_cost := flow.get("stat_cost")) is not None:
353  wanted_statistics_metadata.add(stat_cost)
354  validate_calls.append(
355  functools.partial(
356  _async_validate_cost_stat,
357  hass,
358  statistics_metadata,
359  stat_cost,
360  source_result,
361  )
362  )
363  elif (
364  entity_energy_price := flow.get("entity_energy_price")
365  ) is not None:
366  validate_calls.append(
367  functools.partial(
368  _async_validate_price_entity,
369  hass,
370  entity_energy_price,
371  source_result,
372  ENERGY_PRICE_UNITS,
373  ENERGY_PRICE_UNIT_ERROR,
374  )
375  )
376 
377  if (
378  flow.get("entity_energy_price") is not None
379  or flow.get("number_energy_price") is not None
380  ):
381  validate_calls.append(
382  functools.partial(
383  _async_validate_auto_generated_cost_entity,
384  hass,
385  flow["stat_energy_from"],
386  source_result,
387  )
388  )
389 
390  for flow in source["flow_to"]:
391  wanted_statistics_metadata.add(flow["stat_energy_to"])
392  validate_calls.append(
393  functools.partial(
394  _async_validate_usage_stat,
395  hass,
396  statistics_metadata,
397  flow["stat_energy_to"],
398  ENERGY_USAGE_DEVICE_CLASSES,
399  ENERGY_USAGE_UNITS,
400  ENERGY_UNIT_ERROR,
401  source_result,
402  )
403  )
404 
405  if (stat_compensation := flow.get("stat_compensation")) is not None:
406  wanted_statistics_metadata.add(stat_compensation)
407  validate_calls.append(
408  functools.partial(
409  _async_validate_cost_stat,
410  hass,
411  statistics_metadata,
412  stat_compensation,
413  source_result,
414  )
415  )
416  elif (
417  entity_energy_price := flow.get("entity_energy_price")
418  ) is not None:
419  validate_calls.append(
420  functools.partial(
421  _async_validate_price_entity,
422  hass,
423  entity_energy_price,
424  source_result,
425  ENERGY_PRICE_UNITS,
426  ENERGY_PRICE_UNIT_ERROR,
427  )
428  )
429 
430  if (
431  flow.get("entity_energy_price") is not None
432  or flow.get("number_energy_price") is not None
433  ):
434  validate_calls.append(
435  functools.partial(
436  _async_validate_auto_generated_cost_entity,
437  hass,
438  flow["stat_energy_to"],
439  source_result,
440  )
441  )
442 
443  elif source["type"] == "gas":
444  wanted_statistics_metadata.add(source["stat_energy_from"])
445  validate_calls.append(
446  functools.partial(
447  _async_validate_usage_stat,
448  hass,
449  statistics_metadata,
450  source["stat_energy_from"],
451  GAS_USAGE_DEVICE_CLASSES,
452  GAS_USAGE_UNITS,
453  GAS_UNIT_ERROR,
454  source_result,
455  )
456  )
457 
458  if (stat_cost := source.get("stat_cost")) is not None:
459  wanted_statistics_metadata.add(stat_cost)
460  validate_calls.append(
461  functools.partial(
462  _async_validate_cost_stat,
463  hass,
464  statistics_metadata,
465  stat_cost,
466  source_result,
467  )
468  )
469  elif (entity_energy_price := source.get("entity_energy_price")) is not None:
470  validate_calls.append(
471  functools.partial(
472  _async_validate_price_entity,
473  hass,
474  entity_energy_price,
475  source_result,
476  GAS_PRICE_UNITS,
477  GAS_PRICE_UNIT_ERROR,
478  )
479  )
480 
481  if (
482  source.get("entity_energy_price") is not None
483  or source.get("number_energy_price") is not None
484  ):
485  validate_calls.append(
486  functools.partial(
487  _async_validate_auto_generated_cost_entity,
488  hass,
489  source["stat_energy_from"],
490  source_result,
491  )
492  )
493 
494  elif source["type"] == "water":
495  wanted_statistics_metadata.add(source["stat_energy_from"])
496  validate_calls.append(
497  functools.partial(
498  _async_validate_usage_stat,
499  hass,
500  statistics_metadata,
501  source["stat_energy_from"],
502  WATER_USAGE_DEVICE_CLASSES,
503  WATER_USAGE_UNITS,
504  WATER_UNIT_ERROR,
505  source_result,
506  )
507  )
508 
509  if (stat_cost := source.get("stat_cost")) is not None:
510  wanted_statistics_metadata.add(stat_cost)
511  validate_calls.append(
512  functools.partial(
513  _async_validate_cost_stat,
514  hass,
515  statistics_metadata,
516  stat_cost,
517  source_result,
518  )
519  )
520  elif (entity_energy_price := source.get("entity_energy_price")) is not None:
521  validate_calls.append(
522  functools.partial(
523  _async_validate_price_entity,
524  hass,
525  entity_energy_price,
526  source_result,
527  WATER_PRICE_UNITS,
528  WATER_PRICE_UNIT_ERROR,
529  )
530  )
531 
532  if (
533  source.get("entity_energy_price") is not None
534  or source.get("number_energy_price") is not None
535  ):
536  validate_calls.append(
537  functools.partial(
538  _async_validate_auto_generated_cost_entity,
539  hass,
540  source["stat_energy_from"],
541  source_result,
542  )
543  )
544 
545  elif source["type"] == "solar":
546  wanted_statistics_metadata.add(source["stat_energy_from"])
547  validate_calls.append(
548  functools.partial(
549  _async_validate_usage_stat,
550  hass,
551  statistics_metadata,
552  source["stat_energy_from"],
553  ENERGY_USAGE_DEVICE_CLASSES,
554  ENERGY_USAGE_UNITS,
555  ENERGY_UNIT_ERROR,
556  source_result,
557  )
558  )
559 
560  elif source["type"] == "battery":
561  wanted_statistics_metadata.add(source["stat_energy_from"])
562  validate_calls.append(
563  functools.partial(
564  _async_validate_usage_stat,
565  hass,
566  statistics_metadata,
567  source["stat_energy_from"],
568  ENERGY_USAGE_DEVICE_CLASSES,
569  ENERGY_USAGE_UNITS,
570  ENERGY_UNIT_ERROR,
571  source_result,
572  )
573  )
574  wanted_statistics_metadata.add(source["stat_energy_to"])
575  validate_calls.append(
576  functools.partial(
577  _async_validate_usage_stat,
578  hass,
579  statistics_metadata,
580  source["stat_energy_to"],
581  ENERGY_USAGE_DEVICE_CLASSES,
582  ENERGY_USAGE_UNITS,
583  ENERGY_UNIT_ERROR,
584  source_result,
585  )
586  )
587 
588  for device in manager.data["device_consumption"]:
589  device_result = ValidationIssues()
590  result.device_consumption.append(device_result)
591  wanted_statistics_metadata.add(device["stat_consumption"])
592  validate_calls.append(
593  functools.partial(
594  _async_validate_usage_stat,
595  hass,
596  statistics_metadata,
597  device["stat_consumption"],
598  ENERGY_USAGE_DEVICE_CLASSES,
599  ENERGY_USAGE_UNITS,
600  ENERGY_UNIT_ERROR,
601  device_result,
602  )
603  )
604 
605  # Fetch the needed statistics metadata
606  statistics_metadata.update(
607  await recorder.get_instance(hass).async_add_executor_job(
608  functools.partial(
609  recorder.statistics.get_metadata,
610  hass,
611  statistic_ids=set(wanted_statistics_metadata),
612  )
613  )
614  )
615 
616  # Execute all the validation checks
617  for call in validate_calls:
618  call()
619 
620  return result
None add_issue(self, HomeAssistant hass, str issue_type, str affected_entity, float|str|None detail=None)
Definition: validate.py:138
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
EnergyPreferencesValidation async_validate(HomeAssistant hass)
Definition: validate.py:318
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)
Definition: validate.py:176
None _async_validate_cost_stat(HomeAssistant hass, dict[str, tuple[int, recorder.models.StatisticMetaData]] metadata, str stat_id, ValidationIssues issues)
Definition: validate.py:270
None _async_validate_auto_generated_cost_entity(HomeAssistant hass, str energy_entity_id, ValidationIssues issues)
Definition: validate.py:307
None _async_validate_price_entity(HomeAssistant hass, str entity_id, ValidationIssues issues, tuple[str,...] allowed_units, str unit_error)
Definition: validate.py:246
dict[str, str]|None _get_placeholders(HomeAssistant hass, str issue_type)
Definition: validate.py:77
bool valid_entity_id(str entity_id)
Definition: core.py:235