Refactor Teslemetry integration (#112480)

* Refactor Teslemetry

* Add abstractmethod

* Remove unused timestamp const

* Ruff

* Fix

* Update snapshots

* ruff

* Ruff

* ruff

* Lint

* Fix tests

* Fix tests and diag

* Refix snapshot

* Ruff

* Fix

* Fix bad merge

* has as property

* Remove _handle_coordinator_update

* Test and error changes
This commit is contained in:
Brett Adams 2024-05-10 18:52:33 +10:00 committed by GitHub
parent 11f5b48724
commit 1a4e416bf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 562 additions and 323 deletions

View file

@ -16,25 +16,30 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN from .const import DOMAIN, MODELS
from .coordinator import ( from .coordinator import (
TeslemetryEnergyDataCoordinator, TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator, TeslemetryVehicleDataCoordinator,
) )
from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData
PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR] PLATFORMS: Final = [
Platform.CLIMATE,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Teslemetry config.""" """Set up Teslemetry config."""
access_token = entry.data[CONF_ACCESS_TOKEN] access_token = entry.data[CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass)
# Create API connection # Create API connection
teslemetry = Teslemetry( teslemetry = Teslemetry(
session=async_get_clientsession(hass), session=session,
access_token=access_token, access_token=access_token,
) )
try: try:
@ -52,36 +57,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
energysites: list[TeslemetryEnergyData] = [] energysites: list[TeslemetryEnergyData] = []
for product in products: for product in products:
if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes:
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"] vin = product["vin"]
api = VehicleSpecific(teslemetry.vehicle, vin) api = VehicleSpecific(teslemetry.vehicle, vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, api) coordinator = TeslemetryVehicleDataCoordinator(hass, api, product)
device = DeviceInfo(
identifiers={(DOMAIN, vin)},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name=product["display_name"],
model=MODELS.get(vin[3]),
serial_number=vin,
)
vehicles.append( vehicles.append(
TeslemetryVehicleData( TeslemetryVehicleData(
api=api, api=api,
coordinator=coordinator, coordinator=coordinator,
vin=vin, vin=vin,
device=device,
) )
) )
elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
site_id = product["energy_site_id"] site_id = product["energy_site_id"]
api = EnergySpecific(teslemetry.energy, site_id) api = EnergySpecific(teslemetry.energy, site_id)
live_coordinator = TeslemetryEnergySiteLiveCoordinator(hass, api)
device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name=product.get("site_name", "Energy Site"),
)
energysites.append( energysites.append(
TeslemetryEnergyData( TeslemetryEnergyData(
api=api, api=api,
coordinator=TeslemetryEnergyDataCoordinator(hass, api), live_coordinator=live_coordinator,
id=site_id, id=site_id,
info=product, device=device,
) )
) )
# Do all coordinator first refreshes simultaneously # Run all first refreshes
await asyncio.gather( await asyncio.gather(
*( *(
vehicle.coordinator.async_config_entry_first_refresh() vehicle.coordinator.async_config_entry_first_refresh()
for vehicle in vehicles for vehicle in vehicles
), ),
*( *(
energysite.coordinator.async_config_entry_first_refresh() energysite.live_coordinator.async_config_entry_first_refresh()
for energysite in energysites for energysite in energysites
), ),
) )

View file

@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, cast
from tesla_fleet_api.const import Scope from tesla_fleet_api.const import Scope
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
@ -17,10 +18,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, TeslemetryClimateSide from .const import DOMAIN, TeslemetryClimateSide
from .context import handle_command
from .entity import TeslemetryVehicleEntity from .entity import TeslemetryVehicleEntity
from .models import TeslemetryVehicleData from .models import TeslemetryVehicleData
DEFAULT_MIN_TEMP = 15
DEFAULT_MAX_TEMP = 28
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@ -38,8 +41,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
"""Vehicle Location Climate Class.""" """Vehicle Location Climate Class."""
_attr_precision = PRECISION_HALVES _attr_precision = PRECISION_HALVES
_attr_min_temp = 15
_attr_max_temp = 28
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF]
_attr_supported_features = ( _attr_supported_features = (
@ -67,68 +69,65 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
side, side,
) )
@property def _async_update_attrs(self) -> None:
def hvac_mode(self) -> HVACMode | None: """Update the attributes of the entity."""
"""Return hvac operation ie. heat, cool mode.""" value = self.get("climate_state_is_climate_on")
if self.get("climate_state_is_climate_on"): if value is None:
return HVACMode.HEAT_COOL self._attr_hvac_mode = None
return HVACMode.OFF elif value:
self._attr_hvac_mode = HVACMode.HEAT_COOL
else:
self._attr_hvac_mode = HVACMode.OFF
@property self._attr_current_temperature = self.get("climate_state_inside_temp")
def current_temperature(self) -> float | None: self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting")
"""Return the current temperature.""" self._attr_preset_mode = self.get("climate_state_climate_keeper_mode")
return self.get("climate_state_inside_temp") self._attr_min_temp = cast(
float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP)
@property )
def target_temperature(self) -> float | None: self._attr_max_temp = cast(
"""Return the temperature we try to reach.""" float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP)
return self.get(f"climate_state_{self.key}_setting") )
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self.get("climate_state_max_avail_temp", self._attr_max_temp)
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self.get("climate_state_min_avail_temp", self._attr_min_temp)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self.get("climate_state_climate_keeper_mode")
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Set the climate state to on.""" """Set the climate state to on."""
self.raise_for_scope() self.raise_for_scope()
with handle_command(): await self.wake_up_if_asleep()
await self.wake_up_if_asleep() await self.handle_command(self.api.auto_conditioning_start())
await self.api.auto_conditioning_start()
self.set(("climate_state_is_climate_on", True)) self._attr_hvac_mode = HVACMode.HEAT_COOL
self.async_write_ha_state()
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Set the climate state to off.""" """Set the climate state to off."""
self.raise_for_scope() self.raise_for_scope()
with handle_command(): await self.wake_up_if_asleep()
await self.wake_up_if_asleep() await self.handle_command(self.api.auto_conditioning_stop())
await self.api.auto_conditioning_stop()
self.set( self._attr_hvac_mode = HVACMode.OFF
("climate_state_is_climate_on", False), self._attr_preset_mode = self._attr_preset_modes[0]
("climate_state_climate_keeper_mode", "off"), self.async_write_ha_state()
)
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the climate temperature.""" """Set the climate temperature."""
temp = kwargs[ATTR_TEMPERATURE]
with handle_command():
await self.wake_up_if_asleep()
await self.api.set_temps(
driver_temp=temp,
passenger_temp=temp,
)
self.set((f"climate_state_{self.key}_setting", temp)) if temp := kwargs.get(ATTR_TEMPERATURE):
await self.wake_up_if_asleep()
await self.handle_command(
self.api.set_temps(
driver_temp=temp,
passenger_temp=temp,
)
)
self._attr_target_temperature = temp
if mode := kwargs.get(ATTR_HVAC_MODE):
# Set HVAC mode will call write_ha_state
await self.async_set_hvac_mode(mode)
else:
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the climate mode and state.""" """Set the climate mode and state."""
@ -139,18 +138,15 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the climate preset mode.""" """Set the climate preset mode."""
with handle_command(): await self.wake_up_if_asleep()
await self.wake_up_if_asleep() await self.handle_command(
await self.api.set_climate_keeper_mode( self.api.set_climate_keeper_mode(
climate_keeper_mode=self._attr_preset_modes.index(preset_mode) climate_keeper_mode=self._attr_preset_modes.index(preset_mode)
) )
self.set(
(
"climate_state_climate_keeper_mode",
preset_mode,
),
(
"climate_state_is_climate_on",
preset_mode != self._attr_preset_modes[0],
),
) )
self._attr_preset_mode = preset_mode
if preset_mode == self._attr_preset_modes[0]:
self._attr_hvac_mode = HVACMode.OFF
else:
self._attr_hvac_mode = HVACMode.HEAT_COOL
self.async_write_ha_state()

View file

@ -10,10 +10,10 @@ DOMAIN = "teslemetry"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
MODELS = { MODELS = {
"model3": "Model 3", "S": "Model S",
"modelx": "Model X", "3": "Model 3",
"modely": "Model Y", "X": "Model X",
"models": "Model S", "Y": "Model Y",
} }

View file

@ -1,16 +0,0 @@
"""Teslemetry context managers."""
from contextlib import contextmanager
from tesla_fleet_api.exceptions import TeslaFleetError
from homeassistant.exceptions import HomeAssistantError
@contextmanager
def handle_command():
"""Handle wake up and errors."""
try:
yield
except TeslaFleetError as e:
raise HomeAssistantError("Teslemetry command failed") from e

View file

@ -13,12 +13,15 @@ from tesla_fleet_api.exceptions import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER, TeslemetryState from .const import LOGGER, TeslemetryState
SYNC_INTERVAL = 60 VEHICLE_INTERVAL = timedelta(seconds=30)
ENERGY_LIVE_INTERVAL = timedelta(seconds=30)
ENERGY_INFO_INTERVAL = timedelta(seconds=30)
ENDPOINTS = [ ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE, VehicleDataEndpoint.CHARGE_STATE,
VehicleDataEndpoint.CLIMATE_STATE, VehicleDataEndpoint.CLIMATE_STATE,
@ -29,50 +32,41 @@ ENDPOINTS = [
] ]
class TeslemetryDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]:
"""Base class for Teslemetry Data Coordinators.""" """Flatten the data structure."""
result = {}
for key, value in data.items():
if parent:
key = f"{parent}_{key}"
if isinstance(value, dict):
result.update(flatten(value, key))
else:
result[key] = value
return result
name: str
class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API."""
name = "Teslemetry Vehicle"
def __init__( def __init__(
self, hass: HomeAssistant, api: VehicleSpecific | EnergySpecific self, hass: HomeAssistant, api: VehicleSpecific, product: dict
) -> None: ) -> None:
"""Initialize Teslemetry Vehicle Update Coordinator.""" """Initialize Teslemetry Vehicle Update Coordinator."""
super().__init__( super().__init__(
hass, hass,
LOGGER, LOGGER,
name=self.name, name="Teslemetry Vehicle",
update_interval=timedelta(seconds=SYNC_INTERVAL), update_interval=VEHICLE_INTERVAL,
) )
self.api = api self.api = api
self.data = flatten(product)
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:
response = await self.api.wake_up()
if response["response"]["state"] != TeslemetryState.ONLINE:
# The first refresh will fail, so retry later
raise ConfigEntryNotReady("Vehicle is not online")
except InvalidToken as e:
raise ConfigEntryAuthFailed from e
except SubscriptionRequired as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
# The first refresh will also fail, so retry later
raise ConfigEntryNotReady from e
await super().async_config_entry_first_refresh()
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Teslemetry API.""" """Update vehicle data using Teslemetry API."""
try: try:
data = await self.api.vehicle_data(endpoints=ENDPOINTS) data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"]
except VehicleOffline: except VehicleOffline:
self.data["state"] = TeslemetryState.OFFLINE self.data["state"] = TeslemetryState.OFFLINE
return self.data return self.data
@ -83,33 +77,27 @@ class TeslemetryVehicleDataCoordinator(TeslemetryDataCoordinator):
except TeslaFleetError as e: except TeslaFleetError as e:
raise UpdateFailed(e.message) from e raise UpdateFailed(e.message) from e
return self._flatten(data["response"]) return flatten(data)
def _flatten(
self, data: dict[str, Any], parent: str | None = None
) -> dict[str, Any]:
"""Flatten the data structure."""
result = {}
for key, value in data.items():
if parent:
key = f"{parent}_{key}"
if isinstance(value, dict):
result.update(self._flatten(value, key))
else:
result[key] = value
return result
class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator): class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching data from the Teslemetry API.""" """Class to manage fetching energy site live status from the Teslemetry API."""
name = "Teslemetry Energy Site" def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None:
"""Initialize Teslemetry Energy Site Live coordinator."""
super().__init__(
hass,
LOGGER,
name="Teslemetry Energy Site Live",
update_interval=ENERGY_LIVE_INTERVAL,
)
self.api = api
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API.""" """Update energy site data using Teslemetry API."""
try: try:
data = await self.api.live_status() data = (await self.api.live_status())["response"]
except InvalidToken as e: except InvalidToken as e:
raise ConfigEntryAuthFailed from e raise ConfigEntryAuthFailed from e
except SubscriptionRequired as e: except SubscriptionRequired as e:
@ -118,8 +106,8 @@ class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator):
raise UpdateFailed(e.message) from e raise UpdateFailed(e.message) from e
# Convert Wall Connectors from array to dict # Convert Wall Connectors from array to dict
data["response"]["wall_connectors"] = { data["wall_connectors"] = {
wc["din"]: wc for wc in (data["response"].get("wall_connectors") or []) wc["din"]: wc for wc in (data.get("wall_connectors") or [])
} }
return data["response"] return data

