Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
with:
enable-cache: true
- name: Install dependencies
run: uv sync --locked
run: uv sync --locked --extra ble
- name: Ruff
run: uv run ruff check tesla_fleet_api tests
- name: Pyright
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked --python ${{ matrix.python-version }}
run: uv sync --locked --extra ble --python ${{ matrix.python-version }}
- name: Pytest
run: uv run pytest tests -q

Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Python library (`tesla_fleet_api`) providing async interfaces for Tesla Fleet AP
## Development Commands

```bash
# Install dependencies
uv sync
# Install dependencies (add --extra ble for Bluetooth work/tests)
uv sync --extra ble

# Type checking (strict mode)
uv run pyright tesla_fleet_api
Expand Down Expand Up @@ -82,6 +82,8 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A

Teslemetry/Tessie override `Vehicles` with their own vehicle classes (`TeslemetryVehicle`, `TessieVehicle`) extending `VehicleFleet` with service-specific commands (e.g., `closure()`, `seat_heater()` for Teslemetry; `wake()`, `lock()` for Tessie).

**`bleak`/`bleak-retry-connector` are the optional `ble` extra** (`pip install tesla-fleet-api[ble]`); `cryptography`, `protobuf`, and `tesla-protocol` stay base dependencies because the cloud signed-command path (`vehicle/commands.py`, `vehicle/signed.py`) needs them too, not just Bluetooth. `Vehicles.Bluetooth`/`VehiclesBluetooth.Bluetooth` and the top-level `TeslaBluetooth`/`VehicleBluetooth` re-exports resolve their bleak-backed classes from `tesla.bluetooth`/`tesla.vehicle.bluetooth` lazily (a property or module `__getattr__`, not a top-level import) so importing the cloud surface never requires `bleak` to be installed; `DEFAULT_KEEPALIVE_INTERVAL` lives in `const.py` (not `vehicle/bluetooth.py`) so it can be a real default value on `Vehicles`' bleak-free code paths. `tests/test_ble_optional_extra.py` locks this in by poisoning `bleak`/`bleak_retry_connector` in `sys.modules` in a subprocess and asserting the cloud surface still imports and works.

### Submodule Pattern

Each API client lazily attaches submodules in `__init__` via class attributes on `Tesla`:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ You can install the library using pip:
pip install tesla-fleet-api
```

Bluetooth support (`TeslaBluetooth`, `VehicleBluetooth`, `createBluetooth()`) pulls in
`bleak` and `bleak-retry-connector`, so it ships as the optional `ble` extra rather than
a base dependency — cloud-only consumers (Fleet API, Teslemetry, Tessie) don't need to
carry it. If you use Bluetooth, install with:

```bash
pip install tesla-fleet-api[ble]
```

Existing installs that already depend on `bleak`/`bleak-retry-connector` directly, or
that pin `tesla-fleet-api` without extras, keep working: add the `[ble]` extra (or keep
your own `bleak` pin) to continue resolving those packages going forward.

## Usage

### Authentication
Expand Down
2 changes: 2 additions & 0 deletions docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

This document provides detailed examples for using Bluetooth for vehicles.

Bluetooth support requires the `ble` extra: `pip install tesla-fleet-api[ble]`.

## Initialize TeslaBluetooth

The `TeslaBluetooth` class provides methods to interact with Tesla vehicles using Bluetooth. Here's a basic example to initialize the `TeslaBluetooth` class and discover nearby Tesla vehicles:
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ dependencies = [
"cryptography>=43",
"protobuf>=6.32.0",
"tesla-protocol>=0.5.0",
"bleak>=0.22",
"bleak-retry-connector>=3.9",
]

[project.optional-dependencies]
ble = ["bleak>=0.22", "bleak-retry-connector>=3.9"]

[project.urls]
"Homepage" = "https://github.com/Teslemetry/python-tesla-fleet-api"

