From 36713fb8ad88f3182a56acab17a40a0575fb741f Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Sun, 9 Apr 2023 18:46:51 +1000 Subject: [PATCH 1/6] feat: Allow syncing with TeslaMate via MQTT (#564) * Initial comit of TeslaMate connection * More productionising of code. * Update readme * style: auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix pre-commit issuesl * Fix Tests. * Fix manifest file for hassfest check --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 1 + custom_components/tesla_custom/__init__.py | 20 ++ custom_components/tesla_custom/config_flow.py | 8 + custom_components/tesla_custom/const.py | 6 + custom_components/tesla_custom/manifest.json | 1 + custom_components/tesla_custom/strings.json | 3 +- custom_components/tesla_custom/teslamate.py | 228 ++++++++++++++++++ custom_components/tesla_custom/text.py | 63 +++++ .../tesla_custom/translations/en.json | 3 +- tests/test_config_flow.py | 6 + 10 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 custom_components/tesla_custom/teslamate.py create mode 100644 custom_components/tesla_custom/text.py 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, } From c3528895e6019263c65a48f68c8867b5b403de42 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 22:20:05 +0000 Subject: [PATCH 2/6] build: pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e8a758d..64663d27 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - hooks: - id: black repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 - repo: https://github.com/pre-commit/mirrors-prettier hooks: - id: prettier From cdbeb77594c0ddaf59449eab360cc1df45cbac95 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 22:20:26 +0000 Subject: [PATCH 3/6] style: auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64663d27..5e8a758d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - hooks: - id: black repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.1.0 - repo: https://github.com/pre-commit/mirrors-prettier hooks: - id: prettier From 437212bb9deb3de391b6114b8fd10eac00e9b8ed Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Tue, 18 Apr 2023 18:02:26 +1000 Subject: [PATCH 4/6] feat: Add Car Data Update Time Sensor (#568) --- custom_components/tesla_custom/base.py | 2 +- custom_components/tesla_custom/sensor.py | 28 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/custom_components/tesla_custom/base.py b/custom_components/tesla_custom/base.py index a4dae265..14a69769 100644 --- a/custom_components/tesla_custom/base.py +++ b/custom_components/tesla_custom/base.py @@ -23,7 +23,7 @@ def __init__( ) -> None: """Initialise the Tesla device.""" super().__init__(coordinator) - self._coordinator = coordinator + self._coordinator: TeslaDataUpdateCoordinator = coordinator self._enabled_by_default: bool = True self.hass = hass self.type = None diff --git a/custom_components/tesla_custom/sensor.py b/custom_components/tesla_custom/sensor.py index 8e27fc82..08235fd6 100644 --- a/custom_components/tesla_custom/sensor.py +++ b/custom_components/tesla_custom/sensor.py @@ -22,6 +22,7 @@ TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import dt from homeassistant.util.unit_conversion import DistanceConverter @@ -76,6 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie ) entities.append(TeslaCarArrivalTime(hass, car, coordinator)) entities.append(TeslaCarDistanceToArrival(hass, car, coordinator)) + entities.append(TeslaCarDataUpdateTime(hass, car, coordinator)) for energy_site_id, energysite in energysites.items(): coordinator = coordinators[energy_site_id] @@ -684,3 +686,29 @@ def native_value(self) -> float: if self._car.active_route_miles_to_arrival is None: return None return round(self._car.active_route_miles_to_arrival, 2) + + +class TeslaCarDataUpdateTime(TeslaCarEntity, SensorEntity): + """Representation of the TeslajsonPy Last Data Update time.""" + + def __init__( + self, + hass: HomeAssistant, + car: TeslaCar, + coordinator: TeslaDataUpdateCoordinator, + ) -> None: + """Initialize Last Data Update entity.""" + super().__init__(hass, car, coordinator) + self.type = "data last update time" + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_icon = "mdi:timer" + + @property + def native_value(self) -> float: + """Return the last data update time.""" + last_time = self._coordinator.controller.get_last_update_time(vin=self._car.vin) + + utc_tz = dt.get_time_zone("UTC") + date_obj = datetime.fromtimestamp(last_time, utc_tz) + return date_obj From 72ac43541a96c988a103f7935118d8049a050504 Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Fri, 21 Apr 2023 19:14:53 +1000 Subject: [PATCH 5/6] feat: Add Shift State Sensor (#569) closes #476 --- custom_components/tesla_custom/sensor.py | 43 ++++++++++++++++++++++++ hacs.json | 2 +- pyproject.toml | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/custom_components/tesla_custom/sensor.py b/custom_components/tesla_custom/sensor.py index 08235fd6..274a0550 100644 --- a/custom_components/tesla_custom/sensor.py +++ b/custom_components/tesla_custom/sensor.py @@ -67,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie entities.append(TeslaCarChargerEnergy(hass, car, coordinator)) entities.append(TeslaCarChargerPower(hass, car, coordinator)) entities.append(TeslaCarOdometer(hass, car, coordinator)) + entities.append(TeslaCarShiftState(hass, car, coordinator)) entities.append(TeslaCarRange(hass, car, coordinator)) entities.append(TeslaCarTemp(hass, car, coordinator)) entities.append(TeslaCarTemp(hass, car, coordinator, inside=True)) @@ -293,6 +294,48 @@ def native_value(self) -> float: return round(odometer_value, 2) +class TeslaCarShiftState(TeslaCarEntity, SensorEntity): + """Representation of the Tesla car Shift State sensor.""" + + def __init__( + self, + hass: HomeAssistant, + car: TeslaCar, + coordinator: TeslaDataUpdateCoordinator, + ) -> None: + """Initialize odometer entity.""" + super().__init__(hass, car, coordinator) + self.type = "shift state" + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_icon = "mdi:car-shift-pattern" + + @property + def native_value(self) -> float: + """Return the shift state.""" + value = self._car.shift_state + + # When car is parked and off, Tesla API reports shift_state None + if value is None or value == "": + return "P" + + return value + + @property + def options(self) -> float: + """Return the values for the ENUM.""" + values = ["P", "D", "R", "N"] + + return values + + @property + def extra_state_attributes(self): + """Return device state attributes.""" + + return { + "raw_state": self._car.shift_state, + } + + class TeslaCarRange(TeslaCarEntity, SensorEntity): """Representation of the Tesla car range sensor.""" diff --git a/hacs.json b/hacs.json index ba51d516..71d3476e 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Tesla", "hacs": "1.6.0", - "homeassistant": "2022.11.0", + "homeassistant": "2023.1.0", "zip_release": true, "filename": "tesla_custom.zip" } diff --git a/pyproject.toml b/pyproject.toml index 2180752b..90be382b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ teslajsonpy = "^3.8.0" [tool.poetry.group.dev.dependencies] -homeassistant = ">=2022.11.0" +homeassistant = ">=2023.1.0" pytest-homeassistant-custom-component = ">=0.13.1" bandit = ">=1.7.0" black = {version = ">=21.12b0", allow-prereleases = true} From 970759c2219d3d7c24b3de66f80c8c888e1e56ee Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Fri, 21 Apr 2023 19:23:02 +1000 Subject: [PATCH 6/6] feat: add more updates to TeslaMate (#572) closes #573 --- custom_components/tesla_custom/teslamate.py | 74 ++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/custom_components/tesla_custom/teslamate.py b/custom_components/tesla_custom/teslamate.py index 909ed2a5..23bf059a 100644 --- a/custom_components/tesla_custom/teslamate.py +++ b/custom_components/tesla_custom/teslamate.py @@ -15,8 +15,10 @@ async_subscribe_topics, async_unsubscribe_topics, ) +from homeassistant.const import UnitOfLength, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store +from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from teslajsonpy.car import TeslaCar from .const import TESLAMATE_STORAGE_KEY, TESLAMATE_STORAGE_VERSION @@ -26,11 +28,44 @@ logger = logging.getLogger(__name__) + +def cast_odometer(odometer: float) -> float: + """Convert KM to Miles. + + The Tesla API natively returns the Odometer in Miles. + TeslaMate returns the Odometer in KMs. + We need to convert to Miles so the Odometer sensor calculates + properly. + """ + odometer_km = float(odometer) + odometer_miles = DistanceConverter.convert( + odometer_km, UnitOfLength.KILOMETERS, UnitOfLength.MILES + ) + + return odometer_miles + + +def cast_speed(speed: int) -> int: + """Convert KM to Miles. + + The Tesla API natively returns the Speed in Miles M/H. + TeslaMate returns the Speed in km/h. + We need to convert to Miles so the speed calculates + properly. + """ + speed_km = int(speed) + speed_miles = SpeedConverter.convert( + speed_km, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR + ) + + return int(speed_miles) + + MAP_DRIVE_STATE = { "latitude": ("latitude", float), "longitude": ("longitude", float), "shift_state": ("shift_state", str), - "speed": ("speed", int), + "speed": ("speed", cast_speed), "heading": ("heading", int), } @@ -45,6 +80,20 @@ "tpms_pressure_fr": ("tpms_pressure_fr", float), "tpms_pressure_rl": ("tpms_pressure_rl", float), "tpms_pressure_rr": ("tpms_pressure_rr", float), + "locked": ("locked", bool), + "sentry_mode": ("sentry_mode", bool), + "odometer": ("odometer", cast_odometer), +} + +MAP_CHARGE_STATE = { + "battery_level": ("battery_level", float), + "usable_battery_level": ("usable_battery_level", float), + "charge_energy_added": ("charge_energy_added", float), + "charger_actual_current": ("charger_actual_current", int), + "charger_power": ("charger_power", int), + "charger_voltage": ("charger_voltage", int), + "time_to_full_charge": ("time_to_full_charge", float), + "charge_limit_soc": ("charge_limit_soc", int), } @@ -158,8 +207,10 @@ def msg_recieved(msg: ReceiveMessage): ).result() sub_id = f"teslamate_{car.vin}" + mqtt_topic = f"teslamate/cars/{teslamate_id}/#" + topics[sub_id] = { - "topic": f"teslamate/cars/{teslamate_id}/#", + "topic": mqtt_topic, "msg_callback": msg_recieved, "qos": 0, } @@ -170,6 +221,8 @@ def msg_recieved(msg: ReceiveMessage): await async_subscribe_topics(self.hass, self._sub_state) + logger.info("Subscribed to topic: %s", mqtt_topic) + async def async_handle_new_data(self, car: TeslaCar, msg: ReceiveMessage): """Update Car Data from MQTT msg.""" @@ -194,6 +247,12 @@ async def async_handle_new_data(self, car: TeslaCar, msg: ReceiveMessage): self.update_climate_state(car, attr, cast(msg.payload)) coordinator.async_update_listeners() + elif mqtt_attr in MAP_CHARGE_STATE: + logger.info("Setting %s from MQTT", mqtt_attr) + attr, cast = MAP_CHARGE_STATE[mqtt_attr] + self.update_charge_state(car, attr, cast(msg.payload)) + coordinator.async_update_listeners() + @staticmethod def update_drive_state(car, attr, value): """Update Drive State Safely.""" @@ -226,3 +285,14 @@ def update_climate_state(car, attr, value): climate_state = car._vehicle_data["climate_state"] climate_state[attr] = value + + @staticmethod + def update_charge_state(car, attr, value): + """Update Charge State Safely.""" + # pylint: disable=protected-access + + if "charge_state" not in car._vehicle_data: + car._vehicle_data["charge_state"] = {} + + charge_state = car._vehicle_data["charge_state"] + charge_state[attr] = value