View file

@ -36,7 +36,8 @@ async def async_get_config_entry_diagnostics(
x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].vehicles x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].vehicles
] ]
energysites = [ energysites = [
x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].energysites x.live_coordinator.data
for x in hass.data[DOMAIN][config_entry.entry_id].energysites
] ]
# Return only the relevant children # Return only the relevant children

View file

@ -1,52 +1,108 @@
"""Teslemetry parent entity class.""" """Teslemetry parent entity class."""
from abc import abstractmethod
import asyncio import asyncio
from typing import Any from typing import Any
from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.exceptions import TeslaFleetError from tesla_fleet_api.exceptions import TeslaFleetError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MODELS, TeslemetryState from .const import DOMAIN, LOGGER, TeslemetryState
from .coordinator import ( from .coordinator import (
TeslemetryEnergyDataCoordinator, TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator, TeslemetryVehicleDataCoordinator,
) )
from .models import TeslemetryEnergyData, TeslemetryVehicleData from .models import TeslemetryEnergyData, TeslemetryVehicleData
class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]): class TeslemetryEntity(
"""Parent class for Teslemetry Vehicle Entities.""" CoordinatorEntity[
TeslemetryVehicleDataCoordinator | TeslemetryEnergySiteLiveCoordinator
]
):
"""Parent class for all Teslemetry entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
vehicle: TeslemetryVehicleData, coordinator: TeslemetryVehicleDataCoordinator
| TeslemetryEnergySiteLiveCoordinator,
api: VehicleSpecific | EnergySpecific,
key: str, key: str,
) -> None: ) -> None:
"""Initialize common aspects of a Teslemetry entity.""" """Initialize common aspects of a Teslemetry entity."""
super().__init__(vehicle.coordinator) super().__init__(coordinator)
self.api = api
self.key = key self.key = key
self.api = vehicle.api self._attr_translation_key = self.key
self._wakelock = vehicle.wakelock self._async_update_attrs()
car_type = self.coordinator.data["vehicle_config_car_type"] @property
def available(self) -> bool:
"""Return if sensor is available."""
return self.coordinator.last_update_success and self._attr_available
self._attr_translation_key = key @property
self._attr_unique_id = f"{vehicle.vin}-{key}" def _value(self) -> Any | None:
self._attr_device_info = DeviceInfo( """Return a specific value from coordinator data."""
identifiers={(DOMAIN, vehicle.vin)}, return self.coordinator.data.get(self.key)
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console", def get(self, key: str, default: Any | None = None) -> Any | None:
name=self.coordinator.data["vehicle_state_vehicle_name"], """Return a specific value from coordinator data."""
model=MODELS.get(car_type, car_type), return self.coordinator.data.get(key, default)
sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0],
hw_version=self.coordinator.data["vehicle_config_driver_assist"], @property
serial_number=vehicle.vin, def is_none(self) -> bool:
) """Return if the value is a literal None."""
return self.get(self.key, False) is None
@property
def has(self) -> bool:
"""Return True if a specific value is in coordinator data."""
return self.key in self.coordinator.data
async def handle_command(self, command) -> dict[str, Any]:
"""Handle a command."""
try:
result = await command
LOGGER.debug("Command result: %s", result)
except TeslaFleetError as e:
LOGGER.debug("Command error: %s", e.message)
raise HomeAssistantError(f"Teslemetry command failed, {e.message}") from e
return result
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_attrs()
self.async_write_ha_state()
@abstractmethod
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
class TeslemetryVehicleEntity(TeslemetryEntity):
"""Parent class for Teslemetry Vehicle entities."""
_last_update: int = 0
def __init__(
self,
data: TeslemetryVehicleData,
key: str,
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
self._attr_unique_id = f"{data.vin}-{key}"
self._wakelock = data.wakelock
self._attr_device_info = data.device
super().__init__(data.coordinator, data.api, key)
@property @property
def _value(self) -> Any | None: def _value(self) -> Any | None:
@ -73,15 +129,27 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator
raise HomeAssistantError("Could not wake up vehicle") raise HomeAssistantError("Could not wake up vehicle")
await asyncio.sleep(times * 5) await asyncio.sleep(times * 5)
def get(self, key: str | None = None, default: Any | None = None) -> Any: async def handle_command(self, command) -> dict[str, Any]:
"""Return a specific value from coordinator data.""" """Handle a vehicle command."""
return self.coordinator.data.get(key or self.key, default) result = await super().handle_command(command)
if (response := result.get("response")) is None:
def set(self, *args: Any) -> None: if message := result.get("error"):
"""Set a value in coordinator data.""" # No response with error
for key, value in args: LOGGER.info("Command failure: %s", message)
self.coordinator.data[key] = value raise HomeAssistantError(message)
self.async_write_ha_state() # No response without error (unexpected)
LOGGER.error("Unknown response: %s", response)
raise HomeAssistantError("Unknown response")
if (message := response.get("result")) is not True:
if message := response.get("reason"):
# Result of false with reason
LOGGER.info("Command failure: %s", message)
raise HomeAssistantError(message)
# Result of false without reason (unexpected)
LOGGER.error("Unknown response: %s", response)
raise HomeAssistantError("Unknown response")
# Response with result of true
return result
def raise_for_scope(self): def raise_for_scope(self):
"""Raise an error if a scope is not available.""" """Raise an error if a scope is not available."""
@ -89,63 +157,53 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator
raise ServiceValidationError("Missing required scope") raise ServiceValidationError("Missing required scope")
class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]): class TeslemetryEnergyLiveEntity(TeslemetryEntity):
"""Parent class for Teslemetry Energy Entities.""" """Parent class for Teslemetry Energy Site Live entities."""
_attr_has_entity_name = True
def __init__( def __init__(
self, self,
energysite: TeslemetryEnergyData, data: TeslemetryEnergyData,
key: str, key: str,
) -> None: ) -> None:
"""Initialize common aspects of a Teslemetry entity.""" """Initialize common aspects of a Teslemetry Energy Site Live entity."""
super().__init__(energysite.coordinator) self._attr_unique_id = f"{data.id}-{key}"
self.key = key self._attr_device_info = data.device
self.api = energysite.api
self._attr_translation_key = key super().__init__(data.live_coordinator, data.api, 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]): class TeslemetryWallConnectorEntity(
TeslemetryEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator]
):
"""Parent class for Teslemetry Wall Connector Entities.""" """Parent class for Teslemetry Wall Connector Entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self,
energysite: TeslemetryEnergyData, data: TeslemetryEnergyData,
din: str, din: str,
key: str, key: str,
) -> None: ) -> None:
"""Initialize common aspects of a Teslemetry entity.""" """Initialize common aspects of a Teslemetry entity."""
super().__init__(energysite.coordinator)
self.din = din self.din = din
self.key = key self._attr_unique_id = f"{data.id}-{din}-{key}"
self._attr_translation_key = key
self._attr_unique_id = f"{energysite.id}-{din}-{key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, din)}, identifiers={(DOMAIN, din)},
manufacturer="Tesla", manufacturer="Tesla",
configuration_url="https://teslemetry.com/console", configuration_url="https://teslemetry.com/console",
name="Wall Connector", name="Wall Connector",
via_device=(DOMAIN, str(energysite.id)), via_device=(DOMAIN, str(data.id)),
serial_number=din.split("-")[-1], serial_number=din.split("-")[-1],
) )
super().__init__(data.live_coordinator, data.api, key)
@property @property
def _value(self) -> int: def _value(self) -> int:
"""Return a specific wall connector value from coordinator data.""" """Return a specific wall connector value from coordinator data."""
return self.coordinator.data["wall_connectors"][self.din].get(self.key) return (
self.coordinator.data.get("wall_connectors", {})
.get(self.din, {})
.get(self.key)
)

View file

@ -8,8 +8,10 @@ from dataclasses import dataclass
from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope from tesla_fleet_api.const import Scope
from homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import ( from .coordinator import (
TeslemetryEnergyDataCoordinator, TeslemetryEnergySiteLiveCoordinator,
TeslemetryVehicleDataCoordinator, TeslemetryVehicleDataCoordinator,
) )
@ -31,6 +33,7 @@ class TeslemetryVehicleData:
coordinator: TeslemetryVehicleDataCoordinator coordinator: TeslemetryVehicleDataCoordinator
vin: str vin: str
wakelock = asyncio.Lock() wakelock = asyncio.Lock()
device: DeviceInfo
@dataclass @dataclass
@ -38,6 +41,6 @@ class TeslemetryEnergyData:
"""Data for a vehicle in the Teslemetry integration.""" """Data for a vehicle in the Teslemetry integration."""
api: EnergySpecific api: EnergySpecific
coordinator: TeslemetryEnergyDataCoordinator live_coordinator: TeslemetryEnergySiteLiveCoordinator
id: int id: int
info: dict[str, str] device: DeviceInfo

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import timedelta
from itertools import chain from itertools import chain
from typing import cast from typing import cast
@ -36,7 +36,7 @@ from homeassistant.util.variance import ignore_variance
from .const import DOMAIN from .const import DOMAIN
from .entity import ( from .entity import (
TeslemetryEnergyEntity, TeslemetryEnergyLiveEntity,
TeslemetryVehicleEntity, TeslemetryVehicleEntity,
TeslemetryWallConnectorEntity, TeslemetryWallConnectorEntity,
) )
@ -298,7 +298,7 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = (
), ),
) )
ENERGY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key="solar_power", key="solar_power",
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -421,15 +421,15 @@ async def async_setup_entry(
for description in VEHICLE_TIME_DESCRIPTIONS for description in VEHICLE_TIME_DESCRIPTIONS
), ),
( # Add energy site live ( # Add energy site live
TeslemetryEnergySensorEntity(energysite, description) TeslemetryEnergyLiveSensorEntity(energysite, description)
for energysite in data.energysites for energysite in data.energysites
for description in ENERGY_DESCRIPTIONS for description in ENERGY_LIVE_DESCRIPTIONS
if description.key in energysite.coordinator.data if description.key in energysite.live_coordinator.data
), ),
( # Add wall connectors ( # Add wall connectors
TeslemetryWallConnectorSensorEntity(energysite, din, description) TeslemetryWallConnectorSensorEntity(energysite, din, description)
for energysite in data.energysites for energysite in data.energysites
for din in energysite.coordinator.data.get("wall_connectors", {}) for din in energysite.live_coordinator.data.get("wall_connectors", {})
for description in WALL_CONNECTOR_DESCRIPTIONS for description in WALL_CONNECTOR_DESCRIPTIONS
), ),
) )
@ -443,21 +443,23 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
def __init__( def __init__(
self, self,
vehicle: TeslemetryVehicleData, data: TeslemetryVehicleData,
description: TeslemetrySensorEntityDescription, description: TeslemetrySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self.entity_description = description self.entity_description = description
super().__init__(vehicle, description.key) super().__init__(data, description.key)
@property def _async_update_attrs(self) -> None:
def native_value(self) -> StateType: """Update the attributes of the sensor."""
"""Return the state of the sensor.""" if self.has:
return self.entity_description.value_fn(self._value) self._attr_native_value = self.entity_description.value_fn(self._value)
else:
self._attr_native_value = None
class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
"""Base class for Teslemetry vehicle metric sensors.""" """Base class for Teslemetry vehicle time sensors."""
entity_description: TeslemetryTimeEntityDescription entity_description: TeslemetryTimeEntityDescription
@ -475,35 +477,31 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
super().__init__(data, description.key) super().__init__(data, description.key)
@property def _async_update_attrs(self) -> None:
def native_value(self) -> datetime | None: """Update the attributes of the sensor."""
"""Return the state of the sensor.""" self._attr_available = isinstance(self._value, int | float) and self._value > 0
return self._get_timestamp(self._value) if self._attr_available:
self._attr_native_value = self._get_timestamp(self._value)
@property
def available(self) -> bool:
"""Return the availability of the sensor."""
return isinstance(self._value, int | float) and self._value > 0
class TeslemetryEnergySensorEntity(TeslemetryEnergyEntity, SensorEntity): class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity):
"""Base class for Teslemetry energy site metric sensors.""" """Base class for Teslemetry energy site metric sensors."""
entity_description: SensorEntityDescription entity_description: SensorEntityDescription
def __init__( def __init__(
self, self,
energysite: TeslemetryEnergyData, data: TeslemetryEnergyData,
description: SensorEntityDescription, description: SensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(energysite, description.key)
self.entity_description = description self.entity_description = description
super().__init__(data, description.key)
@property def _async_update_attrs(self) -> None:
def native_value(self) -> StateType: """Update the attributes of the sensor."""
"""Return the state of the sensor.""" self._attr_available = not self.is_none
return self.get() self._attr_native_value = self._value
class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity):
@ -513,19 +511,19 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE
def __init__( def __init__(
self, self,
energysite: TeslemetryEnergyData, data: TeslemetryEnergyData,
din: str, din: str,
description: SensorEntityDescription, description: SensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self.entity_description = description
super().__init__( super().__init__(
energysite, data,
din, din,
description.key, description.key,
) )
self.entity_description = description
@property def _async_update_attrs(self) -> None:
def native_value(self) -> StateType: """Update the attributes of the sensor."""
"""Return the state of the sensor.""" self._attr_available = not self.is_none
return self._value self._attr_native_value = self._value

View file

@ -8,10 +8,11 @@ from unittest.mock import patch
import pytest import pytest
from .const import ( from .const import (
COMMAND_OK,
LIVE_STATUS, LIVE_STATUS,
METADATA, METADATA,
PRODUCTS, PRODUCTS,
RESPONSE_OK, SITE_INFO,
VEHICLE_DATA, VEHICLE_DATA,
WAKE_UP_ONLINE, WAKE_UP_ONLINE,
) )
@ -70,7 +71,7 @@ def mock_request():
"""Mock Tesla Fleet API Vehicle Specific class.""" """Mock Tesla Fleet API Vehicle Specific class."""
with patch( with patch(
"homeassistant.components.teslemetry.Teslemetry._request", "homeassistant.components.teslemetry.Teslemetry._request",
return_value=RESPONSE_OK, return_value=COMMAND_OK,
) as mock_request: ) as mock_request:
yield mock_request yield mock_request
@ -83,3 +84,13 @@ def mock_live_status():
side_effect=lambda: deepcopy(LIVE_STATUS), side_effect=lambda: deepcopy(LIVE_STATUS),
) as mock_live_status: ) as mock_live_status:
yield mock_live_status yield mock_live_status
@pytest.fixture(autouse=True)
def mock_site_info():
"""Mock Teslemetry Energy Specific site_info method."""
with patch(
"homeassistant.components.teslemetry.EnergySpecific.site_info",
side_effect=lambda: deepcopy(SITE_INFO),
) as mock_live_status:
yield mock_live_status

View file

@ -14,6 +14,18 @@ PRODUCTS = load_json_object_fixture("products.json", DOMAIN)
VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN)
VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN)
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN)
COMMAND_OK = {"response": {"result": True, "reason": ""}}
COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}}
COMMAND_NOREASON = {"response": {"result": False}} # Unexpected
COMMAND_ERROR = {
"response": None,
"error": "vehicle unavailable: vehicle is offline or asleep",
"error_description": "",
}
COMMAND_NOERROR = {"answer": 42}
COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERROR)
RESPONSE_OK = {"response": {}, "error": None} RESPONSE_OK = {"response": {}, "error": None}

