diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 37fb669e54b..e8891d6665f 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,9 +1,12 @@ """Tessie integration.""" +import asyncio from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError +from tesla_fleet_api import EnergySpecific, Tessie +from tesla_fleet_api.exceptions import TeslaFleetError from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry @@ -14,8 +17,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, MODELS -from .coordinator import TessieStateUpdateCoordinator -from .models import TessieData, TessieVehicleData +from .coordinator import ( + TessieEnergySiteInfoCoordinator, + TessieEnergySiteLiveCoordinator, + TessieStateUpdateCoordinator, +) +from .models import TessieData, TessieEnergyData, TessieVehicleData PLATFORMS = [ Platform.BINARY_SENSOR, @@ -40,10 +47,11 @@ type TessieConfigEntry = ConfigEntry[TessieData] async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bool: """Set up Tessie config.""" api_key = entry.data[CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) try: state_of_all_vehicles = await get_state_of_all_vehicles( - session=async_get_clientsession(hass), + session=session, api_key=api_key, only_active=True, ) @@ -84,7 +92,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo if vehicle["last_state"] is not None ] - entry.runtime_data = TessieData(vehicles=vehicles) + # Energy Sites + tessie = Tessie(session, api_key) + try: + products = (await tessie.products())["response"] + except TeslaFleetError as e: + raise ConfigEntryNotReady from e + + energysites: list[TessieEnergyData] = [] + for product in products: + if "energy_site_id" in product: + site_id = product["energy_site_id"] + api = EnergySpecific(tessie.energy, site_id) + energysites.append( + TessieEnergyData( + api=api, + id=site_id, + live_coordinator=TessieEnergySiteLiveCoordinator(hass, api), + info_coordinator=TessieEnergySiteInfoCoordinator(hass, api), + device=DeviceInfo( + identifiers={(DOMAIN, str(site_id))}, + manufacturer="Tesla", + name=product.get("site_name", "Energy Site"), + ), + ) + ) + + # Populate coordinator data before forwarding to platforms + await asyncio.gather( + *( + energysite.live_coordinator.async_config_entry_first_refresh() + for energysite in energysites + ), + *( + energysite.info_coordinator.async_config_entry_first_refresh() + for energysite in energysites + ), + ) + + entry.runtime_data = TessieData(vehicles, energysites) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index f717d758f5a..bdb20193613 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -79,3 +79,18 @@ TessieChargeStates = { "Disconnected": "disconnected", "NoPower": "no_power", } + + +class TessieWallConnectorStates(IntEnum): + """Tessie Wall Connector states.""" + + BOOTING = 0 + CHARGING = 1 + DISCONNECTED = 2 + CONNECTED = 4 + SCHEDULED = 5 + NEGOTIATING = 6 + ERROR = 7 + CHARGING_FINISHED = 8 + WAITING_CAR = 9 + CHARGING_REDUCED = 10 diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index bea1bf72a8d..4582260bfb2 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -6,21 +6,37 @@ import logging from typing import Any from aiohttp import ClientResponseError +from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError from tessie_api import get_state, get_status from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import TessieStatus # This matches the update interval Tessie performs server side TESSIE_SYNC_INTERVAL = 10 +TESSIE_FLEET_API_SYNC_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +def flatten(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(flatten(value, key)) + else: + result[key] = value + return result + + class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Tessie API.""" @@ -41,7 +57,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.api_key = api_key self.vin = vin self.session = async_get_clientsession(hass) - self.data = self._flatten(data) + self.data = flatten(data) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" @@ -68,18 +84,61 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from e raise - return self._flatten(vehicle) + return flatten(vehicle) - 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 TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site live status from the Tessie API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Tessie Energy Site Live coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie Energy Site Live", + update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site data using Tessie API.""" + + try: + data = (await self.api.live_status())["response"] + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + + return data + + +class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site info from the Tessie API.""" + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Tessie Energy Info coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie Energy Site Info", + update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site data using Tessie API.""" + + try: + data = (await self.api.site_info())["response"] + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + return flatten(data) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 1b7ddcbe84c..93b9f10ae67 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -1,36 +1,47 @@ """Tessie parent entity class.""" +from abc import abstractmethod from collections.abc import Awaitable, Callable from typing import Any from aiohttp import ClientResponseError from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import TessieStateUpdateCoordinator -from .models import TessieVehicleData +from .coordinator import ( + TessieEnergySiteInfoCoordinator, + TessieEnergySiteLiveCoordinator, + TessieStateUpdateCoordinator, +) +from .models import TessieEnergyData, TessieVehicleData -class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): - """Parent class for Tessie Entities.""" +class TessieBaseEntity( + CoordinatorEntity[ + TessieStateUpdateCoordinator + | TessieEnergySiteInfoCoordinator + | TessieEnergySiteLiveCoordinator + ] +): + """Parent class for Tessie entities.""" _attr_has_entity_name = True def __init__( self, - vehicle: TessieVehicleData, + coordinator: TessieStateUpdateCoordinator + | TessieEnergySiteInfoCoordinator + | TessieEnergySiteLiveCoordinator, key: str, ) -> None: """Initialize common aspects of a Tessie entity.""" - super().__init__(vehicle.data_coordinator) - self.vin = vehicle.vin - self.key = key + self.key = key self._attr_translation_key = key - self._attr_unique_id = f"{vehicle.vin}-{key}" - self._attr_device_info = vehicle.device + super().__init__(coordinator) @property def _value(self) -> Any: @@ -41,15 +52,53 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): """Return a specific value from coordinator data.""" return self.coordinator.data.get(key or self.key, default) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @abstractmethod + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + +class TessieEntity(TessieBaseEntity): + """Parent class for Tessie vehicle entities.""" + + def __init__( + self, + vehicle: TessieVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Tessie vehicle entity.""" + self.vin = vehicle.vin + self._session = vehicle.data_coordinator.session + self._api_key = vehicle.data_coordinator.api_key + self._attr_unique_id = f"{vehicle.vin}-{key}" + self._attr_device_info = vehicle.device + + super().__init__(vehicle.data_coordinator, key) + + @property + def _value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data.get(self.key) + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() + async def run( self, func: Callable[..., Awaitable[dict[str, Any]]], **kargs: Any ) -> None: """Run a tessie_api function and handle exceptions.""" try: response = await func( - session=self.coordinator.session, + session=self._session, vin=self.vin, - api_key=self.coordinator.api_key, + api_key=self._api_key, **kargs, ) except ClientResponseError as e: @@ -63,8 +112,55 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): translation_placeholders={"name": name}, ) - def set(self, *args: Any) -> None: - """Set a value in coordinator data.""" - for key, value in args: - self.coordinator.data[key] = value - self.async_write_ha_state() + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + # Not used in this class yet + + +class TessieEnergyEntity(TessieBaseEntity): + """Parent class for Tessie energy site entities.""" + + def __init__( + self, + data: TessieEnergyData, + coordinator: TessieEnergySiteInfoCoordinator | TessieEnergySiteLiveCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie energy site entity.""" + + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(coordinator, key) + + +class TessieWallConnectorEntity(TessieBaseEntity): + """Parent class for Tessie wall connector entities.""" + + def __init__( + self, + data: TessieEnergyData, + din: str, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + self.din = din + self._attr_unique_id = f"{data.id}-{din}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, din)}, + manufacturer="Tesla", + name="Wall Connector", + via_device=(DOMAIN, str(data.id)), + serial_number=din.split("-")[-1], + ) + + super().__init__(data.live_coordinator, key) + + @property + def _value(self) -> int: + """Return a specific wall connector value from coordinator data.""" + return ( + self.coordinator.data.get("wall_connectors", {}) + .get(self.din, {}) + .get(self.key) + ) diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index 0b1051e662f..2543b3ab9e1 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -189,6 +189,42 @@ }, "drive_state_active_route_destination": { "default": "mdi:map-marker" + }, + "battery_power": { + "default": "mdi:home-battery" + }, + "energy_left": { + "default": "mdi:battery" + }, + "generator_power": { + "default": "mdi:generator-stationary" + }, + "grid_power": { + "default": "mdi:transmission-tower" + }, + "grid_services_power": { + "default": "mdi:transmission-tower" + }, + "load_power": { + "default": "mdi:power-plug" + }, + "solar_power": { + "default": "mdi:solar-power" + }, + "total_pack_energy": { + "default": "mdi:battery-high" + }, + "vin": { + "default": "mdi:car-electric" + }, + "wall_connector_fault_state": { + "default": "mdi:ev-station" + }, + "wall_connector_power": { + "default": "mdi:ev-station" + }, + "wall_connector_state": { + "default": "mdi:ev-station" } }, "switch": { diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 52fc8dd5be1..bf1ab5f61e4 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie"], - "requirements": ["tessie-api==0.0.9"] + "requirements": ["tessie-api==0.0.9", "tesla-fleet-api==0.6.1"] } diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index e96562ff8e1..ca670b9650b 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,9 +4,15 @@ from __future__ import annotations from dataclasses import dataclass +from tesla_fleet_api import EnergySpecific + from homeassistant.helpers.device_registry import DeviceInfo -from .coordinator import TessieStateUpdateCoordinator +from .coordinator import ( + TessieEnergySiteInfoCoordinator, + TessieEnergySiteLiveCoordinator, + TessieStateUpdateCoordinator, +) @dataclass @@ -14,6 +20,18 @@ class TessieData: """Data for the Tessie integration.""" vehicles: list[TessieVehicleData] + energysites: list[TessieEnergyData] + + +@dataclass +class TessieEnergyData: + """Data for a Energy Site in the Tessie integration.""" + + api: EnergySpecific + live_coordinator: TessieEnergySiteLiveCoordinator + info_coordinator: TessieEnergySiteInfoCoordinator + id: int + device: DeviceInfo @dataclass diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index dc910c7a03a..586162fe779 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from itertools import chain from typing import cast from homeassistant.components.sensor import ( @@ -33,9 +34,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance from . import TessieConfigEntry -from .const import TessieChargeStates -from .entity import TessieEntity -from .models import TessieVehicleData +from .const import TessieChargeStates, TessieWallConnectorStates +from .entity import TessieEnergyEntity, TessieEntity, TessieWallConnectorEntity +from .models import TessieEnergyData, TessieVehicleData @callback @@ -257,6 +258,115 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) +ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + entity_registry_enabled_default=False, + ), + TessieSensorEntityDescription( + key="percentage_charged", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=2, + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + 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, + entity_registry_enabled_default=False, + ), +) + +WALL_CONNECTOR_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="wall_connector_state", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: TessieWallConnectorStates(cast(int, x)).name.lower(), + options=[state.name.lower() for state in TessieWallConnectorStates], + ), + TessieSensorEntityDescription( + 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, + ), + TessieSensorEntityDescription( + key="vin", + ), +) + +ENERGY_INFO_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="vpp_backup_reserve_percent", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -264,17 +374,38 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" - data = entry.runtime_data async_add_entities( - TessieSensorEntity(vehicle, description) - for vehicle in data.vehicles - for description in DESCRIPTIONS + chain( + ( # Add vehicles + TessieVehicleSensorEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + ), + ( # Add energy site info + TessieEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ), + ( # Add energy site live + TessieEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data + ), + ( # Add wall connectors + TessieWallConnectorSensorEntity(energysite, din, description) + for energysite in entry.runtime_data.energysites + for din in energysite.live_coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ), + ) ) -class TessieSensorEntity(TessieEntity, SensorEntity): - """Base class for Tessie metric sensors.""" +class TessieVehicleSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie sensor entities.""" entity_description: TessieSensorEntityDescription @@ -284,8 +415,8 @@ class TessieSensorEntity(TessieEntity, SensorEntity): description: TessieSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(vehicle, description.key) self.entity_description = description + super().__init__(vehicle, description.key) @property def native_value(self) -> StateType | datetime: @@ -296,3 +427,68 @@ class TessieSensorEntity(TessieEntity, SensorEntity): def available(self) -> bool: """Return if sensor is available.""" return super().available and self.entity_description.available_fn(self.get()) + + +class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): + """Base class for Tessie energy site sensor entity.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + data: TessieEnergyData, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, data.live_coordinator, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = self._value is not None + self._attr_native_value = self.entity_description.value_fn(self._value) + + +class TessieEnergyInfoSensorEntity(TessieEnergyEntity, SensorEntity): + """Base class for Tessie energy site sensor entity.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + data: TessieEnergyData, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, data.info_coordinator, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = self._value is not None + self._attr_native_value = self._value + + +class TessieWallConnectorSensorEntity(TessieWallConnectorEntity, SensorEntity): + """Base class for Tessie wall connector sensor entity.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + data: TessieEnergyData, + din: str, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__( + data, + din, + description.key, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = self._value is not None + self._attr_native_value = self.entity_description.value_fn(self._value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index ea75660ddb7..8e617f137dc 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -167,6 +167,60 @@ }, "drive_state_active_route_destination": { "name": "Destination" + }, + "battery_power": { + "name": "Battery power" + }, + "energy_left": { + "name": "Energy left" + }, + "generator_power": { + "name": "Generator power" + }, + "grid_power": { + "name": "Grid power" + }, + "grid_services_power": { + "name": "Grid services power" + }, + "load_power": { + "name": "Load power" + }, + "percentage_charged": { + "name": "Percentage charged" + }, + "solar_power": { + "name": "Solar power" + }, + "total_pack_energy": { + "name": "Total pack energy" + }, + "vin": { + "name": "Vehicle" + }, + "vpp_backup_reserve_percent": { + "name": "VPP backup reserve" + }, + "wall_connector_fault_state": { + "name": "Fault state code" + }, + "wall_connector_power": { + "name": "Power" + }, + "wall_connector_state": { + "name": "State", + "state": { + "booting": "Booting", + "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]", + "disconnected": "[%key:common::state::disconnected%]", + "connected": "[%key:common::state::connected%]", + "scheduled": "Scheduled", + "negotiating": "Negotiating", + "error": "Error", + "charging_finished": "Charging finished", + "waiting_car": "Waiting car", + "charging_reduced": "Charging reduced" + } } }, "cover": { diff --git a/requirements_all.txt b/requirements_all.txt index 2ec7f38e5e3..f4de7dafa87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2713,6 +2713,7 @@ temperusb==1.6.1 # tensorflow==2.5.0 # homeassistant.components.teslemetry +# homeassistant.components.tessie tesla-fleet-api==0.6.1 # homeassistant.components.powerwall diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0e856c2f6f..12ab15a7d76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2111,6 +2111,7 @@ temescal==0.5 temperusb==1.6.1 # homeassistant.components.teslemetry +# homeassistant.components.tessie tesla-fleet-api==0.6.1 # homeassistant.components.powerwall diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index c19f6f65201..3d24c6b233a 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -16,6 +16,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry, load_json_object_fixture +# Tessie library TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) TEST_VEHICLE_STATUS_AWAKE = {"status": TessieStatus.AWAKE} @@ -47,6 +48,13 @@ ERROR_VIRTUAL_KEY = ClientResponseError( ) ERROR_CONNECTION = ClientConnectionError() +# Fleet API library +PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) +RESPONSE_OK = {"response": {}, "error": None} +COMMAND_OK = {"response": {"result": True, "reason": ""}} + async def setup_platform( hass: HomeAssistant, platforms: list[Platform] | UndefinedType = UNDEFINED diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index 77d1e3fd3e2..79cc9aa44c6 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -2,16 +2,23 @@ from __future__ import annotations +from copy import deepcopy from unittest.mock import patch import pytest from .common import ( + COMMAND_OK, + LIVE_STATUS, + PRODUCTS, + SITE_INFO, TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE, TEST_VEHICLE_STATUS_AWAKE, ) +# Tessie + @pytest.fixture(autouse=True) def mock_get_state(): @@ -41,3 +48,43 @@ def mock_get_state_of_all_vehicles(): return_value=TEST_STATE_OF_ALL_VEHICLES, ) as mock_get_state_of_all_vehicles: yield mock_get_state_of_all_vehicles + + +# Fleet API +@pytest.fixture(autouse=True) +def mock_products(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.tessie.Tessie.products", return_value=PRODUCTS + ) as mock_products: + yield mock_products + + +@pytest.fixture(autouse=True) +def mock_request(): + """Mock Tesla Fleet API request method.""" + with patch( + "homeassistant.components.tessie.Tessie._request", + return_value=COMMAND_OK, + ) as mock_request: + yield mock_request + + +@pytest.fixture(autouse=True) +def mock_live_status(): + """Mock Tesla Fleet API EnergySpecific live_status method.""" + with patch( + "homeassistant.components.tessie.EnergySpecific.live_status", + side_effect=lambda: deepcopy(LIVE_STATUS), + ) as mock_live_status: + yield mock_live_status + + +@pytest.fixture(autouse=True) +def mock_site_info(): + """Mock Tesla Fleet API EnergySpecific site_info method.""" + with patch( + "homeassistant.components.tessie.EnergySpecific.site_info", + side_effect=lambda: deepcopy(SITE_INFO), + ) as mock_live_status: + yield mock_live_status diff --git a/tests/components/tessie/fixtures/live_status.json b/tests/components/tessie/fixtures/live_status.json new file mode 100644 index 00000000000..486f9f4fadd --- /dev/null +++ b/tests/components/tessie/fixtures/live_status.json @@ -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 + } + ] + } +} diff --git a/tests/components/tessie/fixtures/products.json b/tests/components/tessie/fixtures/products.json new file mode 100644 index 00000000000..e1b76e4cefb --- /dev/null +++ b/tests/components/tessie/fixtures/products.json @@ -0,0 +1,121 @@ +{ + "response": [ + { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "LRWXF7EK4KC700000", + "color": null, + "access_type": "OWNER", + "display_name": "Test", + "option_codes": null, + "cached_data": null, + "granular_access": { "hide_private": false }, + "tokens": ["abc", "def"], + "state": "asleep", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": true, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1705701487912, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "command_signing": "allowed", + "release_notes_supported": true + }, + { + "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": true, + "powerwall_onboarding_settings_set": true, + "powerwall_tesla_electric_interested_in": null, + "vpp_tour_enabled": null, + "sync_grid_alert_enabled": true, + "breaker_alert_enabled": true, + "components": { + "battery": true, + "battery_type": "ac_powerwall", + "solar": true, + "solar_type": "pv_panel", + "grid": true, + "load_meter": true, + "market_type": "residential", + "wall_connectors": [ + { + "device_id": "abc-123", + "din": "123-abc", + "is_active": true + }, + { + "device_id": "bcd-234", + "din": "234-bcd", + "is_active": true + } + ] + }, + "features": { + "rate_plan_manager_no_pricing_constraint": true + } + } + ], + "count": 2 +} diff --git a/tests/components/tessie/fixtures/site_info.json b/tests/components/tessie/fixtures/site_info.json new file mode 100644 index 00000000000..f581707ff14 --- /dev/null +++ b/tests/components/tessie/fixtures/site_info.json @@ -0,0 +1,125 @@ +{ + "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": true, + "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, + "gateways": [ + { + "device_id": "gateway-id", + "din": "gateway-din", + "serial_number": "CN00000000J50D", + "part_number": "1152100-14-J", + "part_type": 10, + "part_name": "Tesla Backup Gateway 2", + "is_active": true, + "site_id": "1234-abcd", + "firmware_version": "24.4.0 0fe780c9", + "updated_datetime": "2024-05-14T00:00:00.000Z" + } + ], + "batteries": [ + { + "device_id": "battery-1-id", + "din": "battery-1-din", + "serial_number": "TG000000001DA5", + "part_number": "3012170-10-B", + "part_type": 2, + "part_name": "Powerwall 2", + "nameplate_max_charge_power": 5000, + "nameplate_max_discharge_power": 5000, + "nameplate_energy": 13500 + }, + { + "device_id": "battery-2-id", + "din": "battery-2-din", + "serial_number": "TG000000002DA5", + "part_number": "3012170-05-C", + "part_type": 2, + "part_name": "Powerwall 2", + "nameplate_max_charge_power": 5000, + "nameplate_max_discharge_power": 5000, + "nameplate_energy": 13500 + } + ], + "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": 2, + "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 + } +} diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 48beab6133c..ba7b4eae0a5 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1,4 +1,562 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '123456-battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_energy_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.energy_site_energy_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_left', + 'unique_id': '123456-energy_left', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_energy_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Energy Site Energy left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_energy_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_power', + 'unique_id': '123456-generator_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Generator power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '123456-grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_power', + 'unique_id': '123456-grid_services_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Grid services power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_load_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_load_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_power', + 'unique_id': '123456-load_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_load_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Load power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_percentage_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_percentage_charged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Percentage charged', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'percentage_charged', + 'unique_id': '123456-percentage_charged', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.energy_site_percentage_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Percentage charged', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energy_site_percentage_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '123456-solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Energy Site Solar power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_total_pack_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.energy_site_total_pack_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total pack energy', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_pack_energy', + 'unique_id': '123456-total_pack_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_total_pack_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Energy Site Total pack energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_total_pack_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.energy_site_vpp_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.energy_site_vpp_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VPP backup reserve', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vpp_backup_reserve_percent', + 'unique_id': '123456-vpp_backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.energy_site_vpp_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site VPP backup reserve', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energy_site_vpp_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.test_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -592,42 +1150,6 @@ 'state': 'Giga Texas', }) # --- -# name: test_sensors[sensor.test_distance-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_distance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Distance', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_est_battery_range', - 'unique_id': 'VINVINVIN-charge_state_est_battery_range', - 'unit_of_measurement': , - }) -# --- # name: test_sensors[sensor.test_distance_to_arrival-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1544,3 +2066,353 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.wall_connector_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wall_connector_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wall_connector_power', + 'unique_id': '123456-abd-123-wall_connector_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.wall_connector_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wall Connector Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wall_connector_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.wall_connector_power_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wall_connector_power_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wall_connector_power', + 'unique_id': '123456-bcd-234-wall_connector_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.wall_connector_power_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Wall Connector Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wall_connector_power_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.wall_connector_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'booting', + 'charging', + 'disconnected', + 'connected', + 'scheduled', + 'negotiating', + 'error', + 'charging_finished', + 'waiting_car', + 'charging_reduced', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wall_connector_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wall_connector_state', + 'unique_id': '123456-abd-123-wall_connector_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.wall_connector_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Wall Connector State', + 'options': list([ + 'booting', + 'charging', + 'disconnected', + 'connected', + 'scheduled', + 'negotiating', + 'error', + 'charging_finished', + 'waiting_car', + 'charging_reduced', + ]), + }), + 'context': , + 'entity_id': 'sensor.wall_connector_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.wall_connector_state_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'booting', + 'charging', + 'disconnected', + 'connected', + 'scheduled', + 'negotiating', + 'error', + 'charging_finished', + 'waiting_car', + 'charging_reduced', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wall_connector_state_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wall_connector_state', + 'unique_id': '123456-bcd-234-wall_connector_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.wall_connector_state_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Wall Connector State', + 'options': list([ + 'booting', + 'charging', + 'disconnected', + 'connected', + 'scheduled', + 'negotiating', + 'error', + 'charging_finished', + 'waiting_car', + 'charging_reduced', + ]), + }), + 'context': , + 'entity_id': 'sensor.wall_connector_state_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.wall_connector_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wall_connector_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vehicle', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vin', + 'unique_id': '123456-abd-123-vin', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.wall_connector_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Wall Connector Vehicle', + }), + 'context': , + 'entity_id': 'sensor.wall_connector_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.wall_connector_vehicle_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wall_connector_vehicle_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vehicle', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vin', + 'unique_id': '123456-bcd-234-vin', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.wall_connector_vehicle_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Wall Connector Vehicle', + }), + 'context': , + 'entity_id': 'sensor.wall_connector_vehicle_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index c4c1b6d1e72..77b2829b53a 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -2,11 +2,17 @@ from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory +from tesla_fleet_api.exceptions import Forbidden, InvalidToken + from homeassistant.components.tessie import PLATFORMS -from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.components.tessie.coordinator import ( + TESSIE_FLEET_API_SYNC_INTERVAL, + TESSIE_SYNC_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow from .common import ( ERROR_AUTH, @@ -22,60 +28,124 @@ WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) async def test_coordinator_online( - hass: HomeAssistant, mock_get_state, mock_get_status + hass: HomeAssistant, mock_get_state, mock_get_status, freezer: FrozenDateTimeFactory ) -> None: """Tests that the coordinator handles online vehicles.""" await setup_platform(hass, PLATFORMS) - async_fire_time_changed(hass, utcnow() + WAIT) + freezer.tick(WAIT) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_get_status.assert_called_once() mock_get_state.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_ON -async def test_coordinator_asleep(hass: HomeAssistant, mock_get_status) -> None: +async def test_coordinator_asleep( + hass: HomeAssistant, mock_get_status, freezer: FrozenDateTimeFactory +) -> None: """Tests that the coordinator handles asleep vehicles.""" await setup_platform(hass, [Platform.BINARY_SENSOR]) mock_get_status.return_value = TEST_VEHICLE_STATUS_ASLEEP - async_fire_time_changed(hass, utcnow() + WAIT) + freezer.tick(WAIT) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_OFF -async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_status) -> None: +async def test_coordinator_clienterror( + hass: HomeAssistant, mock_get_status, freezer: FrozenDateTimeFactory +) -> None: """Tests that the coordinator handles client errors.""" mock_get_status.side_effect = ERROR_UNKNOWN await setup_platform(hass, [Platform.BINARY_SENSOR]) - async_fire_time_changed(hass, utcnow() + WAIT) + freezer.tick(WAIT) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE -async def test_coordinator_auth(hass: HomeAssistant, mock_get_status) -> None: - """Tests that the coordinator handles timeout errors.""" +async def test_coordinator_auth( + hass: HomeAssistant, mock_get_status, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the coordinator handles auth errors.""" mock_get_status.side_effect = ERROR_AUTH await setup_platform(hass, [Platform.BINARY_SENSOR]) - async_fire_time_changed(hass, utcnow() + WAIT) + freezer.tick(WAIT) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_get_status.assert_called_once() -async def test_coordinator_connection(hass: HomeAssistant, mock_get_status) -> None: +async def test_coordinator_connection( + hass: HomeAssistant, mock_get_status, freezer: FrozenDateTimeFactory +) -> None: """Tests that the coordinator handles connection errors.""" mock_get_status.side_effect = ERROR_CONNECTION await setup_platform(hass, [Platform.BINARY_SENSOR]) - async_fire_time_changed(hass, utcnow() + WAIT) + freezer.tick(WAIT) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_live_error( + hass: HomeAssistant, mock_live_status, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the energy live coordinator handles fleet errors.""" + + await setup_platform(hass, [Platform.SENSOR]) + + mock_live_status.reset_mock() + mock_live_status.side_effect = Forbidden + freezer.tick(TESSIE_FLEET_API_SYNC_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_live_status.assert_called_once() + assert hass.states.get("sensor.energy_site_solar_power").state == STATE_UNAVAILABLE + + +async def test_coordinator_info_error( + hass: HomeAssistant, mock_site_info, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the energy info coordinator handles fleet errors.""" + + await setup_platform(hass, [Platform.SENSOR]) + + mock_site_info.reset_mock() + mock_site_info.side_effect = Forbidden + freezer.tick(TESSIE_FLEET_API_SYNC_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_site_info.assert_called_once() + assert ( + hass.states.get("sensor.energy_site_vpp_backup_reserve").state + == STATE_UNAVAILABLE + ) + + +async def test_coordinator_live_reauth(hass: HomeAssistant, mock_live_status) -> None: + """Tests that the energy live coordinator handles auth errors.""" + + mock_live_status.side_effect = InvalidToken + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_coordinator_info_reauth(hass: HomeAssistant, mock_site_info) -> None: + """Tests that the energy info coordinator handles auth errors.""" + + mock_site_info.side_effect = InvalidToken + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 81d1d758edf..e37512ea8c4 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -1,5 +1,9 @@ """Test the Tessie init.""" +from unittest.mock import patch + +from tesla_fleet_api.exceptions import TeslaFleetError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -44,3 +48,13 @@ async def test_connection_failure( mock_get_state_of_all_vehicles.side_effect = ERROR_CONNECTION entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_fleet_error(hass: HomeAssistant) -> None: + """Test init with a fleet error.""" + + with patch( + "homeassistant.components.tessie.Tessie.products", side_effect=TeslaFleetError + ): + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY