diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ff6791..46d4e28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 3837053..e4cada2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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`: diff --git a/README.md b/README.md index 5ed1b72..779c775 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index b163edf..e0fb01c 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 4160c47..ad2c0b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tesla_fleet_api/__init__.py b/tesla_fleet_api/__init__.py index bd146c8..f45c109 100644 --- a/tesla_fleet_api/__init__.py +++ b/tesla_fleet_api/__init__.py @@ -1,5 +1,7 @@ """Tesla Fleet API""" +from typing import TYPE_CHECKING, Any + __author__ = "hello@teslemetry.com" __version__ = "1.11.0" @@ -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 ( @@ -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", @@ -43,7 +47,6 @@ "TariffRate", "TariffResolution", "TeslaFleetApi", - "TeslaBluetooth", "TeslaFleetOAuth", "Teslemetry", "TeslemetryClientRegistration", @@ -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}") diff --git a/tesla_fleet_api/const.py b/tesla_fleet_api/const.py index 3087ac3..2f6969e 100644 --- a/tesla_fleet_api/const.py +++ b/tesla_fleet_api/const.py @@ -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", diff --git a/tesla_fleet_api/tesla/__init__.py b/tesla_fleet_api/tesla/__init__.py index 26c445d..04cbd7e 100644 --- a/tesla_fleet_api/tesla/__init__.py +++ b/tesla_fleet_api/tesla/__init__.py @@ -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 @@ -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", @@ -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}") diff --git a/tesla_fleet_api/tesla/vehicle/__init__.py b/tesla_fleet_api/tesla/vehicle/__init__.py index 4f1b223..3591dab 100644 --- a/tesla_fleet_api/tesla/vehicle/__init__.py +++ b/tesla_fleet_api/tesla/vehicle/__init__.py @@ -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}") diff --git a/tesla_fleet_api/tesla/vehicle/bluetooth.py b/tesla_fleet_api/tesla/vehicle/bluetooth.py index 5ec045d..e440c3a 100644 --- a/tesla_fleet_api/tesla/vehicle/bluetooth.py +++ b/tesla_fleet_api/tesla/vehicle/bluetooth.py @@ -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, @@ -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 diff --git a/tesla_fleet_api/tesla/vehicle/vehicles.py b/tesla_fleet_api/tesla/vehicle/vehicles.py index 822acf8..3e4dfaf 100644 --- a/tesla_fleet_api/tesla/vehicle/vehicles.py +++ b/tesla_fleet_api/tesla/vehicle/vehicles.py @@ -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") @@ -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) @@ -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, diff --git a/tesla_fleet_api/util.py b/tesla_fleet_api/util.py index d2f1510..7b02f83 100644 --- a/tesla_fleet_api/util.py +++ b/tesla_fleet_api/util.py @@ -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.""" diff --git a/tests/test_ble_optional_extra.py b/tests/test_ble_optional_extra.py new file mode 100644 index 0000000..d457ccc --- /dev/null +++ b/tests/test_ble_optional_extra.py @@ -0,0 +1,143 @@ +"""Bluetooth (``bleak``/``bleak-retry-connector``) is an optional extra +(``tesla-fleet-api[ble]``); the cloud-only surface must import without it. + +These tests run the check in a subprocess with ``bleak``/``bleak_retry_connector`` +poisoned in ``sys.modules`` (setting a module name to ``None`` makes any +``import`` of it raise ``ModuleNotFoundError``, the same failure a real +uninstalled package produces) so the proof holds even though this repo's own +dev/test environment has the ``ble`` extra installed. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from unittest import TestCase + +_BLOCK_BLE_PRELUDE = """ +import sys +sys.modules["bleak"] = None +sys.modules["bleak_retry_connector"] = None +""" + + +def _run(script: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", _BLOCK_BLE_PRELUDE + textwrap.dedent(script)], + capture_output=True, + text=True, + timeout=60, + ) + + +class TestBluetoothIsOptional(TestCase): + def test_top_level_package_imports_without_bleak(self): + result = _run("import tesla_fleet_api") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_cloud_surface_imports_and_works_without_bleak(self): + result = _run( + """ + from tesla_fleet_api import ( + ObservationFunnel, + Region, + TeslaFleetApi, + TeslaFleetOAuth, + Teslemetry, + Tessie, + ) + from tesla_fleet_api.router import EnergySiteRouter, Router, VehicleRouter + from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles + from cryptography.hazmat.primitives.asymmetric import ec + + class DummyParent: + private_key = ec.generate_private_key(ec.SECP256R1()) + + def _request(self): + raise NotImplementedError + + vehicles = Vehicles(parent=DummyParent()) + vehicles.createFleet("5YJXCAE43LF123456") + vehicles.createSigned("5YJXCAE43LF123456") + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_teslemetry_and_tessie_subpackages_import_without_bleak(self): + result = _run( + """ + import tesla_fleet_api.teslemetry.teslemetry + import tesla_fleet_api.tessie.tessie + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_creating_bluetooth_vehicle_without_bleak_raises_clear_error(self): + result = _run( + """ + from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles + + vehicles = Vehicles(parent=object()) + try: + vehicles.createBluetooth("5YJXCAE43LF123456") + except ImportError as err: + assert "ble" in str(err) + assert "tesla-fleet-api[ble]" in str(err) + else: + raise AssertionError("expected ImportError") + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_tesla_bluetooth_attribute_without_bleak_raises_clear_error(self): + result = _run( + """ + import tesla_fleet_api + + try: + tesla_fleet_api.TeslaBluetooth + except ImportError as err: + assert "tesla-fleet-api[ble]" in str(err) + else: + raise AssertionError("expected ImportError") + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_tesla_vehicle_bluetooth_attribute_without_bleak_raises_clear_error(self): + result = _run( + """ + import tesla_fleet_api.tesla + + try: + tesla_fleet_api.tesla.TeslaBluetooth + except ImportError as err: + assert "tesla-fleet-api[ble]" in str(err) + else: + raise AssertionError("expected ImportError") + + try: + tesla_fleet_api.tesla.VehicleBluetooth + except ImportError as err: + assert "tesla-fleet-api[ble]" in str(err) + else: + raise AssertionError("expected ImportError") + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_vehicle_bluetooth_attribute_without_bleak_raises_clear_error(self): + result = _run( + """ + import tesla_fleet_api.tesla.vehicle + + try: + tesla_fleet_api.tesla.vehicle.VehicleBluetooth + except ImportError as err: + assert "tesla-fleet-api[ble]" in str(err) + else: + raise AssertionError("expected ImportError") + """ + ) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/uv.lock b/uv.lock index 5bf9e25..bc56fc5 100644 --- a/uv.lock +++ b/uv.lock @@ -746,13 +746,17 @@ dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, { name = "aiolimiter" }, - { name = "bleak" }, - { name = "bleak-retry-connector" }, { name = "cryptography" }, { name = "protobuf" }, { name = "tesla-protocol" }, ] +[package.optional-dependencies] +ble = [ + { name = "bleak" }, + { name = "bleak-retry-connector" }, +] + [package.dev-dependencies] dev = [ { name = "pyright" }, @@ -765,12 +769,13 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24" }, { name = "aiohttp", specifier = ">=3" }, { name = "aiolimiter", specifier = ">=1" }, - { name = "bleak", specifier = ">=0.22" }, - { name = "bleak-retry-connector", specifier = ">=3.9" }, + { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.22" }, + { name = "bleak-retry-connector", marker = "extra == 'ble'", specifier = ">=3.9" }, { name = "cryptography", specifier = ">=43" }, { name = "protobuf", specifier = ">=6.32.0" }, { name = "tesla-protocol", specifier = ">=0.5.0" }, ] +provides-extras = ["ble"] [package.metadata.requires-dev] dev = [