Add sensor platform to Teslemetry (#109088)

* Adding Energy

* Adding Energy

* Work in progress

* Add fixtures

* Add product info

* Add sensors

* Add icons

* Update metadata

* Use SensorEntityDescription for Energy

* Use ENERGY_STORAGE

* Add tests

* Fix coverage

* Update wall connector precision and units

* Change devices

* Fix serial number

* Add icons and VIN to wall connector

* Fix serial number again

* Update snapshots

* Use timestamp for minutes to arrival

* Cleanup snapshot

* Improvements

* Update fixture

* Add "code" to translations

* Add "code" to snapshot

* Use async_add_entities once

* Disable a bunch of sensors

* Ruff

* Improve fixture and test coverage

* Regen Snapshots

* Add init to coordinator
pull/112267/head
Brett Adams 2024-03-05 03:42:56 +10:00 committed by GitHub
parent b195c3fa7b
commit b5528de807
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 3186 additions and 58 deletions

View File

@ -2,7 +2,7 @@
import asyncio
from typing import Final
from tesla_fleet_api import Teslemetry, VehicleSpecific
from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError
from homeassistant.config_entries import ConfigEntry
@ -12,12 +12,13 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from .coordinator import TeslemetryVehicleDataCoordinator
from .models import TeslemetryVehicleData
from .coordinator import (
TeslemetryEnergyDataCoordinator,
TeslemetryVehicleDataCoordinator,
)
from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData
PLATFORMS: Final = [
Platform.CLIMATE,
]
PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -42,29 +43,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from e
# Create array of classes
data = []
vehicles: list[TeslemetryVehicleData] = []
energysites: list[TeslemetryEnergyData] = []
for product in products:
if "vin" not in product:
continue
vin = product["vin"]
api = VehicleSpecific(teslemetry.vehicle, vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
data.append(
TeslemetryVehicleData(
api=api,
coordinator=coordinator,
vin=vin,
if "vin" in product:
vin = product["vin"]
api = VehicleSpecific(teslemetry.vehicle, vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
vehicles.append(
TeslemetryVehicleData(
api=api,
coordinator=coordinator,
vin=vin,
)
)
elif "energy_site_id" in product:
site_id = product["energy_site_id"]
api = EnergySpecific(teslemetry.energy, site_id)
energysites.append(
TeslemetryEnergyData(
api=api,
coordinator=TeslemetryEnergyDataCoordinator(hass, api),
id=site_id,
info=product,
)
)
)
# Do all coordinator first refresh simultaneously
# Do all coordinator first refreshes simultaneously
await asyncio.gather(
*(vehicle.coordinator.async_config_entry_first_refresh() for vehicle in data)
*(
vehicle.coordinator.async_config_entry_first_refresh()
for vehicle in vehicles
),
*(
energysite.coordinator.async_config_entry_first_refresh()
for energysite in energysites
),
)
# Setup Platforms
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData(
vehicles, energysites
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -26,7 +26,7 @@ async def async_setup_entry(
async_add_entities(
TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER)
for vehicle in data
for vehicle in data.vehicles
)

View File

@ -2,7 +2,7 @@
from datetime import timedelta
from typing import Any
from tesla_fleet_api import VehicleSpecific
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline
from homeassistant.core import HomeAssistant
@ -14,19 +14,29 @@ from .const import LOGGER, TeslemetryState
SYNC_INTERVAL = 60
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API."""
class TeslemetryDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Base class for Teslemetry Data Coordinators."""
def __init__(self, hass: HomeAssistant, api: VehicleSpecific) -> None:
"""Initialize Teslemetry Data Update Coordinator."""
name: str
def __init__(
self, hass: HomeAssistant, api: VehicleSpecific | EnergySpecific
) -> None:
"""Initialize Teslemetry Vehicle Update Coordinator."""
super().__init__(
hass,
LOGGER,
name="Teslemetry Vehicle",
name=self.name,
update_interval=timedelta(seconds=SYNC_INTERVAL),
)
self.api = api
class TeslemetryVehicleDataCoordinator(TeslemetryDataCoordinator):
"""Class to manage fetching data from the Teslemetry API."""
name = "Teslemetry Vehicle"
async def async_config_entry_first_refresh(self) -> None:
"""Perform first refresh."""
try:
@ -65,3 +75,24 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else:
result[key] = value
return result
class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator):
"""Class to manage fetching data from the Teslemetry API."""
name = "Teslemetry Energy Site"
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
try:
data = await self.api.live_status()
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
# Convert Wall Connectors from array to dict
data["response"]["wall_connectors"] = {
wc["din"]: wc for wc in data["response"].get("wall_connectors", [])
}
return data["response"]

View File

@ -10,12 +10,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MODELS, TeslemetryState
from .coordinator import TeslemetryVehicleDataCoordinator
from .models import TeslemetryVehicleData
from .coordinator import (
TeslemetryEnergyDataCoordinator,
TeslemetryVehicleDataCoordinator,
)
from .models import TeslemetryEnergyData, TeslemetryVehicleData
class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]):
"""Parent class for Teslemetry Entities."""
"""Parent class for Teslemetry Vehicle Entities."""
_attr_has_entity_name = True
@ -74,3 +77,65 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator
for key, value in args:
self.coordinator.data[key] = value
self.async_write_ha_state()
class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]):
"""Parent class for Teslemetry Energy Entities."""
_attr_has_entity_name = True
def __init__(
self,
energysite: TeslemetryEnergyData,
key: str,
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
super().__init__(energysite.coordinator)
self.key = key
self.api = energysite.api
self._attr_translation_key = key
self._attr_unique_id = f"{energysite.id}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(energysite.id))},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name=self.coordinator.data.get("site_name", "Energy Site"),
)
def get(self, key: str | None = None, default: Any | None = None) -> Any:
"""Return a specific value from coordinator data."""
return self.coordinator.data.get(key or self.key, default)
class TeslemetryWallConnectorEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]):
"""Parent class for Teslemetry Wall Connector Entities."""
_attr_has_entity_name = True
def __init__(
self,
energysite: TeslemetryEnergyData,
din: str,
key: str,
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
super().__init__(energysite.coordinator)
self.din = din
self.key = key
self._attr_translation_key = key
self._attr_unique_id = f"{energysite.id}-{din}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, din)},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name="Wall Connector",
via_device=(DOMAIN, str(energysite.id)),
serial_number=din.split("-")[-1],
)
@property
def _value(self) -> int:
"""Return a specific wall connector value from coordinator data."""
return self.coordinator.data["wall_connectors"][self.din].get(self.key)