View file

@ -73,14 +73,14 @@
}, },
"climate_state": { "climate_state": {
"allow_cabin_overheat_protection": true, "allow_cabin_overheat_protection": true,
"auto_seat_climate_left": false, "auto_seat_climate_left": true,
"auto_seat_climate_right": true, "auto_seat_climate_right": true,
"auto_steering_wheel_heat": false, "auto_steering_wheel_heat": false,
"battery_heater": false, "battery_heater": false,
"battery_heater_no_power": null, "battery_heater_no_power": null,
"cabin_overheat_protection": "On", "cabin_overheat_protection": "On",
"cabin_overheat_protection_actively_cooling": false, "cabin_overheat_protection_actively_cooling": false,
"climate_keeper_mode": "off", "climate_keeper_mode": "keep",
"cop_activation_temperature": "High", "cop_activation_temperature": "High",
"defrost_mode": 0, "defrost_mode": 0,
"driver_temp_setting": 22, "driver_temp_setting": 22,
@ -88,7 +88,7 @@
"hvac_auto_request": "On", "hvac_auto_request": "On",
"inside_temp": 29.8, "inside_temp": 29.8,
"is_auto_conditioning_on": false, "is_auto_conditioning_on": false,
"is_climate_on": false, "is_climate_on": true,
"is_front_defroster_on": false, "is_front_defroster_on": false,
"is_preconditioning": false, "is_preconditioning": false,
"is_rear_defroster_on": false, "is_rear_defroster_on": false,

View file

@ -46,6 +46,81 @@
}) })
# --- # ---
# name: test_climate[climate.test_climate-state] # name: test_climate[climate.test_climate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 30.0,
'friendly_name': 'Test Climate',
'hvac_modes': list([
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.0,
'min_temp': 15.0,
'preset_mode': 'keep',
'preset_modes': list([
'off',
'keep',
'dog',
'camp',
]),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 22.0,
}),
'context': <ANY>,
'entity_id': 'climate.test_climate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_cool',
})
# ---
# name: test_climate_alt[climate.test_climate-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.0,
'min_temp': 15.0,
'preset_modes': list([
'off',
'keep',
'dog',
'camp',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.test_climate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Climate',
'platform': 'teslemetry',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': <TeslemetryClimateSide.DRIVER: 'driver_temp'>,
'unique_id': 'VINVINVIN-driver_temp',
'unit_of_measurement': None,
})
# ---
# name: test_climate_alt[climate.test_climate-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'current_temperature': 30.0, 'current_temperature': 30.0,
@ -74,3 +149,78 @@
'state': 'off', 'state': 'off',
}) })
# --- # ---
# name: test_climate_offline[climate.test_climate-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.0,
'min_temp': 15.0,
'preset_modes': list([
'off',
'keep',
'dog',
'camp',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.test_climate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Climate',
'platform': 'teslemetry',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': <TeslemetryClimateSide.DRIVER: 'driver_temp'>,
'unique_id': 'VINVINVIN-driver_temp',
'unit_of_measurement': None,
})
# ---
# name: test_climate_offline[climate.test_climate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': None,
'friendly_name': 'Test Climate',
'hvac_modes': list([
<HVACMode.HEAT_COOL: 'heat_cool'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 28.0,
'min_temp': 15.0,
'preset_mode': None,
'preset_modes': list([
'off',
'keep',
'dog',
'camp',
]),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': None,
}),
'context': <ANY>,
'entity_id': 'climate.test_climate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View file

