Skip to content

Commit

Permalink
feat: Allow syncing with TeslaMate via MQTT (#564)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Megabytemb and pre-commit-ci[bot] authored Apr 9, 2023
1 parent e1e286c commit 36713fb
Show file tree
Hide file tree
Showing 10 changed files with 337 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions custom_components/tesla_custom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
8 changes: 8 additions & 0 deletions custom_components/tesla_custom/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions custom_components/tesla_custom/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,6 +27,7 @@
"select",
"update",
"number",
"text",
]

AUTH_CALLBACK_PATH = "/auth/tesla/callback"
Expand All @@ -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"
1 change: 1 addition & 0 deletions custom_components/tesla_custom/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"domain": "tesla_custom",
"name": "Tesla Custom Integration",
"after_dependencies": ["mqtt"],
"codeowners": ["@alandtse"],
"config_flow": true,
"dependencies": ["http"],
Expand Down
3 changes: 2 additions & 1 deletion custom_components/tesla_custom/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
228 changes: 228 additions & 0 deletions custom_components/tesla_custom/teslamate.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 36713fb

Please sign in to comment.