From e04d6128749192b236c8f5f0a2dd2d8cc22ab33e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 6 Jul 2024 20:29:18 +1000 Subject: [PATCH] Add energy number entities for Tessie (#121354) --- homeassistant/components/tessie/number.py | 83 +++++++++++-- homeassistant/components/tessie/strings.json | 6 + .../tessie/snapshots/test_number.ambr | 116 ++++++++++++++++++ tests/components/tessie/test_number.py | 47 ++++++- 4 files changed, 240 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 56739193d7f..4847ac55da5 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -2,9 +2,12 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from itertools import chain +from typing import Any +from tesla_fleet_api import EnergySpecific from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit from homeassistant.components.number import ( @@ -21,10 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level from . import TessieConfigEntry -from .entity import TessieEntity -from .models import TessieVehicleData +from .entity import TessieEnergyEntity, TessieEntity +from .helpers import handle_command +from .models import TessieEnergyData, TessieVehicleData @dataclass(frozen=True, kw_only=True) @@ -39,7 +44,7 @@ class TessieNumberEntityDescription(NumberEntityDescription): max_key: str -DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( +VEHICLE_DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( TessieNumberEntityDescription( key="charge_state_charge_current_request", native_step=PRECISION_WHOLE, @@ -79,6 +84,28 @@ DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( ) +@dataclass(frozen=True, kw_only=True) +class TessieNumberBatteryEntityDescription(NumberEntityDescription): + """Describes Tessie Number entity.""" + + func: Callable[[EnergySpecific, float], Awaitable[Any]] + requires: str + + +ENERGY_INFO_DESCRIPTIONS: tuple[TessieNumberBatteryEntityDescription, ...] = ( + TessieNumberBatteryEntityDescription( + key="backup_reserve_percent", + func=lambda api, value: api.backup(int(value)), + requires="components_battery", + ), + TessieNumberBatteryEntityDescription( + 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: TessieConfigEntry, @@ -88,9 +115,19 @@ async def async_setup_entry( data = entry.runtime_data async_add_entities( - TessieNumberEntity(vehicle, description) - for vehicle in data.vehicles - for description in DESCRIPTIONS + chain( + ( # Add vehicle entities + TessieNumberEntity(vehicle, description) + for vehicle in data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Add energy site entities + TessieEnergyInfoNumberSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if energysite.info_coordinator.data.get(description.requires) + ), + ) ) @@ -136,3 +173,35 @@ class TessieNumberEntity(TessieEntity, NumberEntity): self.entity_description.func(), **{self.entity_description.arg: value} ) self.set((self.key, value)) + + +class TessieEnergyInfoNumberSensorEntity(TessieEnergyEntity, NumberEntity): + """Energy info number entity base class.""" + + entity_description: TessieNumberBatteryEntityDescription + _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: TessieEnergyData, + description: TessieNumberBatteryEntityDescription, + ) -> None: + """Initialize the number entity.""" + self.entity_description = description + super().__init__(data, data.info_coordinator, 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) + 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/tessie/strings.json b/homeassistant/components/tessie/strings.json index 5c11730e2cd..7c28b5df344 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -451,6 +451,9 @@ } }, "number": { + "backup_reserve_percent": { + "name": "Backup reserve" + }, "charge_state_charge_current_request": { "name": "Charge current" }, @@ -459,6 +462,9 @@ }, "vehicle_state_speed_limit_mode_current_limit_mph": { "name": "Speed limit" + }, + "off_grid_vehicle_charging_reserve_percent": { + "name": "Off grid reserve" } }, "update": { diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index c91fb74adeb..6e641bdf5b7 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -1,4 +1,120 @@ # serializer version: 1 +# name: test_numbers[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': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[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_numbers[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': 'tessie', + '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_numbers[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_numbers[number.test_charge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 8a3d1a649c7..0fb13779183 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -4,12 +4,16 @@ from unittest.mock import patch from syrupy import SnapshotAssertion -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import assert_entities, setup_platform +from .common import TEST_RESPONSE, assert_entities, setup_platform async def test_numbers( @@ -29,7 +33,7 @@ async def test_numbers( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: [entity_id], "value": 16}, + {ATTR_ENTITY_ID: [entity_id], ATTR_VALUE: 16}, blocking=True, ) mock_set_charging_amps.assert_called_once() @@ -42,7 +46,7 @@ async def test_numbers( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: [entity_id], "value": 80}, + {ATTR_ENTITY_ID: [entity_id], ATTR_VALUE: 80}, blocking=True, ) mock_set_charge_limit.assert_called_once() @@ -55,8 +59,41 @@ async def test_numbers( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: [entity_id], "value": 60}, + {ATTR_ENTITY_ID: [entity_id], ATTR_VALUE: 60}, blocking=True, ) mock_set_speed_limit.assert_called_once() assert hass.states.get(entity_id).state == "60.0" + + entity_id = "number.energy_site_backup_reserve" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.backup", + return_value=TEST_RESPONSE, + ) 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.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + return_value=TEST_RESPONSE, + ) 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()