@ -94,14 +94,14 @@
'charge_state_usable_battery_level': 77, 'charge_state_usable_battery_level': 77,
'charge_state_user_charge_enable_request': None, 'charge_state_user_charge_enable_request': None,
'climate_state_allow_cabin_overheat_protection': True, 'climate_state_allow_cabin_overheat_protection': True,
'climate_state_auto_seat_climate_left': False, 'climate_state_auto_seat_climate_left': True,
'climate_state_auto_seat_climate_right': True, 'climate_state_auto_seat_climate_right': True,
'climate_state_auto_steering_wheel_heat': False, 'climate_state_auto_steering_wheel_heat': False,
'climate_state_battery_heater': False, 'climate_state_battery_heater': False,
'climate_state_battery_heater_no_power': None, 'climate_state_battery_heater_no_power': None,
'climate_state_cabin_overheat_protection': 'On', 'climate_state_cabin_overheat_protection': 'On',
'climate_state_cabin_overheat_protection_actively_cooling': False, 'climate_state_cabin_overheat_protection_actively_cooling': False,
'climate_state_climate_keeper_mode': 'off', 'climate_state_climate_keeper_mode': 'keep',
'climate_state_cop_activation_temperature': 'High', 'climate_state_cop_activation_temperature': 'High',
'climate_state_defrost_mode': 0, 'climate_state_defrost_mode': 0,
'climate_state_driver_temp_setting': 22, 'climate_state_driver_temp_setting': 22,
@ -109,7 +109,7 @@
'climate_state_hvac_auto_request': 'On', 'climate_state_hvac_auto_request': 'On',
'climate_state_inside_temp': 29.8, 'climate_state_inside_temp': 29.8,
'climate_state_is_auto_conditioning_on': False, 'climate_state_is_auto_conditioning_on': False,
'climate_state_is_climate_on': False, 'climate_state_is_climate_on': True,
'climate_state_is_front_defroster_on': False, 'climate_state_is_front_defroster_on': False,
'climate_state_is_preconditioning': False, 'climate_state_is_preconditioning': False,
'climate_state_is_rear_defroster_on': False, 'climate_state_is_rear_defroster_on': False,

