Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow entities to be updated from TeslaMate via MQTT #564

Merged
merged 7 commits into from
Apr 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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