diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index a7d649bce56967..60230cd881d3f8 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -62,6 +62,12 @@ def get(self, key: str, default: Any | None = None) -> Any | None: """Return a specific value from coordinator data.""" return self.coordinator.data.get(key, default) + def get_number(self, key: str, default: float) -> float: + """Return a specific number from coordinator data.""" + if isinstance(value := self.coordinator.data.get(key), (int, float)): + return value + return default + @property def is_none(self) -> bool: """Return if the value is a literal None.""" diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py new file mode 100644 index 00000000000000..b806b4dbc77d49 --- /dev/null +++ b/homeassistant/components/tesla_fleet/number.py @@ -0,0 +1,206 @@ +"""Number platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslaFleetEnergyData, TeslaFleetVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): + """Describes TeslaFleet Number entity.""" + + func: Callable[[VehicleSpecific, float], Awaitable[Any]] + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( + TeslaFleetNumberVehicleEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + mode=NumberMode.AUTO, + max_key="charge_state_charge_current_request_max", + func=lambda api, value: api.set_charging_amps(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS], + ), + TeslaFleetNumberVehicleEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + mode=NumberMode.AUTO, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda api, value: api.set_charge_limit(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), +) + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription): + """Describes TeslaFleet Number entity.""" + + func: Callable[[EnergySpecific, float], Awaitable[Any]] + requires: str | None = None + + +ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = ( + TeslaFleetNumberBatteryEntityDescription( + key="backup_reserve_percent", + func=lambda api, value: api.backup(int(value)), + requires="components_battery", + ), + TeslaFleetNumberBatteryEntityDescription( + key="off_grid_vehicle_charging_reserve_percent", + func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), + requires="components_off_grid_vehicle_charging_reserve_supported", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet number platform from a config entry.""" + + async_add_entities( + chain( + ( # Add vehicle entities + TeslaFleetVehicleNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Add energy site entities + TeslaFleetEnergyInfoNumberSensorEntity( + energysite, + description, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.requires is None + or energysite.info_coordinator.data.get(description.requires) + ), + ) + ) + + +class TeslaFleetVehicleNumberEntity(TeslaFleetVehicleEntity, NumberEntity): + """Vehicle number entity base class.""" + + entity_description: TeslaFleetNumberVehicleEntityDescription + + def __init__( + self, + data: TeslaFleetVehicleData, + description: TeslaFleetNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + super().__init__( + data, + description.key, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + + if (min_key := self.entity_description.min_key) is not None: + self._attr_native_min_value = self.get_number( + min_key, + self.entity_description.native_min_value, + ) + else: + self._attr_native_min_value = self.entity_description.native_min_value + + self._attr_native_max_value = self.get_number( + self.entity_description.max_key, + self.entity_description.native_max_value, + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_read_only(self.entity_description.scopes[0]) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslaFleetEnergyInfoNumberSensorEntity(TeslaFleetEnergyInfoEntity, NumberEntity): + """Energy info number entity base class.""" + + entity_description: TeslaFleetNumberBatteryEntityDescription + _attr_native_step = PRECISION_WHOLE + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_device_class = NumberDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, + data: TeslaFleetEnergyData, + description: TeslaFleetNumberBatteryEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + self._attr_icon = icon_for_battery_level(self.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 25011cd6d4590b..ed8f45d2f8f652 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -133,6 +133,20 @@ "name": "Route" } }, + "number": { + "backup_reserve_percent": { + "name": "Backup reserve" + }, + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "off_grid_vehicle_charging_reserve_percent": { + "name": "Off grid reserve" + } + }, "select": { "climate_state_seat_heater_left": { "name": "Seat heater front left", diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr new file mode 100644 index 00000000000000..00dd67015fefa7 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_number[number.energy_site_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Backup reserve', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Backup reserve', + 'icon': 'mdi:battery-alert', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_off_grid_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Off grid reserve', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'off_grid_vehicle_charging_reserve_percent', + 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Off grid reserve', + 'icon': 'mdi:battery-unknown', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_off_grid_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_number[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py new file mode 100644 index 00000000000000..8551a99ee29619 --- /dev/null +++ b/tests/components/tesla_fleet/test_number.py @@ -0,0 +1,119 @@ +"""Test the Tesla Fleet number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the number entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_number_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the number entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + state = hass.states.get("number.test_charge_current") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_services( + hass: HomeAssistant, mock_vehicle_data, normal_config_entry: MockConfigEntry +) -> None: + """Tests that the number services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + + entity_id = "number.test_charge_current" + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.set_charging_amps", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 16}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "16" + call.assert_called_once() + + entity_id = "number.test_charge_limit" + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.set_charge_limit", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "60" + call.assert_called_once() + + entity_id = "number.energy_site_backup_reserve" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.backup", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 80, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "80" + call.assert_called_once() + + entity_id = "number.energy_site_off_grid_reserve" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.off_grid_vehicle_charging_reserve", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 88}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "88" + call.assert_called_once()