Expand Down
17 changes: 14 additions & 3 deletions tesla_fleet_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tesla Fleet API"""

from typing import TYPE_CHECKING, Any

__author__ = "hello@teslemetry.com"
__version__ = "1.11.0"

Expand All @@ -20,7 +22,6 @@
get_tariff_periods,
unwrap_tariff_v2,
)
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
from tesla_fleet_api.tesla.fleet import TeslaFleetApi
from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth
from tesla_fleet_api.teslemetry.teslemetry import (
Expand All @@ -29,7 +30,10 @@
register_client,
)
from tesla_fleet_api.tessie.tessie import Tessie
from tesla_fleet_api.util import firmware_at_least, firmware_compare
from tesla_fleet_api.util import import_ble_class, firmware_at_least, firmware_compare

if TYPE_CHECKING:
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth as TeslaBluetooth

__all__ = [
"BleBroadcastPublisher",
Expand All @@ -43,7 +47,6 @@
"TariffRate",
"TariffResolution",
"TeslaFleetApi",
"TeslaBluetooth",
"TeslaFleetOAuth",
"Teslemetry",
"TeslemetryClientRegistration",
Expand All @@ -56,3 +59,11 @@
"register_client",
"unwrap_tariff_v2",
]


def __getattr__(name: str) -> Any:
# TeslaBluetooth requires bleak (the "ble" extra); import it lazily so
# importing this package doesn't require bleak to be installed.
if name == "TeslaBluetooth":
return import_ble_class("tesla_fleet_api.tesla.bluetooth", "TeslaBluetooth")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
4 changes: 4 additions & 0 deletions tesla_fleet_api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
# broadcast, "verify" additionally reads back state on an ack/broadcast timeout.
BluetoothConfirmation = Literal["optimistic", "ack", "verify"]

# An idle held BLE link to the vehicle drops at ~42s mean; a trivial GATT read
# every 20s keeps it alive ~10x longer. See AGENTS.md for the measured evidence.
DEFAULT_KEEPALIVE_INTERVAL = 20.0

SERVERS: dict[Region, str] = {
"na": "https://fleet-api.prd.na.vn.cloud.tesla.com",
"eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com",
Expand Down
23 changes: 19 additions & 4 deletions tesla_fleet_api/tesla/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Tesla Fleet API classes."""

from typing import TYPE_CHECKING, Any

from tesla_fleet_api.tesla.fleet import TeslaFleetApi
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth
from tesla_fleet_api.tesla.charging import Charging
from tesla_fleet_api.tesla.energysite import EnergySites, EnergySite
Expand All @@ -13,13 +14,16 @@
VehiclesBluetooth,
VehicleFleet,
VehicleSigned,
VehicleBluetooth,
Vehicle,
)
from tesla_fleet_api.util import import_ble_class

if TYPE_CHECKING:
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth as TeslaBluetooth
from tesla_fleet_api.tesla.vehicle import VehicleBluetooth as VehicleBluetooth

__all__ = [
"TeslaFleetApi",
"TeslaBluetooth",
"TeslaFleetOAuth",
"Charging",
"EnergySites",
Expand All @@ -32,7 +36,18 @@
"VehiclesBluetooth",
"VehicleFleet",
"VehicleSigned",
"VehicleBluetooth",
"Router",
"VehicleRouter",
]