View File

@ -4,9 +4,20 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from tesla_fleet_api import VehicleSpecific
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from .coordinator import TeslemetryVehicleDataCoordinator
from .coordinator import (
TeslemetryEnergyDataCoordinator,
TeslemetryVehicleDataCoordinator,
)
@dataclass
class TeslemetryData:
"""Data for the Teslemetry integration."""
vehicles: list[TeslemetryVehicleData]
energysites: list[TeslemetryEnergyData]
@dataclass
@ -17,3 +28,13 @@ class TeslemetryVehicleData:
coordinator: TeslemetryVehicleDataCoordinator
vin: str
wakelock = asyncio.Lock()
@dataclass
class TeslemetryEnergyData:
"""Data for a vehicle in the Teslemetry integration."""
api: EnergySpecific
coordinator: TeslemetryEnergyDataCoordinator
id: int
info: dict[str, str]

View File

@ -0,0 +1,461 @@
"""Sensor platform for Teslemetry integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from itertools import chain
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .entity import (
TeslemetryEnergyEntity,
TeslemetryVehicleEntity,
TeslemetryWallConnectorEntity,
)
from .models import TeslemetryEnergyData, TeslemetryVehicleData
@callback
def minutes_to_datetime(value: StateType) -> datetime | None:
"""Convert relative minutes into absolute datetime."""
if isinstance(value, (int, float)) and value > 0:
return dt_util.now() + timedelta(minutes=value)
return None
@dataclass(frozen=True, kw_only=True)
class TeslemetrySensorEntityDescription(SensorEntityDescription):
"""Describes Teslemetry Sensor entity."""
value_fn: Callable[[StateType], StateType | datetime] = lambda x: x
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
TeslemetrySensorEntityDescription(
key="charge_state_usable_battery_level",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
TeslemetrySensorEntityDescription(
key="charge_state_charge_energy_added",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=1,
),
TeslemetrySensorEntityDescription(
key="charge_state_charger_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
),
TeslemetrySensorEntityDescription(
key="charge_state_charger_voltage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetrySensorEntityDescription(
key="charge_state_charger_actual_current",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetrySensorEntityDescription(
key="charge_state_charge_rate",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="charge_state_minutes_to_full_charge",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=minutes_to_datetime,
),
TeslemetrySensorEntityDescription(
key="charge_state_battery_range",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
),
TeslemetrySensorEntityDescription(
key="drive_state_speed",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="drive_state_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="drive_state_shift_state",
icon="mdi:car-shift-pattern",
options=["p", "d", "r", "n"],
device_class=SensorDeviceClass.ENUM,
value_fn=lambda x: x.lower() if isinstance(x, str) else x,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="vehicle_state_odometer",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="vehicle_state_tpms_pressure_fl",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="vehicle_state_tpms_pressure_fr",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="vehicle_state_tpms_pressure_rl",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="vehicle_state_tpms_pressure_rr",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
device_class=SensorDeviceClass.PRESSURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="climate_state_inside_temp",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
TeslemetrySensorEntityDescription(
key="climate_state_outside_temp",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
),
TeslemetrySensorEntityDescription(
key="climate_state_driver_temp_setting",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="climate_state_passenger_temp_setting",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="drive_state_active_route_traffic_minutes_delay",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="drive_state_active_route_energy_at_arrival",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetrySensorEntityDescription(
key="drive_state_active_route_miles_to_arrival",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
),
TeslemetrySensorEntityDescription(
key="drive_state_active_route_minutes_to_arrival",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=minutes_to_datetime,
),
TeslemetrySensorEntityDescription(
key="drive_state_active_route_destination",
icon="mdi:map-marker",
entity_category=EntityCategory.DIAGNOSTIC,
),
)
ENERGY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="solar_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:solar-power",
),
SensorEntityDescription(
key="energy_left",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:battery",
),
SensorEntityDescription(
key="total_pack_energy",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:battery-high",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="percentage_charged",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
suggested_display_precision=2,
),
SensorEntityDescription(
key="battery_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:home-battery",
),
SensorEntityDescription(
key="load_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:power-plug",
),
SensorEntityDescription(
key="grid_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:transmission-tower",
),
SensorEntityDescription(
key="grid_services_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:transmission-tower",
),
SensorEntityDescription(
key="generator_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:generator-stationary",
entity_registry_enabled_default=False,
),
)
WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="wall_connector_state",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:ev-station",
),
SensorEntityDescription(
key="wall_connector_fault_state",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:ev-station",
),
SensorEntityDescription(
key="wall_connector_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
icon="mdi:ev-station",
),
SensorEntityDescription(
key="vin",
icon="mdi:car-electric",
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Teslemetry sensor platform from a config entry."""
data = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
chain(
( # Add vehicles
TeslemetryVehicleSensorEntity(vehicle, description)
for vehicle in data.vehicles
for description in VEHICLE_DESCRIPTIONS
),
( # Add energy sites
TeslemetryEnergySensorEntity(energysite, description)
for energysite in data.energysites
for description in ENERGY_DESCRIPTIONS
if description.key in energysite.coordinator.data
),
( # Add wall connectors
TeslemetryWallConnectorSensorEntity(energysite, din, description)
for energysite in data.energysites
for din in energysite.coordinator.data.get("wall_connectors", {})
for description in WALL_CONNECTOR_DESCRIPTIONS
),
)
)
class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
"""Base class for Teslemetry vehicle metric sensors."""
entity_description: TeslemetrySensorEntityDescription
def __init__(
self,
vehicle: TeslemetryVehicleData,
description: TeslemetrySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(vehicle, description.key)
self.entity_description = description
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.get())
@property
def available(self) -> bool:
"""Return if sensor is available."""
return super().available and self.get() is not None
class TeslemetryEnergySensorEntity(TeslemetryEnergyEntity, SensorEntity):
"""Base class for Teslemetry energy site metric sensors."""
entity_description: SensorEntityDescription
def __init__(
self,
energysite: TeslemetryEnergyData,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(energysite, description.key)
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.get()
class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity):
"""Base class for Teslemetry energy site metric sensors."""
entity_description: SensorEntityDescription
def __init__(
self,
energysite: TeslemetryEnergyData,
din: str,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(
energysite,
din,
description.key,
)
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._value

View File

@ -30,6 +30,128 @@
}
}
}
},
"sensor": {
"charge_state_usable_battery_level": {
"name": "Battery level"
},
"charge_state_charge_energy_added": {
"name": "Charge energy added"
},
"charge_state_charger_power": {
"name": "Charger power"
},
"charge_state_charger_voltage": {
"name": "Charger voltage"
},
"charge_state_charger_actual_current": {
"name": "Charger current"
},
"charge_state_charge_rate": {
"name": "Charge rate"
},
"charge_state_battery_range": {
"name": "Battery range"
},
"charge_state_minutes_to_full_charge": {
"name": "Time to full charge"
},
"drive_state_speed": {
"name": "Speed"
},
"drive_state_power": {
"name": "Power"
},
"drive_state_shift_state": {
"name": "Shift state",
"state": {
"p": "Park",
"d": "Drive",
"r": "Reverse",
"n": "Neutral"
}
},
"vehicle_state_odometer": {
"name": "Odometer"
},
"vehicle_state_tpms_pressure_fl": {
"name": "Tire pressure front left"
},
"vehicle_state_tpms_pressure_fr": {
"name": "Tire pressure front right"
},
"vehicle_state_tpms_pressure_rl": {
"name": "Tire pressure rear left"
},
"vehicle_state_tpms_pressure_rr": {
"name": "Tire pressure rear right"
},
"climate_state_inside_temp": {
"name": "Inside temperature"
},
"climate_state_outside_temp": {
"name": "Outside temperature"
},
"climate_state_driver_temp_setting": {
"name": "Driver temperature setting"
},
"climate_state_passenger_temp_setting": {
"name": "Passenger temperature setting"
},
"drive_state_active_route_traffic_minutes_delay": {
"name": "Traffic delay"
},
"drive_state_active_route_energy_at_arrival": {
"name": "State of charge at arrival"
},
"drive_state_active_route_miles_to_arrival": {
"name": "Distance to arrival"
},
"drive_state_active_route_minutes_to_arrival": {
"name": "Time to arrival"
},
"drive_state_active_route_destination": {
"name": "Destination"
},
"solar_power": {
"name": "Solar power"
},
"energy_left": {
"name": "Energy left"
},
"total_pack_energy": {
"name": "Total pack energy"
},
"percentage_charged": {
"name": "Percentage charged"
},
"battery_power": {
"name": "Battery power"
},
"load_power": {
"name": "Load power"
},
"grid_power": {
"name": "Grid power"
},
"grid_services_power": {
"name": "Grid services power"
},
"generator_power": {
"name": "Generator power"
},
"wall_connector_state": {
"name": "State code"
},
"wall_connector_fault_state": {
"name": "Fault state code"
},
"wall_connector_power": {
"name": "Power"
},
"vin": {
"name": "Vehicle"
}
}
}
}

