diff --git a/README.md b/README.md index 76f2c5e..519f88c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The integration calculates the set of 15-minute intervals that will give the low - Optional possibility to provide information to the integration about when the EV is connected to the charger. - Optional possibility to keep the charger on after completed charging, to enable preconditioning before departure, i.e., preheating/cooling can be done from the power grid instead of the battery. - Service calls to dynamically control all configuration parameters that affect charging. -- Automatically detects and connects to the integrations [Volkswagen We Connect ID](https://github.com/mitch-dc/volkswagen_we_connect_id) and [OCPP](https://github.com/lbbrhzn/ocpp). Connections to other EV and charger integrations can be configured manually. +- Automatically detects and connects to the integrations [Volkswagen We Connect ID](https://github.com/mitch-dc/volkswagen_we_connect_id), [Tesla Fleet](https://www.home-assistant.io/integrations/tesla_fleet/) and [OCPP](https://github.com/lbbrhzn/ocpp). Connections to other EV and charger integrations can be configured manually. ## Installation diff --git a/custom_components/ev_smart_charging/config_flow.py b/custom_components/ev_smart_charging/config_flow.py index c86e8f6..8af8672 100644 --- a/custom_components/ev_smart_charging/config_flow.py +++ b/custom_components/ev_smart_charging/config_flow.py @@ -58,10 +58,10 @@ async def async_step_user(self, user_input=None): # Provide defaults for form user_input[CONF_DEVICE_NAME] = DeviceNameCreator.create(self.hass) user_input[CONF_PRICE_SENSOR] = FindEntity.find_price_sensor(self.hass) - user_input[CONF_EV_SOC_SENSOR] = FindEntity.find_vw_soc_sensor(self.hass) + user_input[CONF_EV_SOC_SENSOR] = FindEntity.find_ev_soc_sensor(self.hass) user_input[ CONF_EV_TARGET_SOC_SENSOR - ] = FindEntity.find_vw_target_soc_sensor(self.hass) + ] = FindEntity.find_ev_target_soc_sensor(self.hass) user_input[CONF_CHARGER_ENTITY] = FindEntity.find_ocpp_device(self.hass) user_input[CONF_EV_CONTROLLED] = False user_input[CONF_SOLAR_CHARGING_CONFIGURED] = False diff --git a/custom_components/ev_smart_charging/const.py b/custom_components/ev_smart_charging/const.py index 64faa5f..79363be 100644 --- a/custom_components/ev_smart_charging/const.py +++ b/custom_components/ev_smart_charging/const.py @@ -32,6 +32,7 @@ PLATFORM_TGE = "tge" PLATFORM_GESPOT = "ge_spot" PLATFORM_VW = "volkswagen_we_connect_id" +PLATFORM_TESLA_FLEET = "tesla_fleet" PLATFORM_OCPP = "ocpp" PLATFORM_GENERIC = "generic" diff --git a/custom_components/ev_smart_charging/helpers/config_flow.py b/custom_components/ev_smart_charging/helpers/config_flow.py index 712cc3d..6c2ceed 100644 --- a/custom_components/ev_smart_charging/helpers/config_flow.py +++ b/custom_components/ev_smart_charging/helpers/config_flow.py @@ -29,6 +29,7 @@ PLATFORM_NORDPOOL, PLATFORM_TGE, PLATFORM_OCPP, + PLATFORM_TESLA_FLEET, PLATFORM_VW, SWITCH, ) @@ -227,6 +228,35 @@ def find_vw_target_soc_sensor(hass: HomeAssistant) -> str: return entity_id return "" + @staticmethod + def find_tesla_fleet_soc_sensor(hass: HomeAssistant) -> str: + """Search for Tesla Fleet SOC sensor""" + entity_registry: EntityRegistry = async_entity_registry_get(hass) + registry_entries: UserDict[str, RegistryEntry] = ( + entity_registry.entities.items() + ) + for entry in registry_entries: + if entry[1].platform == PLATFORM_TESLA_FLEET: + entity_id = entry[1].entity_id + if "battery_level" in entity_id: + if "usable" not in entity_id: + return entity_id + return "" + + @staticmethod + def find_tesla_fleet_target_soc_sensor(hass: HomeAssistant) -> str: + """Search for Tesla Fleet Target SOC (Charge Limit) sensor""" + entity_registry: EntityRegistry = async_entity_registry_get(hass) + registry_entries: UserDict[str, RegistryEntry] = ( + entity_registry.entities.items() + ) + for entry in registry_entries: + if entry[1].platform == PLATFORM_TESLA_FLEET: + entity_id = entry[1].entity_id + if "charge_limit" in entity_id: + return entity_id + return "" + @staticmethod def find_ocpp_device(hass: HomeAssistant) -> str: """Find OCPP entity""" @@ -242,6 +272,26 @@ def find_ocpp_device(hass: HomeAssistant) -> str: return entity_id return "" + @staticmethod + def find_ev_soc_sensor(hass: HomeAssistant) -> str: + """Search for EV SOC sensor across supported platforms""" + sensor = "" + if len(sensor) == 0: + sensor = FindEntity.find_tesla_fleet_soc_sensor(hass) + if len(sensor) == 0: + sensor = FindEntity.find_vw_soc_sensor(hass) + return sensor + + @staticmethod + def find_ev_target_soc_sensor(hass: HomeAssistant) -> str: + """Search for EV Target SOC sensor across supported platforms""" + sensor = "" + if len(sensor) == 0: + sensor = FindEntity.find_tesla_fleet_target_soc_sensor(hass) + if len(sensor) == 0: + sensor = FindEntity.find_vw_target_soc_sensor(hass) + return sensor + class DeviceNameCreator: """Class that creates the name of the new device""" diff --git a/tests/helpers/helpers.py b/tests/helpers/helpers.py index b3c4b20..a597c0a 100644 --- a/tests/helpers/helpers.py +++ b/tests/helpers/helpers.py @@ -12,6 +12,7 @@ PLATFORM_GESPOT, PLATFORM_NORDPOOL, PLATFORM_OCPP, + PLATFORM_TESLA_FLEET, PLATFORM_TGE, PLATFORM_VW, SENSOR, @@ -379,3 +380,45 @@ def create( def set_state(hass: HomeAssistant, new_state: str): """Set state""" hass.states.async_set("switch.ocpp_charge_control", new_state) + + +class MockTeslaFleetSOCEntity: + """Mockup for Tesla Fleet SOC entity""" + + @staticmethod + def create(hass: HomeAssistant, entity_registry: EntityRegistry, value: str = "55"): + """Create a correct Tesla Fleet SOC entity""" + entity_registry.async_get_or_create( + domain=SENSOR, + platform=PLATFORM_TESLA_FLEET, + unique_id="battery_level", + ) + MockTeslaFleetSOCEntity.set_state(hass, value) + + @staticmethod + def set_state(hass: HomeAssistant, new_state: str): + """Set state""" + hass.states.async_set( + "sensor.tesla_fleet_battery_level", new_state + ) + + +class MockTeslaFleetTargetSOCEntity: + """Mockup for Tesla Fleet Target SOC (Charge Limit) entity""" + + @staticmethod + def create(hass: HomeAssistant, entity_registry: EntityRegistry, value: str = "80"): + """Create a correct Tesla Fleet target SOC entity""" + entity_registry.async_get_or_create( + domain=SENSOR, + platform=PLATFORM_TESLA_FLEET, + unique_id="charge_limit", + ) + MockTeslaFleetTargetSOCEntity.set_state(hass, value) + + @staticmethod + def set_state(hass: HomeAssistant, new_state: str): + """Set state""" + hass.states.async_set( + "sensor.tesla_fleet_charge_limit", new_state + ) diff --git a/tests/helpers/test_config_flow.py b/tests/helpers/test_config_flow.py index e5d94ef..0de3141 100644 --- a/tests/helpers/test_config_flow.py +++ b/tests/helpers/test_config_flow.py @@ -22,6 +22,7 @@ NAME, PLATFORM_NORDPOOL, PLATFORM_OCPP, + PLATFORM_TESLA_FLEET, PLATFORM_VW, SENSOR, SWITCH, @@ -43,6 +44,8 @@ MockPriceEntityTGE, MockSOCEntity, MockTargetSOCEntity, + MockTeslaFleetSOCEntity, + MockTeslaFleetTargetSOCEntity, ) from tests.price import PRICE_THIRTEEN_LIST @@ -390,3 +393,65 @@ async def test_device_name_creator(hass: HomeAssistant): ) assert (name4 := DeviceNameCreator.create(hass)) not in names assert NAME in name4 + + +async def test_find_tesla_fleet_entity(hass: HomeAssistant): + """Test the FindEntity for Tesla Fleet.""" + + entity_registry: EntityRegistry = async_entity_registry_get(hass) + + # Initially no Tesla Fleet entities should be found + assert FindEntity.find_tesla_fleet_soc_sensor(hass) == "" + assert FindEntity.find_tesla_fleet_target_soc_sensor(hass) == "" + + # Create Tesla Fleet SOC entity + MockTeslaFleetSOCEntity.create(hass, entity_registry) + assert FindEntity.find_tesla_fleet_soc_sensor(hass) != "" + assert "battery_level" in FindEntity.find_tesla_fleet_soc_sensor(hass) + + # Create Tesla Fleet Target SOC entity + MockTeslaFleetTargetSOCEntity.create(hass, entity_registry) + assert FindEntity.find_tesla_fleet_target_soc_sensor(hass) != "" + assert "charge_limit" in FindEntity.find_tesla_fleet_target_soc_sensor(hass) + + +async def test_find_ev_soc_sensor_priority(hass: HomeAssistant): + """Test that find_ev_soc_sensor prefers Tesla Fleet over VW.""" + + entity_registry: EntityRegistry = async_entity_registry_get(hass) + + # No EV entities -> empty string + assert FindEntity.find_ev_soc_sensor(hass) == "" + assert FindEntity.find_ev_target_soc_sensor(hass) == "" + + # Only VW entities -> returns VW + MockSOCEntity.create(hass, entity_registry) + MockTargetSOCEntity.create(hass, entity_registry) + assert "volkswagen" in FindEntity.find_ev_soc_sensor(hass) + assert "volkswagen" in FindEntity.find_ev_target_soc_sensor(hass) + + # Add Tesla Fleet entities -> now prefers Tesla Fleet + MockTeslaFleetSOCEntity.create(hass, entity_registry) + MockTeslaFleetTargetSOCEntity.create(hass, entity_registry) + assert "tesla_fleet" in FindEntity.find_ev_soc_sensor(hass) + assert "tesla_fleet" in FindEntity.find_ev_target_soc_sensor(hass) + + +async def test_find_tesla_fleet_soc_excludes_usable(hass: HomeAssistant): + """Test that find_tesla_fleet_soc_sensor excludes usable_battery_level.""" + + entity_registry: EntityRegistry = async_entity_registry_get(hass) + + # Create a usable_battery_level entity (should be excluded) + entity_registry.async_get_or_create( + domain=SENSOR, + platform=PLATFORM_TESLA_FLEET, + unique_id="usable_battery_level", + ) + hass.states.async_set("sensor.tesla_fleet_usable_battery_level", "50") + assert FindEntity.find_tesla_fleet_soc_sensor(hass) == "" + + # Create the correct battery_level entity + MockTeslaFleetSOCEntity.create(hass, entity_registry) + assert FindEntity.find_tesla_fleet_soc_sensor(hass) != "" + assert "usable" not in FindEntity.find_tesla_fleet_soc_sensor(hass)