def __getattr__(name: str) -> Any:
# TeslaBluetooth/VehicleBluetooth require bleak (the "ble" extra);
# import them lazily so importing this package doesn't require bleak.
if name == "TeslaBluetooth":
return import_ble_class("tesla_fleet_api.tesla.bluetooth", "TeslaBluetooth")
if name == "VehicleBluetooth":
return import_ble_class(
"tesla_fleet_api.tesla.vehicle.bluetooth", "VehicleBluetooth"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
20 changes: 18 additions & 2 deletions tesla_fleet_api/tesla/vehicle/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
"""Tesla Fleet API classes."""

from typing import TYPE_CHECKING, Any

from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles, VehiclesBluetooth
from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
from tesla_fleet_api.tesla.vehicle.signed import VehicleSigned
from tesla_fleet_api.tesla.vehicle.vehicle import Vehicle
from tesla_fleet_api.util import import_ble_class

if TYPE_CHECKING:
from tesla_fleet_api.tesla.vehicle.bluetooth import (
VehicleBluetooth as VehicleBluetooth,
)

__all__ = [
"Vehicles",
"VehiclesBluetooth",
"Vehicle",
"VehicleFleet",
"VehicleBluetooth",
"VehicleSigned",
]


def __getattr__(name: str) -> Any:
# VehicleBluetooth requires bleak (the "ble" extra); import it lazily so
# importing this package doesn't require bleak to be installed.
if name == "VehicleBluetooth":
return import_ble_class(
"tesla_fleet_api.tesla.vehicle.bluetooth", "VehicleBluetooth"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
11 changes: 6 additions & 5 deletions tesla_fleet_api/tesla/vehicle/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
from cryptography.hazmat.primitives.asymmetric import ec
from google.protobuf.message import DecodeError

from tesla_fleet_api.const import BluetoothConfirmation, BluetoothVehicleData, LOGGER
from tesla_fleet_api.const import (
DEFAULT_KEEPALIVE_INTERVAL,
BluetoothConfirmation,
BluetoothVehicleData,
LOGGER,
)
from tesla_fleet_api.exceptions import (
WHITELIST_OPERATION_STATUS,
BluetoothCommandFailed,
Expand Down Expand Up @@ -111,10 +116,6 @@
NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
APPEARANCE_UUID = "00002a01-0000-1000-8000-00805f9b34fb"

# An idle held BLE link to the vehicle drops at ~42s mean; a trivial GATT read
# every 20s keeps it alive ~10x longer. See AGENTS.md for the measured evidence.
DEFAULT_KEEPALIVE_INTERVAL = 20.0

# The connector's per-attempt timeout is fixed and unexposed. Keep one retry for
# transient failures without delaying Router fallback for its full default.
DEFAULT_CONNECT_ATTEMPTS = 2
Expand Down
34 changes: 26 additions & 8 deletions tesla_fleet_api/tesla/vehicle/vehicles.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Generic, TypeVar

from bleak.backends.device import BLEDevice
from cryptography.hazmat.primitives.asymmetric import ec

from tesla_fleet_api.const import BluetoothConfirmation
from tesla_fleet_api.const import DEFAULT_KEEPALIVE_INTERVAL, BluetoothConfirmation
from tesla_fleet_api.tesla.vehicle.signed import VehicleSigned
from tesla_fleet_api.tesla.vehicle.bluetooth import (
DEFAULT_KEEPALIVE_INTERVAL,
VehicleBluetooth,
)
from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet
from tesla_fleet_api.tesla.vehicle.vehicle import Vehicle
from tesla_fleet_api.util import import_ble_class

if TYPE_CHECKING:
from bleak.backends.device import BLEDevice

from tesla_fleet_api.tesla.fleet import TeslaFleetApi
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth


def _import_vehicle_bluetooth() -> type["VehicleBluetooth[Any]"]:
"""Import VehicleBluetooth on demand so the ``ble`` extra stays optional
for callers who never create a bluetooth vehicle."""
return import_ble_class(
"tesla_fleet_api.tesla.vehicle.bluetooth", "VehicleBluetooth"
)


FleetParentT = TypeVar("FleetParentT", bound="TeslaFleetApi")
BluetoothClientT = TypeVar("BluetoothClientT", bound="TeslaBluetooth")
Expand All @@ -27,11 +35,16 @@ class Vehicles(dict[str, Vehicle[Any]], Generic[FleetParentT]):
_parent: FleetParentT
Fleet: type[VehicleFleet[FleetParentT]] = VehicleFleet
Signed: type[VehicleSigned[FleetParentT]] = VehicleSigned
Bluetooth: type[VehicleBluetooth[FleetParentT]] = VehicleBluetooth

def __init__(self, parent: FleetParentT):
self._parent = parent

@property
def Bluetooth(self) -> type[VehicleBluetooth[FleetParentT]]:
"""The bluetooth vehicle class, imported on demand so the ``ble``
extra stays optional for callers who never create one."""
return _import_vehicle_bluetooth()

def createFleet(self, vin: str) -> VehicleFleet[FleetParentT]:
"""Creates a Fleet API vehicle."""
vehicle = self.Fleet(self._parent, vin)
Expand Down Expand Up @@ -93,11 +106,16 @@ class VehiclesBluetooth(dict[str, Vehicle[Any]], Generic[BluetoothClientT]):
"""Class containing and creating bluetooth vehicles."""

_parent: BluetoothClientT
Bluetooth: type[VehicleBluetooth[BluetoothClientT]] = VehicleBluetooth

def __init__(self, parent: BluetoothClientT):
self._parent = parent

@property
def Bluetooth(self) -> type[VehicleBluetooth[BluetoothClientT]]:
"""The bluetooth vehicle class, imported on demand so the ``ble``
extra stays optional for callers who never create one."""
return _import_vehicle_bluetooth()

def create(
self,
vin: str,
Expand Down
21 changes: 21 additions & 0 deletions tesla_fleet_api/util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
"""Shared utility helpers."""

import importlib
from typing import Any


def import_ble_class(module_path: str, class_name: str) -> Any:
"""Import a bleak-backed class on demand.

Raises a friendly ImportError naming the ``ble`` extra if bleak is not
installed, instead of letting a bare ``ModuleNotFoundError: No module
named 'bleak'`` escape to the caller. Not part of ``__all__``: this is an
internal cross-module helper, not a public API function.
"""
try:
module = importlib.import_module(module_path)
except ImportError as err:
raise ImportError(
"Bluetooth support requires the 'ble' extra: "
"install with `pip install tesla-fleet-api[ble]`."
) from err
return getattr(module, class_name)


def _parse_firmware(version: str) -> tuple[int, ...] | None:
"""Parse a dotted numeric firmware string, or None if it doesn't parse."""
Expand Down
Loading
Loading