Skip to content

Commit

Permalink
Add attributes max_datetime and min_datetime (#232)
Browse files Browse the repository at this point in the history
  • Loading branch information
Limych committed Oct 7, 2024
1 parent 9b49ef1 commit 7bbfbca
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 98 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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().

Expand Down
30 changes: 29 additions & 1 deletion config/configuration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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() }}'
6 changes: 5 additions & 1 deletion custom_components/average/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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 = [
Expand All @@ -61,7 +63,9 @@
ATTR_AVAILABLE_SOURCES,
ATTR_COUNT,
ATTR_MAX_VALUE,
ATTR_MAX_DATETIME,
ATTR_MIN_VALUE,
ATTR_MIN_DATETIME,
ATTR_TRENDING_TOWARDS,
]

Expand Down
2 changes: 1 addition & 1 deletion custom_components/average/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"requirements": [
"pip>=21.3.1"
],
"version": "2.4.0"
"version": "2.4.1-alpha"
}
235 changes: 142 additions & 93 deletions custom_components/average/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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",
Expand All @@ -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,
)

0 comments on commit 7bbfbca

Please sign in to comment.