View File

@ -1,11 +1,12 @@
"""Fixtures for Tessie."""
from __future__ import annotations
from copy import deepcopy
from unittest.mock import patch
import pytest
from .const import PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE
from .const import LIVE_STATUS, PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE
@pytest.fixture(autouse=True)
@ -55,3 +56,13 @@ def mock_request():
return_value=RESPONSE_OK,
) as mock_request:
yield mock_request
@pytest.fixture(autouse=True)
def mock_live_status():
"""Mock Teslemetry Energy Specific live_status method."""
with patch(
"homeassistant.components.teslemetry.EnergySpecific.live_status",
side_effect=lambda: deepcopy(LIVE_STATUS),
) as mock_live_status:
yield mock_live_status

View File

@ -12,5 +12,6 @@ WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None}
PRODUCTS = load_json_object_fixture("products.json", DOMAIN)
VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN)
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
RESPONSE_OK = {"response": {}, "error": None}

View File

@ -0,0 +1,33 @@
{
"response": {
"solar_power": 1185,
"energy_left": 38896.47368421053,
"total_pack_energy": 40727,
"percentage_charged": 95.50537403739663,
"backup_capable": true,
"battery_power": 5060,
"load_power": 6245,
"grid_status": "Active",
"grid_services_active": false,
"grid_power": 0,
"grid_services_power": 0,
"generator_power": 0,
"island_status": "on_grid",
"storm_mode_active": false,
"timestamp": "2024-01-01T00:00:00+00:00",
"wall_connectors": [
{
"din": "abd-123",
"wall_connector_state": 2,
"wall_connector_fault_state": 2,
"wall_connector_power": 0
},
{
"din": "bcd-234",
"wall_connector_state": 2,
"wall_connector_fault_state": 2,
"wall_connector_power": 0
}
]
}
}

