Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Uses ha-historical-sensor for correct historical statistics #410

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion custom_components/hildebrandglow_dcc/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/HandyHat/ha-hildebrandglow-dcc/issues",
"requirements": [
"pyglowmarkt==0.5.5"
"pyglowmarkt==0.5.5",
"homeassistant-historical-sensor==2.0.0rc4"
],
"version": "1.0.3"
}
227 changes: 175 additions & 52 deletions custom_components/hildebrandglow_dcc/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from collections.abc import Callable
from datetime import datetime, time, timedelta
import logging

import itertools
import statistics
import requests

from homeassistant.components.sensor import (
Expand All @@ -21,6 +22,21 @@
DataUpdateCoordinator,
)

from homeassistant.components import recorder
from homeassistant.components.recorder import statistics
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dtutil

from homeassistant_historical_sensor import (
HistoricalSensor,
HistoricalState,
PollUpdateMixin,
)

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -141,20 +157,15 @@ async def should_update() -> bool:
return False


async def daily_data(hass: HomeAssistant, resource) -> float:
async def daily_data(hass: HomeAssistant, resource, t_from: datetime = None) -> (float, str):
"""Get daily usage from the API."""
# If it's before 01:06, we need to fetch yesterday's data
# Should only need to be before 00:36 but gas data can be 30 minutes behind electricity data
if datetime.now().time() <= time(1, 5):
_LOGGER.debug("Fetching yesterday's data")
now = datetime.now() - timedelta(days=1)
else:
now = datetime.now()
# Always pull down the last 6 hours of readings
now = datetime.now()
# Round to the day to set time to 00:00:00
t_from = await hass.async_add_executor_job(resource.round, now, "P1D")
# Round to the minute
t_to = await hass.async_add_executor_job(resource.round, now, "PT1M")

if t_from is None:
t_from = await hass.async_add_executor_job(resource.round, datetime.now() - timedelta(hours=6), "P1D")
# Round to the minute subtract 1 hour to account for non complete hours
t_to = await hass.async_add_executor_job(resource.round, (now - timedelta(hours=1)).replace(minute= 59, second=59), "PT1M")
# Tell Hildebrand to pull latest DCC data
try:
await hass.async_add_executor_job(resource.catchup)
Expand All @@ -176,20 +187,11 @@ async def daily_data(hass: HomeAssistant, resource) -> float:
_LOGGER.exception("Unexpected exception: %s. Please open an issue", ex)

try:
_LOGGER.debug(
"Get readings from %s to %s for %s", t_from, t_to, resource.classifier
)
readings = await hass.async_add_executor_job(
resource.get_readings, t_from, t_to, "P1D", "sum", True
resource.get_readings, t_from, t_to, "PT1H", "sum", True
)
_LOGGER.debug("Successfully got daily usage for resource id %s", resource.id)
_LOGGER.debug(
"Readings for %s has %s entries", resource.classifier, len(readings)
)
v = readings[0][1].value
if len(readings) > 1:
v += readings[1][1].value
return v
return readings
except requests.Timeout as ex:
_LOGGER.error("Timeout: %s", ex)
except requests.exceptions.ConnectionError as ex:
Expand Down Expand Up @@ -235,25 +237,76 @@ async def tariff_data(hass: HomeAssistant, resource) -> float:
_LOGGER.exception("Unexpected exception: %s. Please open an issue", ex)
return None

class HistoricalSensorMixin(PollUpdateMixin, HistoricalSensor, SensorEntity):
@property
def statistic_id(self) -> str:
return self.entity_id

def get_statistic_metadata(self) -> StatisticMetaData:
meta = super().get_statistic_metadata()
meta["has_sum"] = True
meta["has_mean"] = True

return meta

async def async_calculate_statistic_data(
self, hist_states: list[HistoricalState], *, latest: dict | None = None
) -> list[StatisticData]:
#
# Group historical states by hour
# Calculate sum, mean, etc...
#

accumulated = latest["sum"] if latest else 0

def hour_block_for_hist_state(hist_state: HistoricalState) -> datetime:
# XX:00:00 states belongs to previous hour block
if hist_state.dt.minute == 0 and hist_state.dt.second == 0:
dt = hist_state.dt - timedelta(hours=1)
return dt.replace(minute=0, second=0, microsecond=0)

else:
return hist_state.dt.replace(minute=0, second=0, microsecond=0)

ret = []
for dt, collection_it in itertools.groupby(
hist_states, key=hour_block_for_hist_state
):
collection = list(collection_it)
mean = statistics.mean([x.state for x in collection])
partial_sum = sum([x.state for x in collection])
accumulated = accumulated + partial_sum

ret.append(
StatisticData(
start=dt,
state=partial_sum,
mean=mean,
sum=accumulated,
)
)
return ret

class Usage(SensorEntity):
class Usage(PollUpdateMixin, HistoricalSensor, SensorEntity):
"""Sensor object for daily usage."""

_attr_device_class = SensorDeviceClass.ENERGY
_attr_has_entity_name = True
_attr_name = "Usage (today)"
_attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(self, hass: HomeAssistant, resource, virtual_entity) -> None:
"""Initialize the sensor."""
self._attr_unique_id = resource.id

