From 7bbfbca272391a78fdd4f1f32d67fb4b61f56863 Mon Sep 17 00:00:00 2001 From: Andrey Khrolenok Date: Mon, 7 Oct 2024 11:40:32 +0300 Subject: [PATCH] Add attributes max_datetime and min_datetime (#232) --- README.md | 10 +- config/configuration.yaml | 30 ++- custom_components/average/const.py | 6 +- custom_components/average/manifest.json | 2 +- custom_components/average/sensor.py | 235 ++++++++++++++---------- 5 files changed, 185 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 36dc1cf..8c6fb06 100644 --- a/README.md +++ b/README.md @@ -179,12 +179,18 @@ I put a lot of work into making this repo and component available and updated to **count**:\ Total count of processed values of source sensors. -**min**:\ +**min_value**:\ Minimum value of processed values of source sensors. -**max**:\ +**min_datetime**:\ + Date and time of minimum value of processed values of source sensors (if period was set). + +**max_value**:\ Maximum value of processed values of source sensors. +**max_datetime**:\ + Date and time of maximum value of processed values of source sensors (if period was set). + **trending_towards**:\ The predicted value if monitored entities keep their current states for the remainder of the period. Requires "end" configuration variable to be set to actual end of period and not now(). diff --git a/config/configuration.yaml b/config/configuration.yaml index ccc8410..2035885 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -9,4 +9,32 @@ homeassistant: logger: default: info logs: - custom_components.integration_blueprint: debug + custom_components.average: debug + +# https://www.home-assistant.io/integrations/debugpy/ +# If you need to debug uncomment the line below +#debugpy: + +sensor: + - platform: template + sensors: + test1: + value_template: "{{ state_attr('sun.sun', 'elevation') }}" + unit_of_measurement: '°C' + device_class: 'temperature' + test2: + value_template: "{{ 2 }}" + unit_of_measurement: '°C' + device_class: 'temperature' + + - platform: average + entities: + - sensor.test1 + - sensor.test2 + + - platform: average + name: 'Average History' + entities: + - sensor.test1 + start: '{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}' + end: '{{ now() }}' diff --git a/custom_components/average/const.py b/custom_components/average/const.py index 026dc5d..2468aac 100644 --- a/custom_components/average/const.py +++ b/custom_components/average/const.py @@ -13,7 +13,7 @@ # Base component constants NAME: Final = "Average Sensor" DOMAIN: Final = "average" -VERSION: Final = "2.4.0" +VERSION: Final = "2.4.1-alpha" ISSUE_URL: Final = "https://github.com/Limych/ha-average/issues" STARTUP_MESSAGE: Final = f""" @@ -50,7 +50,9 @@ ATTR_AVAILABLE_SOURCES: Final = "available_sources" ATTR_COUNT: Final = "count" ATTR_MIN_VALUE: Final = "min_value" +ATTR_MIN_DATETIME: Final = "min_datetime" ATTR_MAX_VALUE: Final = "max_value" +ATTR_MAX_DATETIME: Final = "max_datetime" ATTR_TRENDING_TOWARDS: Final = "trending_towards" # ATTR_TO_PROPERTY: Final = [ @@ -61,7 +63,9 @@ ATTR_AVAILABLE_SOURCES, ATTR_COUNT, ATTR_MAX_VALUE, + ATTR_MAX_DATETIME, ATTR_MIN_VALUE, + ATTR_MIN_DATETIME, ATTR_TRENDING_TOWARDS, ] diff --git a/custom_components/average/manifest.json b/custom_components/average/manifest.json index d3fdd3e..02abaef 100644 --- a/custom_components/average/manifest.json +++ b/custom_components/average/manifest.json @@ -17,5 +17,5 @@ "requirements": [ "pip>=21.3.1" ], - "version": "2.4.0" + "version": "2.4.1-alpha" } \ No newline at end of file diff --git a/custom_components/average/sensor.py b/custom_components/average/sensor.py index e85e267..b60af2f 100644 --- a/custom_components/average/sensor.py +++ b/custom_components/average/sensor.py @@ -171,6 +171,7 @@ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: self.count = 0 self.trending_towards = None self.min_value = self.max_value = None + self.min_datetime = self.max_datetime = None self._attr_name = config.get(CONF_NAME, DEFAULT_NAME) self._attr_native_value = None @@ -284,6 +285,7 @@ def _get_temperature(self, state: State) -> float | None: def _get_state_value(self, state: State) -> float | None: """Return value of given entity state and count some sensor attributes.""" + state_dt_changed = state.last_changed state = self._get_temperature(state) if self._temperature_mode else state.state if not self._has_state(state): return self._undef @@ -298,9 +300,17 @@ def _get_state_value(self, state: State) -> float | None: rstate = round(state, self._precision) if self.min_value is None: self.min_value = self.max_value = rstate + if self._period: + self.min_datetime = self.max_datetime = state_dt_changed else: - self.min_value = min(self.min_value, rstate) - self.max_value = max(self.max_value, rstate) + if rstate < self.min_value: + self.min_value = rstate + if self._period: + self.min_datetime = state_dt_changed + if rstate > self.max_value: + self.max_value = rstate + if self._period: + self.max_datetime = state_dt_changed return state @Throttle(UPDATE_MIN_TIME) @@ -393,8 +403,8 @@ async def _async_update_period(self) -> None: # noqa: PLR0912 end = min(end, now) # No point in making stats of the future self._period = start, end - self.start = start.replace(microsecond=0).isoformat() - self.end = end.replace(microsecond=0).isoformat() + self.start = start + self.end = end def _init_mode(self, state: State) -> None: """Initialize sensor mode.""" @@ -424,45 +434,50 @@ def _init_mode(self, state: State) -> None: async def _async_update_state(self) -> None: # noqa: PLR0912, PLR0915 """Update the sensor state.""" _LOGGER.debug('Updating sensor "%s"', self.name) - start = end = start_ts = end_ts = None p_period = self._period # Parse templates await self._async_update_period() - if self._period is not None: - now = dt_util.now() - start, end = self._period - if p_period is None: - p_start = p_end = now - else: - p_start, p_end = p_period - - # Convert times to UTC - now = dt_util.as_utc(now) - start = dt_util.as_utc(start) - end = dt_util.as_utc(end) - actual_end = dt_util.as_utc(self._actual_end) - p_start = dt_util.as_utc(p_start) - p_end = dt_util.as_utc(p_end) - - # Compute integer timestamps - now_ts = math.floor(dt_util.as_timestamp(now)) - start_ts = math.floor(dt_util.as_timestamp(start)) - end_ts = math.floor(dt_util.as_timestamp(end)) - actual_end_ts = math.floor(dt_util.as_timestamp(actual_end)) - p_start_ts = math.floor(dt_util.as_timestamp(p_start)) - p_end_ts = math.floor(dt_util.as_timestamp(p_end)) - - # If period has not changed and current time after the period end.. - if start_ts == p_start_ts and end_ts == p_end_ts and end_ts <= now_ts: - # Don't compute anything as the value cannot have changed - return + if self._period is None: + self._update_state_no_period() + return + + now = dt_util.now() + start, end = self._period + if p_period is None: + p_start = p_end = now + else: + p_start, p_end = p_period + + # Convert times to UTC + now = dt_util.as_utc(now) + start = dt_util.as_utc(start) + end = dt_util.as_utc(end) + actual_end = dt_util.as_utc(self._actual_end) + p_start = dt_util.as_utc(p_start) + p_end = dt_util.as_utc(p_end) + + # Compute integer timestamps + now_ts = math.floor(dt_util.as_timestamp(now)) + start_ts = math.floor(dt_util.as_timestamp(start)) + end_ts = math.floor(dt_util.as_timestamp(end)) + actual_end_ts = math.floor(dt_util.as_timestamp(actual_end)) + p_start_ts = math.floor(dt_util.as_timestamp(p_start)) + p_end_ts = math.floor(dt_util.as_timestamp(p_end)) + + # If period has not changed and current time after the period end.. + if start_ts == p_start_ts and end_ts == p_end_ts and end_ts <= now_ts: + # Don't compute anything as the value cannot have changed + return self.available_sources = 0 - values = [] self.count = 0 self.min_value = self.max_value = None + self.min_datetime = self.max_datetime = None + self.trending_towards = None + # + values = [] last_values = [] # pylint: disable=too-many-nested-blocks @@ -481,68 +496,62 @@ async def _async_update_state(self) -> None: # noqa: PLR0912, PLR0915 elapsed = 0 trending_last_state = None - if self._period is None: - # Get current state - value = self._get_state_value(state) - _LOGGER.debug("Current state: %s", value) + # Get history between start and now + history_list = await get_instance(self.hass).async_add_executor_job( + history.state_changes_during_period, + self.hass, + start, + end, + str(entity_id), + ) - else: - # Get history between start and now - history_list = await get_instance(self.hass).async_add_executor_job( - history.state_changes_during_period, - self.hass, - start, - end, - str(entity_id), + if ( + entity_id not in history_list + or history_list[entity_id] is None + or len(history_list[entity_id]) == 0 + ): + value = self._get_state_value(state) + _LOGGER.warning( + 'Historical data not found for entity "%s". ' + "Current state used: %s", + entity_id, + value, ) - - if ( - entity_id not in history_list - or history_list[entity_id] is None - or len(history_list[entity_id]) == 0 - ): - value = self._get_state_value(state) - _LOGGER.warning( - 'Historical data not found for entity "%s". ' - "Current state used: %s", - entity_id, - value, - ) - else: - # Get the first state - item = history_list[entity_id][0] - _LOGGER.debug("Initial historical state: %s", item) - last_state = None - last_time = start_ts - if item is not None and self._has_state(item.state): - last_state = self._get_state_value(item) - - # Get the other states - for item in history_list.get(entity_id): - _LOGGER.debug("Historical state: %s", item) - current_state = self._get_state_value(item) - current_time = item.last_changed.timestamp() - - if last_state is not None: - last_elapsed = current_time - last_time - value += last_state * last_elapsed - elapsed += last_elapsed - - last_state = current_state - last_time = current_time - - # Count time elapsed between last history state and now - if last_state is None: - value = None - else: - last_elapsed = end_ts - last_time + else: + # Get the first state + item = history_list[entity_id][0] + _LOGGER.debug("Initial historical state: %s", item) + last_state = None + last_time = start_ts + if item is not None and self._has_state(item.state): + last_state = self._get_state_value(item) + + # Get the other states + for item in history_list.get(entity_id): + _LOGGER.debug("Historical state: %s", item) + current_state = self._get_state_value(item) + current_time = item.last_changed.timestamp() + + if last_state is not None: + last_elapsed = current_time - last_time value += last_state * last_elapsed elapsed += last_elapsed - if elapsed: - value /= elapsed - trending_last_state = last_state - _LOGGER.debug("Historical average state: %s", value) + last_state = current_state + last_time = current_time + + # Count time elapsed between last history state and now + if last_state is None: + value = None + else: + last_elapsed = end_ts - last_time + value += last_state * last_elapsed + elapsed += last_elapsed + if elapsed: + value /= elapsed + trending_last_state = last_state + + _LOGGER.debug("Historical average state: %s", value) if isinstance(value, numbers.Number): values.append(value) @@ -568,8 +577,6 @@ async def _async_update_state(self) -> None: # noqa: PLR0912, PLR0915 to_now = self._attr_native_value * part_of_period to_end = current_average * (1 - part_of_period) self.trending_towards = to_now + to_end - else: - self.trending_towards = None _LOGGER.debug( "Total average state: %s %s", @@ -581,3 +588,45 @@ async def _async_update_state(self) -> None: # noqa: PLR0912, PLR0915 self.trending_towards, self._attr_native_unit_of_measurement, ) + + def _update_state_no_period(self) -> None: + """Update the sensor state then period is not set.""" + self.available_sources = 0 + values = [] + self.count = 0 + self.min_value = self.max_value = None + self.min_datetime = self.max_datetime = None + self.trending_towards = None + + # pylint: disable=too-many-nested-blocks + for entity_id in self.sources: + _LOGGER.debug('Processing entity "%s"', entity_id) + + state = self.hass.states.get(entity_id) # type: State + + if state is None: + _LOGGER.error('Unable to find an entity "%s"', entity_id) + continue + + self._init_mode(state) + + # Get current state + value = self._get_state_value(state) + _LOGGER.debug("Current state: %s", value) + + if isinstance(value, numbers.Number): + values.append(value) + self.available_sources += 1 + + if values: + self._attr_native_value = round(sum(values) / len(values), self._precision) + if self._precision < 1: + self._attr_native_value = int(self._attr_native_value) + else: + self._attr_native_value = None + + _LOGGER.debug( + "Total average state: %s %s", + self._attr_native_value, + self._attr_native_unit_of_measurement, + )