View file

@ -1,6 +1,5 @@
"""Test the Teslemetry climate platform.""" """Test the Teslemetry climate platform."""
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@ -19,14 +18,20 @@ from homeassistant.components.climate import (
SERVICE_TURN_ON, SERVICE_TURN_ON,
HVACMode, HVACMode,
) )
from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import assert_entities, setup_platform from . import assert_entities, setup_platform
from .const import METADATA_NOSCOPE, WAKE_UP_ASLEEP, WAKE_UP_ONLINE from .const import (
COMMAND_ERRORS,
METADATA_NOSCOPE,
VEHICLE_DATA_ALT,
WAKE_UP_ASLEEP,
WAKE_UP_ONLINE,
)
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -43,27 +48,34 @@ async def test_climate(
assert_entities(hass, entry.entry_id, entity_registry, snapshot) assert_entities(hass, entry.entry_id, entity_registry, snapshot)
entity_id = "climate.test_climate" entity_id = "climate.test_climate"
state = hass.states.get(entity_id)
# Turn On # Turn On and Set Temp
await hass.services.async_call( await hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, {
ATTR_ENTITY_ID: [entity_id],
ATTR_TEMPERATURE: 20,
ATTR_HVAC_MODE: HVACMode.HEAT_COOL,
},
blocking=True, blocking=True,
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_TEMPERATURE] == 20
assert state.state == HVACMode.HEAT_COOL assert state.state == HVACMode.HEAT_COOL
# Set Temp # Set Temp
await hass.services.async_call( await hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, {
ATTR_ENTITY_ID: [entity_id],
ATTR_TEMPERATURE: 21,
},
blocking=True, blocking=True,
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_TEMPERATURE] == 20 assert state.attributes[ATTR_TEMPERATURE] == 21
# Set Preset # Set Preset
await hass.services.async_call( await hass.services.async_call(
@ -75,6 +87,16 @@ async def test_climate(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "keep" assert state.attributes[ATTR_PRESET_MODE] == "keep"
# Set Preset
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "off"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "off"
# Turn Off # Turn Off
await hass.services.async_call( await hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,
@ -86,9 +108,34 @@ async def test_climate(
assert state.state == HVACMode.OFF assert state.state == HVACMode.OFF
async def test_errors( async def test_climate_alt(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_vehicle_data,
) -> None: ) -> None:
"""Tests that the climate entity is correct."""
mock_vehicle_data.return_value = VEHICLE_DATA_ALT
entry = await setup_platform(hass, [Platform.CLIMATE])
assert_entities(hass, entry.entry_id, entity_registry, snapshot)
async def test_climate_offline(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_vehicle_data,
) -> None:
"""Tests that the climate entity is correct."""
mock_vehicle_data.side_effect = VehicleOffline
entry = await setup_platform(hass, [Platform.CLIMATE])
assert_entities(hass, entry.entry_id, entity_registry, snapshot)
@pytest.mark.parametrize("response", COMMAND_ERRORS)
async def test_errors(hass: HomeAssistant, response: str) -> None:
"""Tests service error is handled.""" """Tests service error is handled."""
await setup_platform(hass, platforms=[Platform.CLIMATE]) await setup_platform(hass, platforms=[Platform.CLIMATE])
@ -110,6 +157,21 @@ async def test_errors(
mock_on.assert_called_once() mock_on.assert_called_once()
assert error.from_exception == InvalidCommand assert error.from_exception == InvalidCommand
with (
patch(
"homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start",
return_value=response,
) as mock_on,
pytest.raises(HomeAssistantError) as error,
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [entity_id]},
blocking=True,
)
mock_on.assert_called_once()
async def test_asleep_or_offline( async def test_asleep_or_offline(
hass: HomeAssistant, hass: HomeAssistant,
@ -127,7 +189,7 @@ async def test_asleep_or_offline(
# Put the vehicle alseep # Put the vehicle alseep
mock_vehicle_data.reset_mock() mock_vehicle_data.reset_mock()
mock_vehicle_data.side_effect = VehicleOffline mock_vehicle_data.side_effect = VehicleOffline
freezer.tick(timedelta(seconds=SYNC_INTERVAL)) freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_vehicle_data.assert_called_once() mock_vehicle_data.assert_called_once()

View file

@ -1,7 +1,5 @@
"""Test the Tessie init.""" """Test the Tessie init."""
from datetime import timedelta
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from tesla_fleet_api.exceptions import ( from tesla_fleet_api.exceptions import (
@ -11,13 +9,12 @@ from tesla_fleet_api.exceptions import (
VehicleOffline, VehicleOffline,
) )
from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import setup_platform from . import setup_platform
from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -50,48 +47,6 @@ async def test_init_error(
# Vehicle Coordinator # Vehicle Coordinator
async def test_vehicle_first_refresh(
hass: HomeAssistant,
mock_wake_up,
mock_vehicle_data,
mock_products,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test first coordinator refresh but vehicle is asleep."""
# Mock vehicle is asleep
mock_wake_up.return_value = WAKE_UP_ASLEEP
entry = await setup_platform(hass)
assert entry.state is ConfigEntryState.SETUP_RETRY
mock_wake_up.assert_called_once()
# Reset mock and set vehicle to online
mock_wake_up.reset_mock()
mock_wake_up.return_value = WAKE_UP_ONLINE
# Wait for the retry
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify we have loaded
assert entry.state is ConfigEntryState.LOADED
mock_wake_up.assert_called_once()
mock_vehicle_data.assert_called_once()
@pytest.mark.parametrize(("side_effect", "state"), ERRORS)
async def test_vehicle_first_refresh_error(
hass: HomeAssistant, mock_wake_up, side_effect, state
) -> None:
"""Test first coordinator refresh with an error."""
mock_wake_up.side_effect = side_effect
entry = await setup_platform(hass)
assert entry.state is state
async def test_vehicle_refresh_offline( async def test_vehicle_refresh_offline(
hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory
) -> None: ) -> None:
@ -102,7 +57,7 @@ async def test_vehicle_refresh_offline(
mock_vehicle_data.reset_mock() mock_vehicle_data.reset_mock()
mock_vehicle_data.side_effect = VehicleOffline mock_vehicle_data.side_effect = VehicleOffline
freezer.tick(timedelta(seconds=SYNC_INTERVAL)) freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_vehicle_data.assert_called_once() mock_vehicle_data.assert_called_once()
@ -118,11 +73,9 @@ async def test_vehicle_refresh_error(
assert entry.state is state assert entry.state is state
# Test Energy Coordinator # Test Energy Live Coordinator
@pytest.mark.parametrize(("side_effect", "state"), ERRORS) @pytest.mark.parametrize(("side_effect", "state"), ERRORS)
async def test_energy_refresh_error( async def test_energy_live_refresh_error(
hass: HomeAssistant, mock_live_status, side_effect, state hass: HomeAssistant, mock_live_status, side_effect, state
) -> None: ) -> None:
"""Test coordinator refresh with an error.""" """Test coordinator refresh with an error."""

View file

@ -1,12 +1,10 @@
"""Test the Teslemetry sensor platform.""" """Test the Teslemetry sensor platform."""
from datetime import timedelta
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -35,7 +33,7 @@ async def test_sensors(
# Coordinator refresh # Coordinator refresh
mock_vehicle_data.return_value = VEHICLE_DATA_ALT mock_vehicle_data.return_value = VEHICLE_DATA_ALT
freezer.tick(timedelta(seconds=SYNC_INTERVAL)) freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()