diff --git a/README.md b/README.md index 47365ac8..3744c7f2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Tesla options are set via **Configuration** -> **Integrations** -> **Tesla** -> - Seconds between polling - referred to below as the `polling_interval`. - Wake cars on start - Whether to wake sleeping cars on Home Assistant startup. This allows a user to choose whether cars should continue to sleep (and not update information) or to wake up the cars potentially interrupting long term hibernation and increasing vampire drain. - Polling policy - When do we actively poll the car to get updates, and when do we try to allow the car to sleep. See [the Wiki](https://github.com/alandtse/tesla/wiki/Polling-policy) for more information. +- Sync Data from TeslaMate via MQTT - Enable syncing of Data from an TeslaMate instance via MQTT, esentially enabling the Streaming API for updates. This requies MQTT to be configured in Home Assistant. ## Potential Battery impacts diff --git a/custom_components/tesla_custom/__init__.py b/custom_components/tesla_custom/__init__.py index 2e3b8d7e..50fe2319 100644 --- a/custom_components/tesla_custom/__init__.py +++ b/custom_components/tesla_custom/__init__.py @@ -26,12 +26,14 @@ from .config_flow import CannotConnect, InvalidAuth, validate_input from .const import ( + CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, CONF_INCLUDE_VEHICLES, CONF_POLLING_POLICY, CONF_WAKE_ON_START, DATA_LISTENER, + DEFAULT_ENABLE_TESLAMATE, DEFAULT_POLLING_POLICY, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -40,6 +42,7 @@ PLATFORMS, ) from .services import async_setup_services, async_unload_services +from .teslamate import TeslaMate from .util import SSL_CONTEXT _LOGGER = logging.getLogger(__name__) @@ -286,11 +289,20 @@ def _async_create_close_task(): **{vin: _partial_coordinator(vins={vin}) for vin in cars}, } + teslamate = TeslaMate(hass=hass, cars=cars, coordinators=coordinators) + + enable_teslamate = config_entry.options.get( + CONF_ENABLE_TESLAMATE, DEFAULT_ENABLE_TESLAMATE + ) + + await teslamate.enable(enable_teslamate) + hass.data[DOMAIN][config_entry.entry_id] = { "controller": controller, "coordinators": coordinators, "cars": cars, "energysites": energysites, + "teslamate": teslamate, DATA_LISTENER: [config_entry.add_update_listener(update_listener)], } _LOGGER.debug("Connected to the Tesla API") @@ -317,6 +329,8 @@ async def async_unload_entry(hass, config_entry) -> bool: listener() username = config_entry.title + await entry_data["teslamate"].unload() + if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) _LOGGER.debug("Unloaded entry for %s", username) @@ -344,6 +358,12 @@ async def update_listener(hass, config_entry): controller.update_interval, ) + enable_teslamate = config_entry.options.get( + CONF_ENABLE_TESLAMATE, DEFAULT_ENABLE_TESLAMATE + ) + + await entry_data["teslamate"].enable(enable_teslamate) + class TeslaDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Tesla data.""" diff --git a/custom_components/tesla_custom/config_flow.py b/custom_components/tesla_custom/config_flow.py index 5418d319..22ae454c 100644 --- a/custom_components/tesla_custom/config_flow.py +++ b/custom_components/tesla_custom/config_flow.py @@ -23,11 +23,13 @@ ATTR_POLLING_POLICY_ALWAYS, ATTR_POLLING_POLICY_CONNECTED, ATTR_POLLING_POLICY_NORMAL, + CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, CONF_INCLUDE_VEHICLES, CONF_POLLING_POLICY, CONF_WAKE_ON_START, + DEFAULT_ENABLE_TESLAMATE, DEFAULT_POLLING_POLICY, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -162,6 +164,12 @@ async def async_step_init(self, user_input=None): ATTR_POLLING_POLICY_ALWAYS, ] ), + vol.Optional( + CONF_ENABLE_TESLAMATE, + default=self.config_entry.options.get( + CONF_ENABLE_TESLAMATE, DEFAULT_ENABLE_TESLAMATE + ), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/tesla_custom/const.py b/custom_components/tesla_custom/const.py index 74ea5d37..11cdf13d 100644 --- a/custom_components/tesla_custom/const.py +++ b/custom_components/tesla_custom/const.py @@ -5,11 +5,13 @@ CONF_INCLUDE_ENERGYSITES = "include_energysites" CONF_POLLING_POLICY = "polling_policy" CONF_WAKE_ON_START = "enable_wake_on_start" +CONF_ENABLE_TESLAMATE = "enable_teslamate" DOMAIN = "tesla_custom" ATTRIBUTION = "Data provided by Tesla" DATA_LISTENER = "listener" DEFAULT_SCAN_INTERVAL = 660 DEFAULT_WAKE_ON_START = False +DEFAULT_ENABLE_TESLAMATE = False ERROR_URL_NOT_DETECTED = "url_not_detected" MIN_SCAN_INTERVAL = 10 @@ -25,6 +27,7 @@ "select", "update", "number", + "text", ] AUTH_CALLBACK_PATH = "/auth/tesla/callback" @@ -42,3 +45,6 @@ DISTANCE_UNITS_KM_HR = "km/hr" SERVICE_API = "api" SERVICE_SCAN_INTERVAL = "polling_interval" + +TESLAMATE_STORAGE_VERSION = 1 +TESLAMATE_STORAGE_KEY = f"{DOMAIN}_teslamate" diff --git a/custom_components/tesla_custom/manifest.json b/custom_components/tesla_custom/manifest.json index ea9bdbd6..b50517d6 100644 --- a/custom_components/tesla_custom/manifest.json +++ b/custom_components/tesla_custom/manifest.json @@ -1,6 +1,7 @@ { "domain": "tesla_custom", "name": "Tesla Custom Integration", + "after_dependencies": ["mqtt"], "codeowners": ["@alandtse"], "config_flow": true, "dependencies": ["http"], diff --git a/custom_components/tesla_custom/strings.json b/custom_components/tesla_custom/strings.json index 8e5f0af8..da991e0a 100644 --- a/custom_components/tesla_custom/strings.json +++ b/custom_components/tesla_custom/strings.json @@ -29,7 +29,8 @@ "init": { "data": { "enable_wake_on_start": "Force cars awake on startup", - "scan_interval": "Seconds between polling" + "scan_interval": "Seconds between polling", + "enable_teslamate": "Sync Data from TeslaMate via MQTT" } } } diff --git a/custom_components/tesla_custom/teslamate.py b/custom_components/tesla_custom/teslamate.py new file mode 100644 index 00000000..909ed2a5 --- /dev/null +++ b/custom_components/tesla_custom/teslamate.py @@ -0,0 +1,228 @@ +"""TelsmaMate Module. + +This listens to Teslamate MQTT topics, and updates their entites +with the latest data. +""" + +import asyncio +import logging +from typing import TYPE_CHECKING + +from homeassistant.components.mqtt import mqtt_config_entry_enabled +from homeassistant.components.mqtt.models import ReceiveMessage +from homeassistant.components.mqtt.subscription import ( + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from teslajsonpy.car import TeslaCar + +from .const import TESLAMATE_STORAGE_KEY, TESLAMATE_STORAGE_VERSION + +if TYPE_CHECKING: + from . import TeslaDataUpdateCoordinator + +logger = logging.getLogger(__name__) + +MAP_DRIVE_STATE = { + "latitude": ("latitude", float), + "longitude": ("longitude", float), + "shift_state": ("shift_state", str), + "speed": ("speed", int), + "heading": ("heading", int), +} + +MAP_CLIMATE_STATE = { + "is_climate_on": ("is_climate_on", bool), + "inside_temp": ("inside_temp", float), + "outside_temp": ("outside_temp", float), +} + +MAP_VEHICLE_STATE = { + "tpms_pressure_fl": ("tpms_pressure_fl", float), + "tpms_pressure_fr": ("tpms_pressure_fr", float), + "tpms_pressure_rl": ("tpms_pressure_rl", float), + "tpms_pressure_rr": ("tpms_pressure_rr", float), +} + + +class TeslaMate: + """TeslaMate Connector. + + Manages connnections to MQTT topics exposed by TeslaMate. + """ + + def __init__( + self, + hass: HomeAssistant, + coordinators: list["TeslaDataUpdateCoordinator"], + cars: dict[str, TeslaCar], + ): + """Init Class.""" + self.cars = cars + self.hass = hass + self.coordinators = coordinators + self._enabled = False + + self.watchers = [] + + self._sub_state = None + self._store = Store[dict[str, str]]( + hass, TESLAMATE_STORAGE_VERSION, TESLAMATE_STORAGE_KEY + ) + + async def unload(self): + """Unload any MQTT watchers.""" + self._enabled = False + + if mqtt_config_entry_enabled(self.hass): + await self._unsub_mqtt() + else: + logger.warning( + "Cannot unsub from TeslaMate as MQTT has not been configured." + ) + + return True + + async def _unsub_mqtt(self): + """Unsub from MQTT topics.""" + logger.info("Un-subbing from MQTT Topics.") + self._sub_state = async_unsubscribe_topics(self.hass, self._sub_state) + + async def set_car_id(self, vin, teslamate_id): + """Set the TeslaMate Car ID.""" + if (data := await self._store.async_load()) is None: + data = {} + + if "car_map" not in data: + data["car_map"] = {} + + data["car_map"][vin] = teslamate_id + + await self._store.async_save(data) + + async def get_car_id(self, vin) -> str | None: + """Get the TeslaMate Car ID.""" + if (data := await self._store.async_load()) is None: + data = {} + + if "car_map" not in data: + data["car_map"] = {} + + result = data["car_map"].get(vin) + + return result + + async def enable(self, enable=True): + """Start Listening to MQTT topics.""" + + if enable is False: + return await self.unload() + + self._enabled = True + return await self.watch_cars() + + async def watch_cars(self): + """Start listening to MQTT for updates.""" + + if self._enabled is False: + logger.info("Can't watch cars. teslaMate is not enabled.") + return None + + if not mqtt_config_entry_enabled(self.hass): + logger.warning("Cannot enable TeslaMate as MQTT has not been configured.") + return None + + logger.info("Setting up MQTT subs for Teslamate") + + # We'll unsub from all topics before we create new ones. + await self._unsub_mqtt() + + for vin in self.cars: + car = self.cars[vin] + teslamate_id = await self.get_car_id(vin=vin) + + if teslamate_id is not None: + await self._watch_car(car=car, teslamate_id=teslamate_id) + + async def _watch_car(self, car: TeslaCar, teslamate_id: str): + """Set up MQTT watchers for a car.""" + + topics = {} + + def msg_recieved(msg: ReceiveMessage): + return asyncio.run_coroutine_threadsafe( + self.async_handle_new_data(car, msg), self.hass.loop + ).result() + + sub_id = f"teslamate_{car.vin}" + topics[sub_id] = { + "topic": f"teslamate/cars/{teslamate_id}/#", + "msg_callback": msg_recieved, + "qos": 0, + } + + self._sub_state = async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + await async_subscribe_topics(self.hass, self._sub_state) + + async def async_handle_new_data(self, car: TeslaCar, msg: ReceiveMessage): + """Update Car Data from MQTT msg.""" + + mqtt_attr = msg.topic.split("/")[-1] + coordinator = self.coordinators[car.vin] + + if mqtt_attr in MAP_DRIVE_STATE: + logger.info("Setting %s from MQTT", mqtt_attr) + attr, cast = MAP_DRIVE_STATE[mqtt_attr] + self.update_drive_state(car, attr, cast(msg.payload)) + coordinator.async_update_listeners() + + elif mqtt_attr in MAP_VEHICLE_STATE: + logger.info("Setting %s from MQTT", mqtt_attr) + attr, cast = MAP_VEHICLE_STATE[mqtt_attr] + self.update_vehicle_state(car, attr, cast(msg.payload)) + coordinator.async_update_listeners() + + elif mqtt_attr in MAP_CLIMATE_STATE: + logger.info("Setting %s from MQTT", mqtt_attr) + attr, cast = MAP_CLIMATE_STATE[mqtt_attr] + self.update_climate_state(car, attr, cast(msg.payload)) + coordinator.async_update_listeners() + + @staticmethod + def update_drive_state(car, attr, value): + """Update Drive State Safely.""" + # pylint: disable=protected-access + + if "drive_state" not in car._vehicle_data: + car._vehicle_data["drive_state"] = {} + + drive_state = car._vehicle_data["drive_state"] + drive_state[attr] = value + + @staticmethod + def update_vehicle_state(car, attr, value): + """Update Vehicle State Safely.""" + # pylint: disable=protected-access + + if "vehicle_state" not in car._vehicle_data: + car._vehicle_data["vehicle_state"] = {} + + vehicle_state = car._vehicle_data["vehicle_state"] + vehicle_state[attr] = value + + @staticmethod + def update_climate_state(car, attr, value): + """Update Climate State Safely.""" + # pylint: disable=protected-access + + if "climate_state" not in car._vehicle_data: + car._vehicle_data["climate_state"] = {} + + climate_state = car._vehicle_data["climate_state"] + climate_state[attr] = value diff --git a/custom_components/tesla_custom/text.py b/custom_components/tesla_custom/text.py new file mode 100644 index 00000000..5205127d --- /dev/null +++ b/custom_components/tesla_custom/text.py @@ -0,0 +1,63 @@ +"""Support for Tesla numbers.""" +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from teslajsonpy.car import TeslaCar + +from . import TeslaDataUpdateCoordinator +from .base import TeslaCarEntity +from .const import DOMAIN +from .teslamate import TeslaMate + + +async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): + """Set up the Tesla numbers by config_entry.""" + entry_data = hass.data[DOMAIN][config_entry.entry_id] + coordinators = entry_data["coordinators"] + cars = entry_data["cars"] + teslamate = entry_data["teslamate"] + entities = [] + + for vin, car in cars.items(): + coordinator = coordinators[vin] + entities.append(TeslaCarTeslaMateID(hass, car, coordinator, teslamate)) + + async_add_entities(entities, update_before_add=True) + + +class TeslaCarTeslaMateID(TeslaCarEntity, TextEntity): + """Representation of a Tesla car charge limit number.""" + + def __init__( + self, + hass: HomeAssistant, + car: TeslaCar, + coordinator: TeslaDataUpdateCoordinator, + teslamate: TeslaMate, + ) -> None: + """Initialize charge limit entity.""" + super().__init__(hass, car, coordinator) + self.type = "teslamate id" + self._attr_icon = "mdi:ev-station" + self._attr_mode = TextMode.TEXT + self._enabled_by_default = False + self._attr_entity_category = EntityCategory.CONFIG + + self.teslsmate = teslamate + self._state = None + + async def async_set_value(self, value: str) -> None: + """Update charge limit.""" + await self.teslsmate.set_car_id(self._car.vin, value) + await self.teslsmate.watch_cars() + await self.async_update_ha_state() + + async def async_update(self) -> None: + """Update the entity.""" + # Ignore manual update requests if the entity is disabled + self._state = await self.teslsmate.get_car_id(self._car.vin) + + @property + def native_value(self) -> str: + """Return charge limit.""" + return self._state diff --git a/custom_components/tesla_custom/translations/en.json b/custom_components/tesla_custom/translations/en.json index 8e5f0af8..da991e0a 100644 --- a/custom_components/tesla_custom/translations/en.json +++ b/custom_components/tesla_custom/translations/en.json @@ -29,7 +29,8 @@ "init": { "data": { "enable_wake_on_start": "Force cars awake on startup", - "scan_interval": "Seconds between polling" + "scan_interval": "Seconds between polling", + "enable_teslamate": "Sync Data from TeslaMate via MQTT" } } } diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8d348734..726a5e34 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -17,11 +17,13 @@ from custom_components.tesla_custom.const import ( ATTR_POLLING_POLICY_CONNECTED, + CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, CONF_INCLUDE_VEHICLES, CONF_POLLING_POLICY, CONF_WAKE_ON_START, + DEFAULT_ENABLE_TESLAMATE, DEFAULT_POLLING_POLICY, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -241,6 +243,7 @@ async def test_option_flow(hass): CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True, CONF_POLLING_POLICY: ATTR_POLLING_POLICY_CONNECTED, + CONF_ENABLE_TESLAMATE: True, }, ) assert result["type"] == "create_entry" @@ -248,6 +251,7 @@ async def test_option_flow(hass): CONF_SCAN_INTERVAL: 350, CONF_WAKE_ON_START: True, CONF_POLLING_POLICY: ATTR_POLLING_POLICY_CONNECTED, + CONF_ENABLE_TESLAMATE: True, } @@ -269,6 +273,7 @@ async def test_option_flow_defaults(hass): CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, CONF_POLLING_POLICY: DEFAULT_POLLING_POLICY, + CONF_ENABLE_TESLAMATE: DEFAULT_ENABLE_TESLAMATE, } @@ -290,4 +295,5 @@ async def test_option_flow_input_floor(hass): CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, CONF_WAKE_ON_START: DEFAULT_WAKE_ON_START, CONF_POLLING_POLICY: DEFAULT_POLLING_POLICY, + CONF_ENABLE_TESLAMATE: DEFAULT_ENABLE_TESLAMATE, }