View File

@ -71,28 +71,50 @@
"release_notes_supported": true
},
{
"energy_site_id": 2345,
"resource_type": "wall_connector",
"id": "ID1234",
"asset_site_id": "abcdef",
"warp_site_number": "ID1234",
"energy_site_id": 123456,
"resource_type": "battery",
"site_name": "Energy Site",
"id": "ABC123",
"gateway_id": "ABC123",
"asset_site_id": "c0ffee",
"warp_site_number": "GA123456",
"energy_left": 23286.105263157893,
"total_pack_energy": 40804,
"percentage_charged": 57.068192488868476,
"battery_type": "ac_powerwall",
"backup_capable": true,
"battery_power": 14990,
"go_off_grid_test_banner_enabled": null,
"storm_mode_enabled": null,
"powerwall_onboarding_settings_set": null,
"storm_mode_enabled": true,
"powerwall_onboarding_settings_set": true,
"powerwall_tesla_electric_interested_in": null,
"vpp_tour_enabled": null,
"sync_grid_alert_enabled": false,
"breaker_alert_enabled": false,
"sync_grid_alert_enabled": true,
"breaker_alert_enabled": true,
"components": {
"battery": false,
"solar": false,
"grid": false,
"load_meter": false,
"battery": true,
"battery_type": "ac_powerwall",
"solar": true,
"solar_type": "pv_panel",
"grid": true,
"load_meter": true,
"market_type": "residential",
"wall_connectors": [
{ "device_id": "abcdef", "din": "12345", "is_active": true }
{
"device_id": "abc-123",
"din": "123-abc",
"is_active": true
},
{
"device_id": "bcd-234",
"din": "234-bcd",
"is_active": true
}
]
},
"features": {}
"features": {
"rate_plan_manager_no_pricing_constraint": true
}
}
],
"count": 2

