Add energy history coordinator and sensors to Teslemetry (#126166)

* start

* More

* fix init

* Update requirements_all.txt

* Update requirements_test_all.txt

* Add Tests

* Add missing fixture

* first refresh history

* Fix mock_energy_history

* Remove failures prop

* Update test_init.py

* Actually add the sensors

* Add more icons

* suggested_display_precision

* Fix updated_once

* Fix fixture

* Review changes

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove init data

* Update homeassistant/components/teslemetry/coordinator.py

* ruff

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Brett Adams 2024-09-24 18:32:38 +10:00 committed by GitHub
parent 4c0fb04f61
commit 5186605cec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1876 additions and 6 deletions

View File

@ -23,6 +23,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, MODELS
from .coordinator import (
TeslemetryEnergyHistoryCoordinator,
TeslemetryEnergySiteInfoCoordinator,
TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator,
@ -120,8 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
continue
api = EnergySpecific(teslemetry.energy, site_id)
live_coordinator = TeslemetryEnergySiteLiveCoordinator(hass, api)
info_coordinator = TeslemetryEnergySiteInfoCoordinator(hass, api, product)
device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
manufacturer="Tesla",
@ -133,8 +132,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
energysites.append(
TeslemetryEnergyData(
api=api,
live_coordinator=live_coordinator,
info_coordinator=info_coordinator,
live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api),
info_coordinator=TeslemetryEnergySiteInfoCoordinator(
hass, api, product
),
history_coordinator=TeslemetryEnergyHistoryCoordinator(hass, api),
id=site_id,
device=device,
)
@ -154,6 +156,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
energysite.info_coordinator.async_config_entry_first_refresh()
for energysite in energysites
),
*(
energysite.history_coordinator.async_config_entry_first_refresh()
for energysite in energysites
),
)
# Add energy device models

View File

@ -16,6 +16,30 @@ MODELS = {
"Y": "Model Y",
}
ENERGY_HISTORY_FIELDS = [
"solar_energy_exported",
"generator_energy_exported",
"grid_energy_imported",
"grid_services_energy_imported",
"grid_services_energy_exported",
"grid_energy_exported_from_solar",
"grid_energy_exported_from_generator",
"grid_energy_exported_from_battery",
"battery_energy_exported",
"battery_energy_imported_from_grid",
"battery_energy_imported_from_solar",
"battery_energy_imported_from_generator",
"consumer_energy_imported_from_grid",
"consumer_energy_imported_from_solar",
"consumer_energy_imported_from_battery",
"consumer_energy_imported_from_generator",
"total_home_usage",
"total_battery_charge",
"total_battery_discharge",
"total_solar_generation",
"total_grid_energy_exported",
]
class TeslemetryState(StrEnum):
"""Teslemetry Vehicle States."""

View File

