diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 0796f4660d4..78ae232d493 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -34,7 +34,7 @@ from .const import ( WALLCONNECTOR_DEVICE_NAME, ) -PLATFORMS: list[str] = ["binary_sensor"] +PLATFORMS: list[str] = ["binary_sensor", "sensor"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py new file mode 100644 index 00000000000..db4064c8c31 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -0,0 +1,141 @@ +"""Sensors for Tesla Wall Connector.""" +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + ENTITY_CATEGORY_DIAGNOSTIC, + FREQUENCY_HERTZ, + POWER_KILO_WATT, + TEMP_CELSIUS, +) + +from . import ( + WallConnectorData, + WallConnectorEntity, + WallConnectorLambdaValueGetterMixin, + prefix_entity_name, +) +from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WallConnectorSensorDescription( + SensorEntityDescription, WallConnectorLambdaValueGetterMixin +): + """Sensor entity description with a function pointer for getting sensor value.""" + + +WALL_CONNECTOR_SENSORS = [ + WallConnectorSensorDescription( + key="evse_state", + name=prefix_entity_name("State"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].evse_state, + ), + WallConnectorSensorDescription( + key="handle_temp_c", + name=prefix_entity_name("Handle Temperature"), + native_unit_of_measurement=TEMP_CELSIUS, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].handle_temp_c, 1), + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=STATE_CLASS_MEASUREMENT, + ), + WallConnectorSensorDescription( + key="grid_v", + name=prefix_entity_name("Grid Voltage"), + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_v, 1), + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="grid_hz", + name=prefix_entity_name("Grid Frequency"), + native_unit_of_measurement=FREQUENCY_HERTZ, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_hz, 3), + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + WallConnectorSensorDescription( + key="power", + name=prefix_entity_name("Power"), + native_unit_of_measurement=POWER_KILO_WATT, + value_fn=lambda data: round( + ( + ( + data[WALLCONNECTOR_DATA_VITALS].currentA_a + * data[WALLCONNECTOR_DATA_VITALS].voltageA_v + ) + + ( + data[WALLCONNECTOR_DATA_VITALS].currentB_a + * data[WALLCONNECTOR_DATA_VITALS].voltageB_v + ) + + ( + data[WALLCONNECTOR_DATA_VITALS].currentC_a + * data[WALLCONNECTOR_DATA_VITALS].voltageC_v + ) + ) + / 1000.0, + 1, + ), + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + WallConnectorSensorDescription( + key="total_energy_kWh", + name=prefix_entity_name("Total Energy"), + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh / 1000.0, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), +] + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Create the Wall Connector sensor devices.""" + wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + + all_entities = [ + WallConnectorSensorEntity(wall_connector_data, description) + for description in WALL_CONNECTOR_SENSORS + ] + + async_add_devices(all_entities) + + +class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): + """Wall Connector Sensor Entity.""" + + entity_description: WallConnectorSensorDescription + + def __init__( + self, + wall_connector_data: WallConnectorData, + description: WallConnectorSensorDescription, + ) -> None: + """Initialize WallConnectorSensorEntity.""" + self.entity_description = description + super().__init__(wall_connector_data) + + @property + def native_value(self): + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index a22061e197e..477926d4b66 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -1,14 +1,21 @@ """Common fixutres with default mocks as well as common test helper methods.""" -from unittest.mock import patch +from dataclasses import dataclass +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch import pytest -import tesla_wall_connector +from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals -from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.components.tesla_wall_connector.const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -24,7 +31,7 @@ def mock_wall_connector_version(): def get_default_version_data(): """Return default version data object for a wall connector.""" - return tesla_wall_connector.wall_connector.Version( + return Version( { "serial_number": "abc123", "part_number": "part_123", @@ -34,39 +41,96 @@ def get_default_version_data(): async def create_wall_connector_entry( - hass: HomeAssistant, side_effect=None + hass: HomeAssistant, side_effect=None, vitals_data=None, lifetime_data=None ) -> MockConfigEntry: """Create a wall connector entry in hass.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, - options={CONF_SCAN_INTERVAL: 30}, + options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) entry.add_to_hass(hass) - # We need to return vitals with a contactor_closed attribute - # Since that is used to determine the update scan interval - fake_vitals = tesla_wall_connector.wall_connector.Vitals( - { - "contactor_closed": "false", - } - ) - with patch( "tesla_wall_connector.WallConnector.async_get_version", return_value=get_default_version_data(), side_effect=side_effect, ), patch( "tesla_wall_connector.WallConnector.async_get_vitals", - return_value=fake_vitals, + return_value=vitals_data, side_effect=side_effect, ), patch( "tesla_wall_connector.WallConnector.async_get_lifetime", - return_value=None, + return_value=lifetime_data, side_effect=side_effect, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry + + +def get_vitals_mock() -> Vitals: + """Get mocked vitals object.""" + vitals = MagicMock(auto_spec=Vitals) + return vitals + + +def get_lifetime_mock() -> Lifetime: + """Get mocked lifetime object.""" + lifetime = MagicMock(auto_spec=Lifetime) + return lifetime + + +@dataclass +class EntityAndExpectedValues: + """Class for keeping entity id along with expected value for first and second data updates.""" + + entity_id: str + first_value: Any + second_value: Any + + +async def _test_sensors( + hass: HomeAssistant, + entities_and_expected_values, + vitals_first_update: Vitals, + vitals_second_update: Vitals, + lifetime_first_update: Lifetime, + lifetime_second_update: Lifetime, +) -> None: + """Test update of sensor values.""" + + # First Update: Data is fetched when the integration is initialized + await create_wall_connector_entry( + hass, vitals_data=vitals_first_update, lifetime_data=lifetime_first_update + ) + + # Verify expected vs actual values of first update + for entity in entities_and_expected_values: + state = hass.states.get(entity.entity_id) + assert state, f"Unable to get state of {entity.entity_id}" + assert ( + state.state == entity.first_value + ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + + # Simulate second data update + with patch( + "tesla_wall_connector.WallConnector.async_get_vitals", + return_value=vitals_second_update, + ), patch( + "tesla_wall_connector.WallConnector.async_get_lifetime", + return_value=lifetime_second_update, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL) + ) + await hass.async_block_till_done() + + # Verify expected vs actual values of second update + for entity in entities_and_expected_values: + state = hass.states.get(entity.entity_id) + assert ( + state.state == entity.second_value + ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" diff --git a/tests/components/tesla_wall_connector/test_binary_sensor.py b/tests/components/tesla_wall_connector/test_binary_sensor.py new file mode 100644 index 00000000000..09283cb5352 --- /dev/null +++ b/tests/components/tesla_wall_connector/test_binary_sensor.py @@ -0,0 +1,41 @@ +"""Tests for binary sensors.""" +from homeassistant.core import HomeAssistant + +from .conftest import ( + EntityAndExpectedValues, + _test_sensors, + get_lifetime_mock, + get_vitals_mock, +) + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test all binary sensors.""" + + entity_and_expected_values = [ + EntityAndExpectedValues( + "binary_sensor.tesla_wall_connector_contactor_closed", "off", "on" + ), + EntityAndExpectedValues( + "binary_sensor.tesla_wall_connector_vehicle_connected", "on", "off" + ), + ] + + mock_vitals_first_update = get_vitals_mock() + mock_vitals_first_update.contactor_closed = False + mock_vitals_first_update.vehicle_connected = True + + mock_vitals_second_update = get_vitals_mock() + mock_vitals_second_update.contactor_closed = True + mock_vitals_second_update.vehicle_connected = False + + lifetime_mock = get_lifetime_mock() + + await _test_sensors( + hass, + entities_and_expected_values=entity_and_expected_values, + vitals_first_update=mock_vitals_first_update, + vitals_second_update=mock_vitals_second_update, + lifetime_first_update=lifetime_mock, + lifetime_second_update=lifetime_mock, + ) diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py new file mode 100644 index 00000000000..37d6c7d9cd1 --- /dev/null +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -0,0 +1,71 @@ +"""Tests for sensors.""" +from homeassistant.core import HomeAssistant + +from .conftest import ( + EntityAndExpectedValues, + _test_sensors, + get_lifetime_mock, + get_vitals_mock, +) + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test all sensors.""" + + entity_and_expected_values = [ + EntityAndExpectedValues("sensor.tesla_wall_connector_state", "1", "2"), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_handle_temperature", "25.5", "-1.4" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_grid_voltage", "230.2", "229.2" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_grid_frequency", "50.021", "49.981" + ), + EntityAndExpectedValues("sensor.tesla_wall_connector_power", "7.6", "7.6"), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_total_energy", "988.022", "989.0" + ), + ] + + mock_vitals_first_update = get_vitals_mock() + mock_vitals_first_update.evse_state = 1 + mock_vitals_first_update.handle_temp_c = 25.51 + mock_vitals_first_update.grid_v = 230.15 + mock_vitals_first_update.grid_hz = 50.021 + # to calculate power, we calculate power of each phase and sum up + # (230.1*10) + (231.1*11) + (232.1*12) = 7628.3 W + mock_vitals_first_update.voltageA_v = 230.1 + mock_vitals_first_update.voltageB_v = 231.1 + mock_vitals_first_update.voltageC_v = 232.1 + mock_vitals_first_update.currentA_a = 10 + mock_vitals_first_update.currentB_a = 11 + mock_vitals_first_update.currentC_a = 12 + + mock_vitals_second_update = get_vitals_mock() + mock_vitals_second_update.evse_state = 2 + mock_vitals_second_update.handle_temp_c = -1.42 + mock_vitals_second_update.grid_v = 229.21 + mock_vitals_second_update.grid_hz = 49.981 + # (228.1*10) + (229.1*11) + (230.1*12) = 7562.3 W + mock_vitals_second_update.voltageB_v = 228.1 + mock_vitals_second_update.voltageC_v = 229.1 + mock_vitals_second_update.voltageA_v = 230.1 + mock_vitals_second_update.currentA_a = 10 + mock_vitals_second_update.currentB_a = 11 + mock_vitals_second_update.currentC_a = 12 + + lifetime_mock_first_update = get_lifetime_mock() + lifetime_mock_first_update.energy_wh = 988022 + lifetime_mock_second_update = get_lifetime_mock() + lifetime_mock_second_update.energy_wh = 989000 + + await _test_sensors( + hass, + entities_and_expected_values=entity_and_expected_values, + vitals_first_update=mock_vitals_first_update, + vitals_second_update=mock_vitals_second_update, + lifetime_first_update=lifetime_mock_first_update, + lifetime_second_update=lifetime_mock_second_update, + )