View File

@ -0,0 +1,87 @@
{
"response": {
"id": "1233-abcd",
"site_name": "Site",
"backup_reserve_percent": 0,
"default_real_mode": "self_consumption",
"installation_date": "2022-01-01T00:00:00+00:00",
"user_settings": {
"go_off_grid_test_banner_enabled": false,
"storm_mode_enabled": true,
"powerwall_onboarding_settings_set": true,
"powerwall_tesla_electric_interested_in": false,
"vpp_tour_enabled": true,
"sync_grid_alert_enabled": true,
"breaker_alert_enabled": false
},
"components": {
"solar": true,
"solar_type": "pv_panel",
"battery": true,
"grid": true,
"backup": true,
"gateway": "teg",
"load_meter": true,
"tou_capable": true,
"storm_mode_capable": true,
"flex_energy_request_capable": false,
"car_charging_data_supported": false,
"off_grid_vehicle_charging_reserve_supported": false,
"vehicle_charging_performance_view_enabled": false,
"vehicle_charging_solar_offset_view_enabled": false,
"battery_solar_offset_view_enabled": true,
"solar_value_enabled": true,
"energy_value_header": "Energy Value",
"energy_value_subheader": "Estimated Value",
"energy_service_self_scheduling_enabled": true,
"show_grid_import_battery_source_cards": true,
"set_islanding_mode_enabled": true,
"wifi_commissioning_enabled": true,
"backup_time_remaining_enabled": true,
"battery_type": "ac_powerwall",
"configurable": true,
"grid_services_enabled": false,
"wall_connectors": [
{
"device_id": "123abc",
"din": "abc123",
"is_active": true
},
{
"device_id": "234bcd",
"din": "bcd234",
"is_active": true
}
],
"disallow_charge_from_grid_with_solar_installed": true,
"customer_preferred_export_rule": "pv_only",
"net_meter_mode": "battery_ok",
"system_alerts_enabled": true
},
"version": "23.44.0 eb113390",
"battery_count": 3,
"tou_settings": {
"optimization_strategy": "economics",
"schedule": [
{
"target": "off_peak",
"week_days": [1, 0],
"start_seconds": 0,
"end_seconds": 3600
},
{
"target": "peak",
"week_days": [1, 0],
"start_seconds": 3600,
"end_seconds": 0
}
]
},
"nameplate_power": 15000,
"nameplate_energy": 40500,
"installation_time_zone": "",
"max_site_meter_power_ac": 1000000000,
"min_site_meter_power_ac": -1000000000,
"vpp_backup_reserve_percent": 0
}
}

