1 """Utility meter from sensors providing raw data."""
3 from __future__
import annotations
5 from collections.abc
import Mapping
6 from dataclasses
import dataclass
7 from datetime
import datetime, timedelta
8 from decimal
import Decimal, DecimalException, InvalidOperation
10 from typing
import Any, Self
12 from cronsim
import CronSim
13 import voluptuous
as vol
20 SensorExtraStoredData,
27 ATTR_UNIT_OF_MEASUREMENT,
30 EVENT_CORE_CONFIG_UPDATE,
36 EventStateChangedData,
46 async_track_point_in_time,
47 async_track_state_change_event,
62 CONF_METER_DELTA_VALUES,
63 CONF_METER_NET_CONSUMPTION,
65 CONF_METER_PERIODICALLY_RESETTING,
67 CONF_SENSOR_ALWAYS_AVAILABLE,
79 SERVICE_CALIBRATE_METER,
86 QUARTER_HOURLY:
"{minute}/15 * * * *",
87 HOURLY:
"{minute} * * * *",
88 DAILY:
"{minute} {hour} * * *",
89 WEEKLY:
"{minute} {hour} * * {day}",
90 MONTHLY:
"{minute} {hour} {day} * *",
91 BIMONTHLY:
"{minute} {hour} {day} */2 *",
92 QUARTERLY:
"{minute} {hour} {day} */3 *",
93 YEARLY:
"{minute} {hour} {day} 1/12 *",
96 _LOGGER = logging.getLogger(__name__)
98 ATTR_SOURCE_ID =
"source"
99 ATTR_STATUS =
"status"
100 ATTR_PERIOD =
"meter_period"
101 ATTR_LAST_PERIOD =
"last_period"
102 ATTR_LAST_VALID_STATE =
"last_valid_state"
103 ATTR_TARIFF =
"tariff"
107 COLLECTING =
"collecting"
111 """Validate value is a number."""
114 raise vol.Invalid(
"Value is not a number")
119 config_entry: ConfigEntry,
120 async_add_entities: AddEntitiesCallback,
122 """Initialize Utility Meter config entry."""
123 entry_id = config_entry.entry_id
124 registry = er.async_get(hass)
126 source_entity_id = er.async_validate_entity_id(
127 registry, config_entry.options[CONF_SOURCE_SENSOR]
136 delta_values = config_entry.options[CONF_METER_DELTA_VALUES]
137 meter_offset =
timedelta(days=config_entry.options[CONF_METER_OFFSET])
138 meter_type = config_entry.options[CONF_METER_TYPE]
139 if meter_type ==
"none":
141 name = config_entry.title
142 net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION]
143 periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING]
144 tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY]
145 sensor_always_available = config_entry.options.get(
146 CONF_SENSOR_ALWAYS_AVAILABLE,
False
150 tariffs = config_entry.options[CONF_TARIFFS]
155 cron_pattern=cron_pattern,
156 delta_values=delta_values,
157 meter_offset=meter_offset,
158 meter_type=meter_type,
160 net_consumption=net_consumption,
161 parent_meter=entry_id,
162 periodically_resetting=periodically_resetting,
163 source_entity=source_entity_id,
164 tariff_entity=tariff_entity,
167 device_info=device_info,
168 sensor_always_available=sensor_always_available,
170 meters.append(meter_sensor)
171 hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor)
174 for tariff
in tariffs:
176 cron_pattern=cron_pattern,
177 delta_values=delta_values,
178 meter_offset=meter_offset,
179 meter_type=meter_type,
180 name=f
"{name} {tariff}",
181 net_consumption=net_consumption,
182 parent_meter=entry_id,
183 periodically_resetting=periodically_resetting,
184 source_entity=source_entity_id,
185 tariff_entity=tariff_entity,
187 unique_id=f
"{entry_id}_{tariff}",
188 device_info=device_info,
189 sensor_always_available=sensor_always_available,
191 meters.append(meter_sensor)
192 hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor)
196 platform = entity_platform.async_get_current_platform()
198 platform.async_register_entity_service(
199 SERVICE_CALIBRATE_METER,
200 {vol.Required(ATTR_VALUE): validate_is_number},
208 async_add_entities: AddEntitiesCallback,
209 discovery_info: DiscoveryInfoType |
None =
None,
211 """Set up the utility meter sensor."""
212 if discovery_info
is None:
214 "This platform is not available to configure "
215 "from 'sensor:' in configuration.yaml"
220 for conf
in discovery_info.values():
221 meter = conf[CONF_METER]
222 conf_meter_source = hass.data[DATA_UTILITY][meter][CONF_SOURCE_SENSOR]
223 conf_meter_unique_id = hass.data[DATA_UTILITY][meter].
get(CONF_UNIQUE_ID)
224 conf_sensor_tariff = conf.get(CONF_TARIFF,
"single_tariff")
225 conf_sensor_unique_id = (
226 f
"{conf_meter_unique_id}_{conf_sensor_tariff}"
227 if conf_meter_unique_id
230 conf_meter_name = hass.data[DATA_UTILITY][meter].
get(CONF_NAME, meter)
231 conf_sensor_tariff = conf.get(CONF_TARIFF)
233 suggested_entity_id =
None
234 if conf_sensor_tariff:
235 conf_sensor_name = f
"{conf_meter_name} {conf_sensor_tariff}"
236 slug =
slugify(f
"{meter} {conf_sensor_tariff}")
237 suggested_entity_id = f
"sensor.{slug}"
239 conf_sensor_name = conf_meter_name
241 conf_meter_type = hass.data[DATA_UTILITY][meter].
get(CONF_METER_TYPE)
242 conf_meter_offset = hass.data[DATA_UTILITY][meter][CONF_METER_OFFSET]
243 conf_meter_delta_values = hass.data[DATA_UTILITY][meter][
244 CONF_METER_DELTA_VALUES
246 conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][
247 CONF_METER_NET_CONSUMPTION
249 conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][
250 CONF_METER_PERIODICALLY_RESETTING
252 conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].
get(
255 conf_cron_pattern = hass.data[DATA_UTILITY][meter].
get(CONF_CRON_PATTERN)
256 conf_sensor_always_available = hass.data[DATA_UTILITY][meter][
257 CONF_SENSOR_ALWAYS_AVAILABLE
260 cron_pattern=conf_cron_pattern,
261 delta_values=conf_meter_delta_values,
262 meter_offset=conf_meter_offset,
263 meter_type=conf_meter_type,
264 name=conf_sensor_name,
265 net_consumption=conf_meter_net_consumption,
267 periodically_resetting=conf_meter_periodically_resetting,
268 source_entity=conf_meter_source,
269 tariff_entity=conf_meter_tariff_entity,
270 tariff=conf_sensor_tariff,
271 unique_id=conf_sensor_unique_id,
272 suggested_entity_id=suggested_entity_id,
273 sensor_always_available=conf_sensor_always_available,
275 meters.append(meter_sensor)
277 hass.data[DATA_UTILITY][meter][DATA_TARIFF_SENSORS].append(meter_sensor)
281 platform = entity_platform.async_get_current_platform()
283 platform.async_register_entity_service(
284 SERVICE_CALIBRATE_METER,
285 {vol.Required(ATTR_VALUE): validate_is_number},
292 """Object to hold extra stored data."""
295 last_reset: datetime |
None
296 last_valid_state: Decimal |
None
298 input_device_class: SensorDeviceClass |
None
301 """Return a dict representation of the utility sensor data."""
303 data[
"last_period"] =
str(self.last_period)
304 if isinstance(self.last_reset, (datetime)):
305 data[
"last_reset"] = self.last_reset.isoformat()
306 data[
"last_valid_state"] = (
307 str(self.last_valid_state)
if self.last_valid_state
else None
309 data[
"status"] = self.status
310 data[
"input_device_class"] =
str(self.input_device_class)
315 def from_dict(cls, restored: dict[str, Any]) -> Self |
None:
316 """Initialize a stored sensor state from a dict."""
317 extra = SensorExtraStoredData.from_dict(restored)
322 last_period: Decimal = Decimal(restored[
"last_period"])
323 last_reset: datetime |
None = dt_util.parse_datetime(restored[
"last_reset"])
324 last_valid_state: Decimal |
None = (
325 Decimal(restored[
"last_valid_state"])
326 if restored.get(
"last_valid_state")
329 status: str = restored[
"status"]
330 input_device_class = try_parse_enum(
331 SensorDeviceClass, restored.get(
"input_device_class")
336 except InvalidOperation:
342 extra.native_unit_of_measurement,
352 """Representation of an utility meter sensor."""
354 _attr_translation_key =
"utility_meter"
355 _attr_should_poll =
False
356 _unrecorded_attributes = frozenset({ATTR_NEXT_RESET})
368 periodically_resetting,
373 sensor_always_available,
374 suggested_entity_id=None,
377 """Initialize the Utility Meter sensor."""
391 if meter_type
is not None:
394 minute=meter_offset.seconds % 3600 // 60,
395 hour=meter_offset.seconds // 3600,
396 day=meter_offset.days + 1,
398 _LOGGER.debug(
"CRON pattern: %s", self.
_cron_pattern_cron_pattern)
416 dt_util.get_default_time_zone()
423 def start(self, attributes: Mapping[str, Any]) ->
None:
424 """Initialize unit and state upon source initial update."""
432 """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number."""
436 if state
is None or state.state
in [STATE_UNAVAILABLE, STATE_UNKNOWN]
439 except DecimalException:
443 self, old_state: State |
None, new_state: State
445 """Calculate the adjustment based on the old and new state."""
448 if (new_state_val := self.
_validate_state_validate_state(new_state))
is None:
449 _LOGGER.warning(
"Invalid state %s", new_state.state)
461 if (old_state_val := self.
_validate_state_validate_state(old_state))
is not None:
462 return new_state_val - old_state_val
465 "%s received an invalid state change coming from %s (%s > %s)",
468 old_state.state
if old_state
else None,
475 """Handle the sensor state changes."""
478 )
is None or source_state.state == STATE_UNAVAILABLE:
486 old_state = event.data[
"old_state"]
487 new_state = event.data[
"new_state"]
488 if new_state
is None:
490 new_state_attributes: Mapping[str, Any] = new_state.attributes
or {}
493 if (new_state_val := self.
_validate_state_validate_state(new_state))
is None:
495 "%s received an invalid new state from %s : %s",
507 sensor.start(new_state_attributes)
510 "Source sensor %s has no unit of measurement. Please %s",
523 ATTR_UNIT_OF_MEASUREMENT
530 """Handle tariff changes."""
531 if (new_state := event.data[
"new_state"])
is None:
537 if self.
_tariff_tariff == tariff:
552 "%s - %s - source <%s>",
554 COLLECTING
if self.
_collecting_collecting
is not None else PAUSED,
561 """Program the reset of the utility meter."""
576 """Reset the utility meter status."""
588 and entity_id
is not None
601 """Calibrate the Utility Meter with a given value."""
602 _LOGGER.debug(
"Calibrate %s = %s type(%s)", self.
namename, value, type(value))
607 """Handle entity which will be added."""
626 last_sensor_data.native_unit_of_measurement
628 self.
_last_period_last_period = last_sensor_data.last_period
629 self.
_last_reset_last_reset = last_sensor_data.last_reset
631 if last_sensor_data.status == COLLECTING:
636 def async_source_tracking(event):
637 """Wait for source to be ready, then start meter."""
649 if not tariff_entity_state:
657 "<%s> collecting %s from %s",
668 async
def async_track_time_zone(event):
669 """Reconfigure Scheduler after time zone changes."""
678 self.
hasshass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_track_time_zone)
682 """Run when entity will be removed from hass."""
689 """Return the device class of the sensor."""
694 in DEVICE_CLASS_UNITS[SensorDeviceClass.ENERGY]
696 return SensorDeviceClass.ENERGY
701 """Return the device class of the sensor."""
703 SensorStateClass.TOTAL
705 else SensorStateClass.TOTAL_INCREASING
710 """Return the state attributes of the sensor."""
712 ATTR_STATUS: PAUSED
if self.
_collecting_collecting
is None else COLLECTING,
716 if self.
_tariff_tariff
is not None:
717 state_attr[ATTR_TARIFF] = self.
_tariff_tariff
724 state_attr[ATTR_LAST_RESET] = last_reset.isoformat()
726 state_attr[ATTR_NEXT_RESET] = self.
_next_reset_next_reset.isoformat()
732 """Return sensor specific state data to be restored."""
739 PAUSED
if self.
_collecting_collecting
is None else COLLECTING,
744 """Restore Utility Meter Sensor Extra Stored Data."""
745 if (restored_last_extra_data := await self.async_get_last_extra_data())
is None:
748 return UtilitySensorExtraStoredData.from_dict(
749 restored_last_extra_data.as_dict()
SensorExtraStoredData|None async_get_last_sensor_data(self)
StateType|date|datetime|Decimal native_value(self)
str|None native_unit_of_measurement(self)
def extra_state_attributes(self)
UtilitySensorExtraStoredData extra_restore_state_data(self)
Decimal|None _validate_state(State|None state)
None start(self, Mapping[str, Any] attributes)
def async_added_to_hass(self)
def _async_reset_meter(self, event)
None async_tariff_change(self, Event[EventStateChangedData] event)
None async_reading(self, Event[EventStateChangedData] event)
def async_calibrate(self, value)
None _change_status(self, str tariff)
_attr_native_unit_of_measurement
def _config_scheduler(self)
UtilitySensorExtraStoredData|None async_get_last_sensor_data(self)
_sensor_periodically_resetting
def __init__(self, *cron_pattern, delta_values, meter_offset, meter_type, name, net_consumption, parent_meter, periodically_resetting, source_entity, tariff_entity, tariff, unique_id, sensor_always_available, suggested_entity_id=None, device_info=None)
Decimal|None calculate_adjustment(self, State|None old_state, State new_state)
def async_reset_meter(self, entity_id)
None async_will_remove_from_hass(self)
str _suggest_report_issue(self)
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
str|UndefinedType|None name(self)
web.Response get(self, web.Request request, str config_key)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
def validate_is_number(value)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
CALLBACK_TYPE async_track_point_in_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
CALLBACK_TYPE async_at_started(HomeAssistant hass, Callable[[HomeAssistant], Coroutine[Any, Any, None]|None] at_start_cb)