Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for tracking consumption over given periods of time."""
2 
3 from datetime import datetime, timedelta
4 import logging
5 
6 from cronsim import CronSim, CronSimError
7 import voluptuous as vol
8 
9 from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
10 from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
11 from homeassistant.config_entries import ConfigEntry
12 from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
13 from homeassistant.core import HomeAssistant, split_entity_id
14 from homeassistant.helpers import discovery, entity_registry as er
16 from homeassistant.helpers.device import (
17  async_remove_stale_devices_links_keep_entity_device,
18 )
19 from homeassistant.helpers.dispatcher import async_dispatcher_send
20 from homeassistant.helpers.typing import ConfigType
21 
22 from .const import (
23  CONF_CRON_PATTERN,
24  CONF_METER,
25  CONF_METER_DELTA_VALUES,
26  CONF_METER_NET_CONSUMPTION,
27  CONF_METER_OFFSET,
28  CONF_METER_PERIODICALLY_RESETTING,
29  CONF_METER_TYPE,
30  CONF_SENSOR_ALWAYS_AVAILABLE,
31  CONF_SOURCE_SENSOR,
32  CONF_TARIFF,
33  CONF_TARIFF_ENTITY,
34  CONF_TARIFFS,
35  DATA_TARIFF_SENSORS,
36  DATA_UTILITY,
37  DOMAIN,
38  METER_TYPES,
39  SERVICE_RESET,
40  SIGNAL_RESET_METER,
41 )
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 DEFAULT_OFFSET = timedelta(hours=0)
46 
47 
48 def validate_cron_pattern(pattern):
49  """Check that the pattern is well-formed."""
50  try:
51  CronSim(pattern, datetime(2020, 1, 1)) # any date will do
52  except CronSimError as err:
53  _LOGGER.error("Invalid cron pattern %s: %s", pattern, err)
54  raise vol.Invalid("Invalid pattern") from err
55  return pattern
56 
57 
58 def period_or_cron(config):
59  """Check that if cron pattern is used, then meter type and offsite must be removed."""
60  if CONF_CRON_PATTERN in config and CONF_METER_TYPE in config:
61  raise vol.Invalid(f"Use <{CONF_CRON_PATTERN}> or <{CONF_METER_TYPE}>")
62  if (
63  CONF_CRON_PATTERN in config
64  and CONF_METER_OFFSET in config
65  and config[CONF_METER_OFFSET] != DEFAULT_OFFSET
66  ):
67  raise vol.Invalid(
68  f"When <{CONF_CRON_PATTERN}> is used <{CONF_METER_OFFSET}> has no meaning"
69  )
70  return config
71 
72 
73 def max_28_days(config):
74  """Check that time period does not include more than 28 days."""
75  if config.days >= 28:
76  raise vol.Invalid(
77  "Unsupported offset of more than 28 days, please use a cron pattern."
78  )
79 
80  return config
81 
82 
83 METER_CONFIG_SCHEMA = vol.Schema(
84  vol.All(
85  {
86  vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
87  vol.Optional(CONF_NAME): cv.string,
88  vol.Optional(CONF_UNIQUE_ID): cv.string,
89  vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES),
90  vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All(
91  cv.time_period, cv.positive_timedelta, max_28_days
92  ),
93  vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean,
94  vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean,
95  vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean,
96  vol.Optional(CONF_TARIFFS, default=[]): vol.All(
97  cv.ensure_list, vol.Unique(), [cv.string]
98  ),
99  vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern,
100  vol.Optional(CONF_SENSOR_ALWAYS_AVAILABLE, default=False): cv.boolean,
101  },
102  period_or_cron,
103  )
104 )
105 
106 CONFIG_SCHEMA = vol.Schema(
107  {DOMAIN: vol.Schema({cv.slug: METER_CONFIG_SCHEMA})}, extra=vol.ALLOW_EXTRA
108 )
109 
110 
111 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
112  """Set up an Utility Meter."""
113  hass.data[DATA_UTILITY] = {}
114 
115  async def async_reset_meters(service_call):
116  """Reset all sensors of a meter."""
117  meters = service_call.data["entity_id"]
118 
119  for meter in meters:
120  _LOGGER.debug("resetting meter %s", meter)
121  domain, entity = split_entity_id(meter)
122  # backward compatibility up to 2022.07:
123  if domain == DOMAIN:
125  hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
126  )
127  else:
128  async_dispatcher_send(hass, SIGNAL_RESET_METER, meter)
129 
130  hass.services.async_register(
131  DOMAIN,
132  SERVICE_RESET,
133  async_reset_meters,
134  vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
135  )
136 
137  if DOMAIN not in config:
138  return True
139 
140  for meter, conf in config[DOMAIN].items():
141  _LOGGER.debug("Setup %s.%s", DOMAIN, meter)
142 
143  hass.data[DATA_UTILITY][meter] = conf
144  hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS] = []
145 
146  if not conf[CONF_TARIFFS]:
147  # only one entity is required
148  hass.async_create_task(
149  discovery.async_load_platform(
150  hass,
151  SENSOR_DOMAIN,
152  DOMAIN,
153  {meter: {CONF_METER: meter}},
154  config,
155  ),
156  eager_start=True,
157  )
158  else:
159  # create tariff selection
160  hass.async_create_task(
161  discovery.async_load_platform(
162  hass,
163  SELECT_DOMAIN,
164  DOMAIN,
165  {CONF_METER: meter, CONF_TARIFFS: conf[CONF_TARIFFS]},
166  config,
167  ),
168  eager_start=True,
169  )
170 
171  hass.data[DATA_UTILITY][meter][CONF_TARIFF_ENTITY] = (
172  f"{SELECT_DOMAIN}.{meter}"
173  )
174 
175  # add one meter for each tariff
176  tariff_confs = {}
177  for tariff in conf[CONF_TARIFFS]:
178  name = f"{meter} {tariff}"
179  tariff_confs[name] = {
180  CONF_METER: meter,
181  CONF_TARIFF: tariff,
182  }
183 
184  hass.async_create_task(
185  discovery.async_load_platform(
186  hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config
187  ),
188  eager_start=True,
189  )
190 
191  return True
192 
193 
194 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
195  """Set up Utility Meter from a config entry."""
196 
198  hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR]
199  )
200 
201  entity_registry = er.async_get(hass)
202  hass.data[DATA_UTILITY][entry.entry_id] = {
203  "source": entry.options[CONF_SOURCE_SENSOR],
204  }
205  hass.data[DATA_UTILITY][entry.entry_id][DATA_TARIFF_SENSORS] = []
206 
207  try:
208  er.async_validate_entity_id(entity_registry, entry.options[CONF_SOURCE_SENSOR])
209  except vol.Invalid:
210  # The entity is identified by an unknown entity registry ID
211  _LOGGER.error(
212  "Failed to setup utility_meter for unknown entity %s",
213  entry.options[CONF_SOURCE_SENSOR],
214  )
215  return False
216 
217  if not entry.options.get(CONF_TARIFFS):
218  # Only a single meter sensor is required
219  hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = None
220  await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
221  else:
222  # Create tariff selection + one meter sensor for each tariff
223  entity_entry = entity_registry.async_get_or_create(
224  Platform.SELECT, DOMAIN, entry.entry_id, suggested_object_id=entry.title
225  )
226  hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = (
227  entity_entry.entity_id
228  )
229  await hass.config_entries.async_forward_entry_setups(
230  entry, (Platform.SELECT, Platform.SENSOR)
231  )
232 
233  entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
234 
235  return True
236 
237 
238 async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
239  """Update listener, called when the config entry options are changed."""
240 
241  await hass.config_entries.async_reload(entry.entry_id)
242 
243 
244 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
245  """Unload a config entry."""
246  platforms_to_unload = [Platform.SENSOR]
247  if entry.options.get(CONF_TARIFFS):
248  platforms_to_unload.append(Platform.SELECT)
249 
250  if unload_ok := await hass.config_entries.async_unload_platforms(
251  entry,
252  platforms_to_unload,
253  ):
254  hass.data[DATA_UTILITY].pop(entry.entry_id)
255 
256  return unload_ok
257 
258 
259 async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
260  """Migrate old entry."""
261  _LOGGER.debug("Migrating from version %s", config_entry.version)
262 
263  if config_entry.version == 1:
264  new = {**config_entry.options}
265  new[CONF_METER_PERIODICALLY_RESETTING] = True
266  hass.config_entries.async_update_entry(config_entry, options=new, version=2)
267 
268  _LOGGER.info("Migration to version %s successful", config_entry.version)
269 
270  return True
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:244
bool async_migrate_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:259
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:194
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:111
None config_entry_update_listener(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:238
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
None async_remove_stale_devices_links_keep_entity_device(HomeAssistant hass, str entry_id, str source_entity_id_or_uuid)
Definition: device.py:66
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193