View File

@ -112,10 +112,20 @@
"wiper_blade_heater": false
},
"drive_state": {
"active_route_latitude": -27.855946,
"active_route_longitude": 153.345056,
"active_route_latitude": 30.2226265,
"active_route_longitude": -97.6236871,
"active_route_miles_to_arrival": 0.039491,
"active_route_minutes_to_arrival": 0.103577,
"active_route_traffic_minutes_delay": 0,
"power": 0,
"gps_as_of": 1701129612,
"heading": 185,
"latitude": -30.222626,
"longitude": -97.6236871,
"native_latitude": -30.222626,
"native_location_supported": 1,
"native_longitude": -97.6236871,
"native_type": "wgs",
"power": -7,
"shift_state": null,
"speed": null,
"timestamp": 1705707520649

File diff suppressed because it is too large Load Diff

View File

@ -55,10 +55,10 @@ async def test_other_failure(hass: HomeAssistant, mock_products) -> None:
assert entry.state is ConfigEntryState.SETUP_RETRY
# Coordinator
# Vehicle Coordinator
async def test_first_refresh(
async def test_vehicle_first_refresh(
hass: HomeAssistant,
mock_wake_up,
mock_vehicle_data,
@ -88,14 +88,14 @@ async def test_first_refresh(
mock_vehicle_data.assert_called_once()
async def test_first_refresh_error(hass: HomeAssistant, mock_wake_up) -> None:
async def test_vehicle_first_refresh_error(hass: HomeAssistant, mock_wake_up) -> None:
"""Test first coordinator refresh with an error."""
mock_wake_up.side_effect = TeslaFleetError
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_refresh_offline(
async def test_vehicle_refresh_offline(
hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory
) -> None:
"""Test coordinator refresh with an error."""
@ -111,8 +111,18 @@ async def test_refresh_offline(
mock_vehicle_data.assert_called_once()
async def test_refresh_error(hass: HomeAssistant, mock_vehicle_data) -> None:
async def test_vehicle_refresh_error(hass: HomeAssistant, mock_vehicle_data) -> None:
"""Test coordinator refresh with an error."""
mock_vehicle_data.side_effect = TeslaFleetError
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.SETUP_RETRY
# Test Energy Coordinator
async def test_energy_refresh_error(hass: HomeAssistant, mock_live_status) -> None:
"""Test coordinator refresh with an error."""
mock_live_status.side_effect = TeslaFleetError
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -0,0 +1,26 @@
"""Test the Teslemetry sensor platform."""
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import assert_entities, setup_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Tests that the sensor entities are correct."""
freezer.move_to("2024-01-01 00:00:00+00:00")
entry = await setup_platform(hass, [Platform.SENSOR])
assert_entities(hass, entry.entry_id, entity_registry, snapshot)