Skip to content

Commit

Permalink
feat: Improve energy consumption implementation with additional senso…
Browse files Browse the repository at this point in the history
…r for duration of boiler being on (#343)
  • Loading branch information
MislavMandaric committed Jun 30, 2024
1 parent e0dda95 commit a581a77
Show file tree
Hide file tree
Showing 9 changed files with 364 additions and 470 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@

**This component will set up the following platforms.**

| Platform | Description |
| --------------- |---------------------------------------------------------------|
| `climate` | Management of Vaillant thermostat. |
| `select` | Selector showing currently selected schedule. |
| `sensor` | Battery sensor for the thermostat.<br/>Mesurement gas sensors |
| `switch` | On/off switch for schedules. |
| `water_heater` | Management of Vaillant boiler. |
| Platform | Description |
| --------------- | -------------------------------------------------------------- |
| `climate` | <li>Management of Vaillant thermostat |
| `select` | <li>Selector showing currently selected schedule |
| `sensor` | <li>Battery sensor for the thermostat<li>Boiler energy sensors |
| `switch` | <li>On/off switch for schedules |
| `water_heater` | <li>Management of Vaillant boiler |

## Installation

Expand Down
58 changes: 4 additions & 54 deletions custom_components/vaillant_vsmart/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Constants for Vaillant vSMART."""
from homeassistant.components.sensor import SensorEntityDescription, SensorDeviceClass
from vaillant_netatmo_api import MeasurementType, Device, Module
from vaillant_netatmo_api import MeasurementType

# Base component constants
NAME = "Vaillant vSMART"
Expand All @@ -18,56 +17,7 @@
CONF_APP_VERSION = "app_version"
CONF_USER_PREFIX = "user_prefix"

SUPPORTED_ENERGY_MEASUREMENT_TYPES = [MeasurementType.SUM_ENERGY_GAS_HEATING, MeasurementType.SUM_ENERGY_GAS_WATER,
MeasurementType.SUM_ENERGY_ELEC_HEATING, MeasurementType.SUM_ENERGY_ELEC_WATER]

# Sensor entity descriptions for measurements
class VaillantSensorEntityDescription:
def __init__(self, key: str, measurement_type: MeasurementType, sensor_name: str, device_class: SensorDeviceClass,
icon: str,
unit: str, conversion: float = 1, enabled=True,
device: Device = None,
module: Module = None):
self.key = key
self.measurement_type = measurement_type
self.sensor_name: str = sensor_name
self.device_class = device_class
self.icon = icon
self.unit = unit
self.conversion = conversion
self.enabled = enabled
self.device = device
self.module = module


# List of measurement sensors to create
MEASUREMENT_SENSORS: list[VaillantSensorEntityDescription] = [
VaillantSensorEntityDescription(key="gaz_heating",
measurement_type=MeasurementType.SUM_ENERGY_GAS_HEATING,
sensor_name="Gaz heating",
icon="mdi:radiator",
device_class=SensorDeviceClass.ENERGY,
unit="kWh",
conversion=0.001,
enabled=True),
VaillantSensorEntityDescription(key="water_heating",
measurement_type=MeasurementType.SUM_ENERGY_GAS_WATER,
sensor_name="Gaz water heating",
icon="mdi:bathtub",
device_class=SensorDeviceClass.ENERGY,
unit="kWh",
conversion=0.001,
enabled=True),
VaillantSensorEntityDescription(key="elec_heating",
measurement_type=MeasurementType.SUM_ENERGY_ELEC_HEATING,
sensor_name="Electricity heating",
icon="mdi:meter-electric",
device_class=SensorDeviceClass.ENERGY,
unit="Wh",
enabled=False),
VaillantSensorEntityDescription(key="elec_water__heating",
measurement_type=MeasurementType.SUM_ENERGY_ELEC_WATER,
sensor_name="Electricity water heating",
icon="mdi:bathtub-outline",
device_class=SensorDeviceClass.ENERGY,
unit="Wh",
enabled=False)
]
SUPPORTED_DURATION_MEASUREMENT_TYPES = [MeasurementType.SUM_BOILER_ON]
175 changes: 97 additions & 78 deletions custom_components/vaillant_vsmart/entity.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
"""Vaillant vSMART entity classes."""
import copy
import datetime
from datetime import timedelta
from datetime import timedelta, datetime
import logging
from typing import Any

import jsonpickle
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
Expand All @@ -19,32 +16,24 @@
Module,
Program,
ThermostatClient,
RequestUnauthorizedException, MeasurementScale, MeasurementItem,
RequestUnauthorizedException,
MeasurementScale,
MeasurementItem,
MeasurementType,
)

from .const import DOMAIN, MEASUREMENT_SENSORS, VaillantSensorEntityDescription
from .const import DOMAIN, SUPPORTED_ENERGY_MEASUREMENT_TYPES, SUPPORTED_DURATION_MEASUREMENT_TYPES

UPDATE_INTERVAL = timedelta(minutes=5)

_LOGGER: logging.Logger = logging.getLogger(__package__)


class VaillantDataMeasure:
def __init__(self, device: Device, module: Module, sensor: VaillantSensorEntityDescription,
measures: list[MeasurementItem] | None,
last_reset: datetime.datetime | None):
self.last_reset = last_reset
self.device = device
self.module = module
self.sensor = sensor
self.measures = measures


class VaillantData:
"""Class holding data which coordinator provides to the entity."""

def __init__(self, client: ThermostatClient, devices: list[Device],
measurements: list[VaillantDataMeasure]) -> None:
measurements: dict[(str, str, MeasurementType), MeasurementItem]) -> None:
"""Initialize."""

self.client = client
Expand All @@ -71,66 +60,29 @@ def __init__(self, hass: HomeAssistant, client: ThermostatClient) -> None:
hass,
_LOGGER,
name=DOMAIN,
update_method=self._update_method,
update_interval=UPDATE_INTERVAL,
)

self._client = client
self.sensors: list[VaillantSensorEntityDescription] = []

async def _async_update_data(self):
async def _update_method(self):
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""

try:
devices = await self._client.async_get_thermostats_data()
measurements: list[VaillantDataMeasure] = []
for device in devices:
for module in device.modules:
# Add measurements of gaz consumption (list defined in const.py)
_LOGGER.debug("Vaillant update")
try:
for measurement_sensor in MEASUREMENT_SENSORS:
sensor: VaillantSensorEntityDescription | None = None
for local_sensor in self.sensors:
if (local_sensor.key == measurement_sensor.key
and local_sensor.device.id == device.id
and local_sensor.module.id == module.id):
sensor = local_sensor
break
new_sensor = False
if sensor is None:
sensor = copy.copy(measurement_sensor)
sensor.device = device
sensor.module = module
self.sensors.append(sensor)
new_sensor = True
# Don't extract sensor information if it is disabled and it is not the first refresh of the sensor
if new_sensor is False and sensor.enabled is False:
_LOGGER.debug("Vaillant sensor %s is disabled, data won't be extracted",
measurement_sensor.sensor_name)
measurements.append(VaillantDataMeasure(device, module, sensor, None, None))
continue
# Range : from the start of the current day until the end of the day
date_begin = datetime.datetime.now().replace(hour=0, minute=0, second=0,
microsecond=0)
date_end = date_begin + datetime.timedelta(days=1)
measured = await self._client.async_get_measure(device_id=device.id, module_id=module.id,
type=sensor.measurement_type,
scale=MeasurementScale.DAY,
date_begin=date_begin,
date_end=date_end)

_LOGGER.debug("Vaillant update measure for %s (%s -> %s): %s", sensor.sensor_name,
date_begin.strftime("%m/%d/%Y %H:%M:%S"),
date_end.strftime("%m/%d/%Y %H:%M:%S"),
jsonpickle.encode(measured))
measurements.append(VaillantDataMeasure(device, module, sensor, measured, date_begin))
# Store the result in the module measured field
# setattr(module.measured, MeasurementType.SUM_ENERGY_GAS_HEATING.value, measured)
except Exception as ex:
_LOGGER.debug("Vaillant error during extraction of measures", exc_info=ex)

date_begin = datetime.now() - timedelta(days=1)
measurements = {
(device.id, module.id, measurement_type): await self._client.async_get_measure(device.id, module.id, measurement_type, MeasurementScale.DAY, date_begin)
for device in devices
for module in device.modules
for measurement_type in SUPPORTED_ENERGY_MEASUREMENT_TYPES+SUPPORTED_DURATION_MEASUREMENT_TYPES
}

return VaillantData(self._client, devices, measurements)
except RequestUnauthorizedException as ex:
Expand All @@ -144,9 +96,9 @@ class VaillantDeviceEntity(CoordinatorEntity[VaillantData]):
"""Base class for Vaillant device entities."""

def __init__(
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
):
"""Initialize."""

Expand Down Expand Up @@ -194,10 +146,10 @@ class VaillantModuleEntity(CoordinatorEntity[VaillantData]):
"""Base class for Vaillant module entities."""

def __init__(
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
module_id: str,
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
module_id: str,
):
"""Initialize."""

Expand Down Expand Up @@ -253,11 +205,11 @@ class VaillantProgramEntity(CoordinatorEntity[VaillantData]):
"""Base class for Vaillant program entities."""

def __init__(
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
module_id: str,
program_id: str,
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
module_id: str,
program_id: str,
):
"""Initialize."""

Expand Down Expand Up @@ -314,3 +266,70 @@ def device_info(self) -> dict[str, Any]:
"manufacturer": self._device.type,
"via_device": (DOMAIN, self._device.id),
}


class VaillantMeasurementEntity(CoordinatorEntity[VaillantData]):
"""Base class for Vaillant measurement entities."""

def __init__(
self,
coordinator: DataUpdateCoordinator[VaillantData],
device_id: str,
module_id: str,
measurement_type: MeasurementType,
):
"""Initialize."""

super().__init__(coordinator)

self._device_id = device_id
self._module_id = module_id
self._measurement_type = measurement_type

@property
def _client(self) -> ThermostatClient:
"""Retrun the instance of the client which enables HTTP communication with the API."""

return self.coordinator.data.client

@property
def _device(self) -> Device:
"""Return the device which this entity represents."""

return self.coordinator.data.devices[self._device_id]

@property
def _module(self) -> Module:
"""Return the module which this entity represents."""

return self.coordinator.data.modules[self._module_id]

@property
def _measurement(self) -> MeasurementItem:
"""Return the measurement which this entity represents."""

return self.coordinator.data.measurements[(self._device_id, self._module_id, self._measurement_type)][-1]

@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""

return f"{self._module.id}_{self._measurement_type}"

@ property
def has_entity_name(self) -> bool:
"""Return if entity is using new entity naming conventions."""

return True

@ property
def device_info(self) -> dict[str, Any]:
"""Return all device info available for this entity."""

return {
"identifiers": {(DOMAIN, self._module.id)},
"name": self._module.module_name,
"sw_version": self._module.firmware,
"manufacturer": self._device.type,
"via_device": (DOMAIN, self._device.id),
}
Loading

0 comments on commit a581a77

Please sign in to comment.