@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from typing import Any
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import VehicleDataEndpoint
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
Forbidden,
InvalidToken,
@ -17,12 +17,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER, TeslemetryState
from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslemetryState
VEHICLE_INTERVAL = timedelta(seconds=30)
VEHICLE_WAIT = timedelta(minutes=15)
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
ENERGY_INFO_INTERVAL = timedelta(seconds=30)
ENERGY_HISTORY_INTERVAL = timedelta(seconds=60)
ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE,
@ -178,3 +179,39 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
raise UpdateFailed(e.message) from e
return flatten(data)
class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site info from the Teslemetry API."""
updated_once: bool
def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
"""Initialize Teslemetry Energy Info coordinator."""
super().__init__(
hass,
LOGGER,
name=f"Teslemetry Energy History {api.energy_site_id}",
update_interval=ENERGY_HISTORY_INTERVAL,
)
self.api = api
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
try:
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
self.updated_once = True
# Add all time periods together
output = {key: 0 for key in ENERGY_HISTORY_FIELDS}
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
output[key] += period.get(key, 0)
return output

View File

@ -11,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import (
TeslemetryEnergyHistoryCoordinator,
TeslemetryEnergySiteInfoCoordinator,
TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator,
@ -22,6 +23,7 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
class TeslemetryEntity(
CoordinatorEntity[
TeslemetryVehicleDataCoordinator
| TeslemetryEnergyHistoryCoordinator
| TeslemetryEnergySiteLiveCoordinator
| TeslemetryEnergySiteInfoCoordinator
]
@ -33,6 +35,7 @@ class TeslemetryEntity(
def __init__(
self,
coordinator: TeslemetryVehicleDataCoordinator
| TeslemetryEnergyHistoryCoordinator
| TeslemetryEnergySiteLiveCoordinator
| TeslemetryEnergySiteInfoCoordinator,
api: VehicleSpecific | EnergySpecific,
@ -148,6 +151,21 @@ class TeslemetryEnergyInfoEntity(TeslemetryEntity):
super().__init__(data.info_coordinator, data.api, key)
class TeslemetryEnergyHistoryEntity(TeslemetryEntity):
"""Parent class for Teslemetry Energy History Entities."""
def __init__(
self,
data: TeslemetryEnergyData,
key: str,
) -> None:
"""Initialize common aspects of a Teslemetry Energy Site Info entity."""
self._attr_unique_id = f"{data.id}-{key}"
self._attr_device_info = data.device
super().__init__(data.history_coordinator, data.api, key)
class TeslemetryWallConnectorEntity(
TeslemetryEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator]
):

View File

@ -219,6 +219,69 @@
},
"wall_connector_state": {
"default": "mdi:ev-station"
},
"total_home_usage": {
"default": "mdi:home-lightning-bolt"
},
"total_battery_charge": {
"default": "mdi:battery-arrow-up"
},
"total_battery_discharge": {
"default": "mdi:battery-arrow-down"
},
"total_solar_production": {
"default": "mdi:solar-power-variant"
},
"grid_energy_imported": {
"default": "mdi:transmission-tower-import"
},
"total_grid_energy_exported": {
"default": "mdi:transmission-tower-export"
},
"solar_energy_exported": {
"default": "mdi:solar-power-variant"
},
"generator_energy_exported": {
"default": "mdi:generator-stationary"
},
"grid_services_energy_imported": {
"default": "mdi:transmission-tower-import"
},
"grid_services_energy_exported": {
"default": "mdi:transmission-tower-export"
},
"grid_energy_exported_from_solar": {
"default": "mdi:solar-power"
},
"grid_energy_exported_from_generator": {
"default": "mdi:generator-stationary"
},
"grid_energy_exported_from_battery": {
"default": "mdi:battery-arrow-down"
},
"battery_energy_exported": {
"default": "mdi:battery-arrow-down"
},
"battery_energy_imported_from_grid": {
"default": "mdi:transmission-tower-import"
},
"battery_energy_imported_from_solar": {
"default": "mdi:solar-power"
},
"battery_energy_imported_from_generator": {
"default": "mdi:generator-stationary"
},
"consumer_energy_imported_from_grid": {
"default": "mdi:transmission-tower-import"
},
"consumer_energy_imported_from_solar": {
"default": "mdi:solar-power"
},
"consumer_energy_imported_from_battery": {
"default": "mdi:home-battery"
},
"consumer_energy_imported_from_generator": {
"default": "mdi:generator-stationary"
}
},
"switch": {

View File

@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import (
TeslemetryEnergyHistoryCoordinator,
TeslemetryEnergySiteInfoCoordinator,
TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator,
@ -44,5 +45,6 @@ class TeslemetryEnergyData:
api: EnergySpecific
live_coordinator: TeslemetryEnergySiteLiveCoordinator
info_coordinator: TeslemetryEnergySiteInfoCoordinator
history_coordinator: TeslemetryEnergyHistoryCoordinator
id: int
device: DeviceInfo

View File

@ -34,7 +34,9 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
from . import TeslemetryConfigEntry
from .const import ENERGY_HISTORY_FIELDS
from .entity import (
TeslemetryEnergyHistoryEntity,
TeslemetryEnergyInfoEntity,
TeslemetryEnergyLiveEntity,
TeslemetryVehicleEntity,
@ -414,6 +416,21 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(key="version"),
)
ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple(
SensorEntityDescription(
key=key,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=(
key.startswith("total") or key == "grid_energy_imported"
),
)
for key in ENERGY_HISTORY_FIELDS
)
async def async_setup_entry(
hass: HomeAssistant,
@ -451,6 +468,13 @@ async def async_setup_entry(
for description in ENERGY_INFO_DESCRIPTIONS
if description.key in energysite.info_coordinator.data
),
( # Add energy history sensor
TeslemetryEnergyHistorySensorEntity(energysite, description)
for energysite in entry.runtime_data.energysites
for description in ENERGY_HISTORY_DESCRIPTIONS
if energysite.info_coordinator.data.get("components_battery")
or energysite.info_coordinator.data.get("components_solar")
),
)
)
@ -566,3 +590,22 @@ class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity)
"""Update the attributes of the sensor."""
self._attr_available = not self.is_none
self._attr_native_value = self._value
class TeslemetryEnergyHistorySensorEntity(TeslemetryEnergyHistoryEntity, SensorEntity):
"""Base class for Tesla Fleet energy site metric sensors."""
entity_description: SensorEntityDescription
def __init__(
self,
data: TeslemetryEnergyData,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
super().__init__(data, description.key)
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
self._attr_native_value = self._value

View File

@ -436,6 +436,69 @@
},
"wall_connector_state": {
"name": "State code"
},
"solar_energy_exported": {
"name": "Solar exported"
},
"generator_energy_exported": {
"name": "Generator exported"
},
"grid_energy_imported": {
"name": "Grid imported"
},
"grid_services_energy_imported": {
"name": "Grid services imported"
},
"grid_services_energy_exported": {
"name": "Grid services exported"
},
"grid_energy_exported_from_solar": {
"name": "Grid exported from solar"
},
"grid_energy_exported_from_generator": {
"name": "Grid exported from generator"
},
"grid_energy_exported_from_battery": {
"name": "Grid exported from battery"
},
"battery_energy_exported": {
"name": "Battery exported"
},
"battery_energy_imported_from_grid": {
"name": "Battery imported from grid"
},
"battery_energy_imported_from_solar": {
"name": "Battery imported from solar"
},
"battery_energy_imported_from_generator": {
"name": "Battery imported from generator"
},
"consumer_energy_imported_from_grid": {
"name": "Consumer imported from grid"
},
"consumer_energy_imported_from_solar": {
"name": "Consumer imported from solar"
},
"consumer_energy_imported_from_battery": {
"name": "Consumer imported from battery"
},
"consumer_energy_imported_from_generator": {
"name": "Consumer imported from generator"
},
"total_home_usage": {
"name": "Home usage"
},
"total_battery_charge": {
"name": "Battery charged"
},
"total_battery_discharge": {
"name": "Battery discharged"
},
"total_solar_generation": {
"name": "Solar generated"
},
"total_grid_energy_exported": {
"name": "Grid exported"
}
},
"switch": {

View File

@ -10,6 +10,7 @@ import pytest
from .const import (
COMMAND_OK,
ENERGY_HISTORY,
LIVE_STATUS,
METADATA,
PRODUCTS,
@ -95,3 +96,13 @@ def mock_site_info():
side_effect=lambda: deepcopy(SITE_INFO),
) as mock_live_status:
yield mock_live_status
@pytest.fixture(autouse=True)
def mock_energy_history():
"""Mock Teslemetry Energy Specific site_info method."""
with patch(
"homeassistant.components.teslemetry.EnergySpecific.energy_history",
return_value=ENERGY_HISTORY,
) as mock_live_status:
yield mock_live_status

View File

@ -15,6 +15,7 @@ VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN)
VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN)
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN)
ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN)
COMMAND_OK = {"response": {"result": True, "reason": ""}}
COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}}

View File

@ -0,0 +1,55 @@
{
"response": {
"serial_number": "xxxxxx",
"period": "day",
"installation_time_zone": "Australia/Brisbane",
"time_series": [
{
"timestamp": "2024-09-18T00:00:00+10:00",
"solar_energy_exported": 0,
"generator_energy_exported": 0,
"grid_energy_imported": 0,
"grid_services_energy_imported": 0,
"grid_services_energy_exported": 0,
"grid_energy_exported_from_solar": 0,
"grid_energy_exported_from_generator": 0,
"grid_energy_exported_from_battery": 0,
"battery_energy_exported": 36,
"battery_energy_imported_from_grid": 0,
"battery_energy_imported_from_solar": 0,
"battery_energy_imported_from_generator": 0,
"consumer_energy_imported_from_grid": 0,
"consumer_energy_imported_from_solar": 0,
"consumer_energy_imported_from_battery": 36,
"consumer_energy_imported_from_generator": 0,
"raw_timestamp": "2024-09-18T00:00:00+10:00",
"total_home_usage": 36,
"total_battery_discharge": 36
},
{
"timestamp": "2024-09-18T08:45:00+10:00",
"solar_energy_exported": 724,
"generator_energy_exported": 0,
"grid_energy_imported": 0,
"grid_services_energy_imported": 0,
"grid_services_energy_exported": 0,
"grid_energy_exported_from_solar": 2,
"grid_energy_exported_from_generator": 0,
"grid_energy_exported_from_battery": 0,
"battery_energy_exported": 0,
"battery_energy_imported_from_grid": 0,
"battery_energy_imported_from_solar": 684,
"battery_energy_imported_from_generator": 0,
"consumer_energy_imported_from_grid": 0,
"consumer_energy_imported_from_solar": 38,
"consumer_energy_imported_from_battery": 0,
"consumer_energy_imported_from_generator": 0,
"raw_timestamp": "2024-09-18T08:45:00+10:00",
"total_home_usage": 38,
"total_solar_generation": 724,
"total_battery_charge": 684,
"total_grid_energy_exported": 2
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -188,3 +188,17 @@ async def test_energy_site_refresh_error(
mock_site_info.side_effect = side_effect
entry = await setup_platform(hass)
assert entry.state is state
# Test Energy History Coordinator
@pytest.mark.parametrize(("side_effect", "state"), ERRORS)
async def test_energy_history_refresh_error(
hass: HomeAssistant,
mock_energy_history: AsyncMock,
side_effect: TeslaFleetError,
state: ConfigEntryState,
) -> None:
"""Test coordinator refresh with an error."""
mock_energy_history.side_effect = side_effect
entry = await setup_platform(hass)
assert entry.state is state