self.UPDATE_INTERVAL = timedelta(minutes = 5)
self.hass = hass
self.initialised = False
self.resource = resource
self.virtual_entity = virtual_entity

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()

@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
Expand All @@ -271,41 +324,101 @@ def icon(self) -> str | None:
if self.resource.classifier == "gas.consumption":
return "mdi:fire"

async def async_update(self) -> None:
async def async_update_historical(self) -> None:
"""Fetch new data for the sensor."""
# Get data on initial startup
if not self.initialised:
value = await daily_data(self.hass, self.resource)
if value:
self._attr_native_value = round(value, 2)
self.initialised = True
readings = await daily_data(self.hass, self.resource)
hist_states = []
for reading in readings:
hist_states.append(HistoricalState( # noqa: PERF401
state = reading[1].value,
# add 1 minute to date so it can correctly call into the hour group
dt = dtutil.as_local(reading[0] + timedelta(minutes=1))
))
self._attr_historical_states = hist_states
self.initialised = True
else:
# Only update the sensor if it's between 0-5 or 30-35 minutes past the hour
if await should_update():
value = await daily_data(self.hass, self.resource)
if value:
self._attr_native_value = round(value, 2)
# Only update the sensor if it's between 1-5 or 31-35minutes past the hour
readings = await daily_data(self.hass, self.resource)
hist_states = []
for reading in readings:
hist_states.append(HistoricalState( # noqa: PERF401
state = reading[1].value,
dt = dtutil.as_local(reading[0] + timedelta(minutes=1))
))
self._attr_historical_states = hist_states

@property
def statistic_id(self) -> str:
return self.entity_id

def get_statistic_metadata(self) -> StatisticMetaData:
meta = super().get_statistic_metadata()
meta["has_sum"] = True
meta["has_mean"] = True

return meta

async def async_calculate_statistic_data(
self, hist_states: list[HistoricalState], *, latest: dict | None = None
) -> list[StatisticData]:
#
# Group historical states by hour
# Calculate sum, mean, etc...
#
accumulated = latest["sum"] if latest else 0

def hour_block_for_hist_state(hist_state: HistoricalState) -> datetime:
# XX:00:00 states belongs to previous hour block
if hist_state.dt.minute == 0 and hist_state.dt.second == 0:
dt = hist_state.dt - timedelta(hours=1)
return dt.replace(minute=0, second=0, microsecond=0)

else:
return hist_state.dt.replace(minute=0, second=0, microsecond=0)

ret = []
for dt, collection_it in itertools.groupby(
hist_states, key=hour_block_for_hist_state
):
collection = list(collection_it)
mean = statistics.mean([x.state for x in collection])
partial_sum = sum([x.state for x in collection])
accumulated = accumulated + partial_sum

ret.append(
StatisticData(
start=dt,
state=partial_sum,
mean=mean,
sum=accumulated,
)
)
return ret


class Cost(SensorEntity):
class Cost(HistoricalSensorMixin):
"""Sensor usage for daily cost."""

_attr_device_class = SensorDeviceClass.MONETARY
_attr_has_entity_name = True
_attr_name = "Cost (today)"
_attr_native_unit_of_measurement = "GBP"
_attr_state_class = SensorStateClass.TOTAL_INCREASING

def __init__(self, hass: HomeAssistant, resource, virtual_entity) -> None:
"""Initialize the sensor."""
self._attr_unique_id = resource.id

self.UPDATE_INTERVAL = timedelta(minutes = 5)
self.hass = hass
self.initialised = False
self.meter = None
self.resource = resource
self.virtual_entity = virtual_entity

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()

@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
Expand All @@ -317,20 +430,30 @@ def device_info(self) -> DeviceInfo:
name=device_name(self.resource, self.virtual_entity),
)

async def async_update(self) -> None:
async def async_update_historical(self) -> None:
"""Fetch new data for the sensor."""
# Get data on initial startup
if not self.initialised:
value = await daily_data(self.hass, self.resource)
if value:
self._attr_native_value = round(value / 100, 2)
self.initialised = True
else:
# Only update the sensor if it's between 0-5 or 30-35 minutes past the hour
if await should_update():
value = await daily_data(self.hass, self.resource)
if value:
self._attr_native_value = round(value / 100, 2)

readings = await daily_data(self.hass, self.resource)
hist_states = []
for reading in readings:
hist_states.append(HistoricalState( # noqa: PERF401
state = reading[1].value / 100,
dt = dtutil.as_local(reading[0] + timedelta(minutes=1)))
)
self._attr_historical_states = hist_states
self.initialised = True
elif await should_update():
# Only update the sensor if it's between 1-5 or 31-35minutes past the hour
readings = await daily_data(self.hass, self.resource)
hist_states = []
for reading in readings:
hist_states.append(HistoricalState( # noqa: PERF401
state = reading[1].value / 100,
dt = dtutil.as_local(reading[0] + timedelta(minutes=1))
))
self._attr_historical_states = hist_states
_LOGGER.debug(self._attr_historical_states)

class TariffCoordinator(DataUpdateCoordinator):
"""Data update coordinator for the tariff sensors."""
Expand Down