diff --git a/AGENTS.md b/AGENTS.md index 6e1853e..116cd37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `VehicleSigned` uses multiple inheritance: `Commands` for signed command logic, `VehicleFleet` for data endpoints and fallback. +`VehicleRouter` (vehicle/router.py) is a composition wrapper (not part of the inheritance chain) that pairs a primary and a fallback vehicle instance and dispatches each method call to the primary when healthy, with automatic per-command failover to the fallback on error — e.g. a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) fallback. The health check may be a `bool`, a sync callable, or an async callable returning `bool`; when omitted it attempts the primary and fails over on exception with no up-front probe. It is exported from `tesla/vehicle/__init__.py` but has no factory on the `Vehicles` collections. + ### Vehicle Collections `Vehicles` (vehicle/vehicles.py) is a `dict[str, Vehicle]` with factory methods: diff --git a/README.md b/README.md index 615af86..ddaaca0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Tesla Fleet API is a Python library that provides an interface to interact with - Fleet API for energy sites - Fleet API with signed vehicle commands - Bluetooth for vehicles +- Routing and failover between vehicle transports (e.g. Bluetooth primary, cloud fallback) - Teslemetry integration - Tessie integration @@ -165,6 +166,44 @@ asyncio.run(main()) For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md). +### Routing and Failover + +The `VehicleRouter` class composes a primary and a fallback vehicle instance and dispatches each method call to the primary when it is healthy, automatically failing over to the fallback on error. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: + +```python +import asyncio +import aiohttp +from tesla_fleet_api import TeslaBluetooth, Teslemetry +from tesla_fleet_api.tesla.vehicle.router import VehicleRouter +from tesla_fleet_api.exceptions import TeslaFleetError + +async def main(): + async with aiohttp.ClientSession() as session: + # Primary: local Bluetooth + tesla_bluetooth = TeslaBluetooth() + await tesla_bluetooth.get_private_key("path/to/private_key.pem") + primary = tesla_bluetooth.vehicles.create("") + + # Fallback: Teslemetry cloud + teslemetry = Teslemetry(access_token="", session=session) + fallback = teslemetry.vehicles.create("") + + vehicle = VehicleRouter(primary, fallback) + + try: + await vehicle.wake_up() + except TeslaFleetError as e: + print(e) + +asyncio.run(main()) +``` + +By default the router attempts the primary and fails over to the fallback on any error, with no up-front probe. You can also pass an explicit `health` check — a `bool`, a sync callable, or an async callable returning `bool` — to decide up front whether to route to the primary or straight to the fallback. + +> **Warning:** Because a failed primary call is replayed on the fallback, a non-idempotent command (e.g. `honk_horn`, `actuate_trunk`, `door_unlock`, `charge_start`) that fails _mid-flight_ — after the primary may have already partially applied it — can be **double-executed** when it is retried on the fallback. This is a deliberate tradeoff of per-command failover. Callers needing exactly-once semantics for such commands should gate dispatch with an explicit `health` check or call the underlying `primary`/`fallback` instances directly. +> +> Dispatch is implemented via `__getattr__`, which does not proxy dunder methods, so `async with VehicleRouter(...)` does **not** manage the primary's BLE connection lifecycle (`__aenter__`/`__aexit__`). Commands still auto-connect on send; for explicit connect/disconnect reach through `vehicle.primary`. + ### Teslemetry The `Teslemetry` class provides methods to interact with the Teslemetry service. Here's a basic example: diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index 3c24f02..e7caa3f 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -11,13 +11,13 @@ Classifier: Operating System :: OS Independent Requires-Python: >=3.10 Description-Content-Type: text/markdown License-File: LICENSE -Requires-Dist: aiohttp -Requires-Dist: aiofiles -Requires-Dist: aiolimiter -Requires-Dist: cryptography -Requires-Dist: protobuf -Requires-Dist: bleak -Requires-Dist: bleak-retry-connector +Requires-Dist: aiohttp>=3 +Requires-Dist: aiofiles>=24 +Requires-Dist: aiolimiter>=1 +Requires-Dist: cryptography>=43 +Requires-Dist: protobuf>=6.31.1 +Requires-Dist: bleak>=0.22 +Requires-Dist: bleak-retry-connector>=3.9 Dynamic: license-file # Tesla Fleet API @@ -30,6 +30,7 @@ Tesla Fleet API is a Python library that provides an interface to interact with - Fleet API for energy sites - Fleet API with signed vehicle commands - Bluetooth for vehicles +- Routing and failover between vehicle transports (e.g. Bluetooth primary, cloud fallback) - Teslemetry integration - Tessie integration @@ -187,6 +188,44 @@ asyncio.run(main()) For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles.md). +### Routing and Failover + +The `VehicleRouter` class composes a primary and a fallback vehicle instance and dispatches each method call to the primary when it is healthy, automatically failing over to the fallback on error. A common setup is a local `VehicleBluetooth` primary with a cloud fallback (e.g. a `TeslemetryVehicle`), so commands go over Bluetooth when the vehicle is reachable and route to the cloud otherwise: + +```python +import asyncio +import aiohttp +from tesla_fleet_api import TeslaBluetooth, Teslemetry +from tesla_fleet_api.tesla.vehicle.router import VehicleRouter +from tesla_fleet_api.exceptions import TeslaFleetError + +async def main(): + async with aiohttp.ClientSession() as session: + # Primary: local Bluetooth + tesla_bluetooth = TeslaBluetooth() + await tesla_bluetooth.get_private_key("path/to/private_key.pem") + primary = tesla_bluetooth.vehicles.create("") + + # Fallback: Teslemetry cloud + teslemetry = Teslemetry(access_token="", session=session) + fallback = teslemetry.vehicles.create("") + + vehicle = VehicleRouter(primary, fallback) + + try: + await vehicle.wake_up() + except TeslaFleetError as e: + print(e) + +asyncio.run(main()) +``` + +By default the router attempts the primary and fails over to the fallback on any error, with no up-front probe. You can also pass an explicit `health` check — a `bool`, a sync callable, or an async callable returning `bool` — to decide up front whether to route to the primary or straight to the fallback. + +> **Warning:** Because a failed primary call is replayed on the fallback, a non-idempotent command (e.g. `honk_horn`, `actuate_trunk`, `door_unlock`, `charge_start`) that fails _mid-flight_ — after the primary may have already partially applied it — can be **double-executed** when it is retried on the fallback. This is a deliberate tradeoff of per-command failover. Callers needing exactly-once semantics for such commands should gate dispatch with an explicit `health` check or call the underlying `primary`/`fallback` instances directly. +> +> Dispatch is implemented via `__getattr__`, which does not proxy dunder methods, so `async with VehicleRouter(...)` does **not** manage the primary's BLE connection lifecycle (`__aenter__`/`__aexit__`). Commands still auto-connect on send; for explicit connect/disconnect reach through `vehicle.primary`. + ### Teslemetry The `Teslemetry` class provides methods to interact with the Teslemetry service. Here's a basic example: diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 8ef7413..6b52ff8 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -23,6 +23,7 @@ tesla_fleet_api/tesla/vehicle/__init__.py tesla_fleet_api/tesla/vehicle/bluetooth.py tesla_fleet_api/tesla/vehicle/commands.py tesla_fleet_api/tesla/vehicle/fleet.py +tesla_fleet_api/tesla/vehicle/router.py tesla_fleet_api/tesla/vehicle/signed.py tesla_fleet_api/tesla/vehicle/vehicle.py tesla_fleet_api/tesla/vehicle/vehicles.py @@ -54,4 +55,5 @@ tesla_fleet_api/tessie/__init__.py tesla_fleet_api/tessie/tessie.py tesla_fleet_api/tessie/vehicles.py tests/test_fleet_auth_refresh.py -tests/test_tessie_vehicle_params.py \ No newline at end of file +tests/test_tessie_vehicle_params.py +tests/test_vehicle_router.py \ No newline at end of file diff --git a/tesla_fleet_api.egg-info/requires.txt b/tesla_fleet_api.egg-info/requires.txt index be2c05b..6130b41 100644 --- a/tesla_fleet_api.egg-info/requires.txt +++ b/tesla_fleet_api.egg-info/requires.txt @@ -1,7 +1,7 @@ -aiohttp -aiofiles -aiolimiter -cryptography -protobuf -bleak -bleak-retry-connector +aiohttp>=3 +aiofiles>=24 +aiolimiter>=1 +cryptography>=43 +protobuf>=6.31.1 +bleak>=0.22 +bleak-retry-connector>=3.9 diff --git a/tesla_fleet_api/tesla/vehicle/__init__.py b/tesla_fleet_api/tesla/vehicle/__init__.py index 4f1b223..87df340 100644 --- a/tesla_fleet_api/tesla/vehicle/__init__.py +++ b/tesla_fleet_api/tesla/vehicle/__init__.py @@ -4,6 +4,7 @@ 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.router import VehicleRouter from tesla_fleet_api.tesla.vehicle.vehicle import Vehicle __all__ = [ @@ -13,4 +14,5 @@ "VehicleFleet", "VehicleBluetooth", "VehicleSigned", + "VehicleRouter", ] diff --git a/tesla_fleet_api/tesla/vehicle/router.py b/tesla_fleet_api/tesla/vehicle/router.py new file mode 100644 index 0000000..312d6d9 --- /dev/null +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import inspect +from typing import ( + Any, + Awaitable, + Callable, + Generic, + TypeVar, + Union, +) + +from tesla_fleet_api.const import LOGGER +from tesla_fleet_api.exceptions import TeslaFleetError + +PrimaryT = TypeVar("PrimaryT") +FallbackT = TypeVar("FallbackT") + +# A health check may be a static bool, a sync callable returning bool, or an +# async callable returning bool. When omitted the router attempts the primary +# directly and fails over to the fallback on exception (no up-front probe). +HealthCheck = Union[bool, Callable[[], bool], Callable[[], Awaitable[bool]]] + + +async def _maybe_await(value: Any) -> Any: + """Await ``value`` if it is awaitable, otherwise return it unchanged.""" + if inspect.isawaitable(value): + return await value + return value + + +class VehicleRouter(Generic[PrimaryT, FallbackT]): + """Routes method calls to a primary instance when healthy, else a fallback. + + Composes an ordered pair of vehicle instances that share a common method + surface (e.g. a :class:`VehicleBluetooth` primary and a cloud + :class:`VehicleFleet`/``TeslemetryVehicle`` fallback). Method calls are + dispatched dynamically: + + - Present and callable on *both* -> the primary is attempted (subject to an + explicit health check) with automatic failover to the fallback. + - Present only on the fallback -> the fallback is called (no health gate). + - Present only on the primary -> the primary is called. + - Present on neither -> :class:`AttributeError`. + + Non-callable attributes (e.g. ``vin``) resolve to the primary's value when + present, otherwise the fallback's. + + Dispatch to a both-present callable performs *per-command* failover: the + primary is attempted first and, if it raises any exception (a connection + failure or a mid-command transport error such as a write/notify failure or a + disconnect), the same call is automatically retried on the fallback with the + same arguments. The error only propagates when the fallback also fails. + + .. warning:: + + Because a failed primary call is replayed on the fallback, a + *non-idempotent* command (e.g. ``honk_horn``, ``actuate_trunk``, + ``door_unlock``, ``charge_start``) that fails *mid-flight* — after the + primary may have already partially applied it — can be **double-executed** + when it is retried on the fallback. This is an accepted, deliberate + tradeoff of per-command failover. Callers that need exactly-once + semantics for non-idempotent commands should gate dispatch with an + explicit health check (so a failing primary skips the primary entirely + rather than replaying on the fallback) or call the underlying + :attr:`primary`/:attr:`fallback` instances directly. + + The health check may be provided as a ``bool``, a sync callable, or an async + callable returning ``bool``. When an explicit check evaluates ``False`` the + primary is skipped entirely and the call routes straight to the fallback; + when it evaluates ``True`` the primary is attempted with the same + fall-back-on-exception behaviour. When omitted (the default) the primary is + attempted directly with fall-back-on-exception and no up-front probe. + + Dispatch is implemented via :meth:`__getattr__`, which does **not** proxy + special/dunder methods (Python looks those up on the type, not the instance). + In particular ``async with VehicleRouter(...)`` does *not* enter the primary's + async context manager, so a :class:`VehicleBluetooth` primary's BLE connection + lifecycle (``__aenter__``/``__aexit__``) is not managed by the router — its + commands still auto-connect on send, but explicit connect/disconnect must be + done by reaching through :attr:`primary`. + + The class is deliberately unbound over its two type parameters so the same + pattern can later wrap a pair of energy site classes. + """ + + _primary: PrimaryT + _fallback: FallbackT + _health: HealthCheck | None + + def __init__( + self, + primary: PrimaryT, + fallback: FallbackT, + health: HealthCheck | None = None, + ): + self._primary = primary + self._fallback = fallback + self._health = health + + async def is_healthy(self) -> bool: + """Resolve an explicit health check to a bool. + + When no explicit check is configured the router does not probe up front — + it attempts the primary and fails over on exception — so this reports + ``True`` in that case. + """ + health = self._health + if health is None: + return True + if isinstance(health, bool): + return health + return bool(await _maybe_await(health())) + + def _dispatch( + self, + name: str, + primary_attr: Callable[..., Any], + fallback_attr: Callable[..., Any], + ) -> Callable[..., Awaitable[Any]]: + """Build an async wrapper with per-command failover for a shared method.""" + + async def _routed(*args: Any, **kwargs: Any) -> Any: + if self._health is not None and not await self.is_healthy(): + return await _maybe_await(fallback_attr(*args, **kwargs)) + try: + return await _maybe_await(primary_attr(*args, **kwargs)) + except (Exception, TeslaFleetError) as e: # noqa: BLE001 - any primary failure -> fallback + LOGGER.debug("Primary call %r failed, routing to fallback: %s", name, e) + return await _maybe_await(fallback_attr(*args, **kwargs)) + + _routed.__name__ = name + _routed.__qualname__ = f"{type(self).__name__}.{name}" + return _routed + + def __getattr__(self, name: str) -> Any: + # __getattr__ is only reached when normal lookup fails. Guard the private + # attributes so an access before __init__ completes raises rather than + # recursing infinitely through this method. + if name in ("_primary", "_fallback", "_health"): + raise AttributeError(name) + + primary = self._primary + fallback = self._fallback + has_primary = hasattr(primary, name) + has_fallback = hasattr(fallback, name) + + if not has_primary and not has_fallback: + raise AttributeError( + f"{type(self).__name__!r} routes to neither primary " + f"{type(primary).__name__!r} nor fallback " + f"{type(fallback).__name__!r} for {name!r}" + ) + + primary_attr = getattr(primary, name) if has_primary else None + fallback_attr = getattr(fallback, name) if has_fallback else None + + if ( + has_primary + and has_fallback + and callable(primary_attr) + and callable(fallback_attr) + ): + return self._dispatch(name, primary_attr, fallback_attr) + + # Only one side has it (pure fall-through), or the shared attribute is not + # callable. Prefer the primary's value when present. + if has_primary: + return primary_attr + return fallback_attr + + @property + def primary(self) -> PrimaryT: + """The primary instance calls are routed to when healthy.""" + return self._primary + + @property + def fallback(self) -> FallbackT: + """The fallback instance calls are routed to when the primary is unhealthy.""" + return self._fallback diff --git a/tests/test_vehicle_router.py b/tests/test_vehicle_router.py new file mode 100644 index 0000000..2fb8eed --- /dev/null +++ b/tests/test_vehicle_router.py @@ -0,0 +1,207 @@ +"""Unit tests for VehicleRouter per-command failover and fall-through. + +These use plain fakes for the two composed vehicle instances so no real BLE +hardware or network access is required. +""" + +import asyncio +from unittest import IsolatedAsyncioTestCase + +from tesla_fleet_api.exceptions import BluetoothTimeout +from tesla_fleet_api.tesla.vehicle.router import VehicleRouter + + +class _FakePrimary: + """A fake primary whose shared command can be made to raise on demand.""" + + def __init__(self, *, fail: bool = False, exc: BaseException | None = None): + self.vin = "PRIMARY_VIN" + self.fail = fail + self.exc = exc + self.shared_calls = 0 + + async def shared(self, value: int) -> str: + self.shared_calls += 1 + if self.exc is not None: + raise self.exc + if self.fail: + raise ConnectionError("BLE command failed") + return f"primary:{value}" + + async def primary_only(self) -> str: + return "primary_only" + + +class _FakeFallback: + """A fake cloud-style fallback exposing the shared surface plus extras.""" + + def __init__(self, *, fail: bool = False): + self.vin = "FALLBACK_VIN" + self.fail = fail + self.shared_calls = 0 + + async def shared(self, value: int) -> str: + self.shared_calls += 1 + if self.fail: + raise ConnectionError("cloud command failed") + return f"fallback:{value}" + + async def fallback_only(self) -> str: + return "fallback_only" + + +class VehicleRouterTests(IsolatedAsyncioTestCase): + """Behavioural tests for VehicleRouter.""" + + async def test_primary_succeeds_routes_to_primary(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router: VehicleRouter[_FakePrimary, _FakeFallback] = VehicleRouter( + primary, fallback + ) + + result = await router.shared(7) + + self.assertEqual(result, "primary:7") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) + + async def test_primary_raises_falls_back_with_same_args(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.shared(9) + + # Primary was attempted, then the same call replayed on the fallback. + self.assertEqual(result, "fallback:9") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + + async def test_both_raise_propagates(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback(fail=True) + router = VehicleRouter(primary, fallback) + + with self.assertRaises(ConnectionError): + await router.shared(1) + + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + + async def test_primary_raises_tesla_fleet_error_falls_back(self): + # Tesla faults subclass BaseException (not Exception); failover must + # still catch them and route to the fallback. + primary = _FakePrimary(exc=BluetoothTimeout()) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.shared(11) + + self.assertEqual(result, "fallback:11") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + + async def test_primary_raises_cancelled_error_propagates(self): + # CancelledError is a BaseException but not a TeslaFleetError; it must + # propagate and never trigger fallback. + primary = _FakePrimary(exc=asyncio.CancelledError()) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + with self.assertRaises(asyncio.CancelledError): + await router.shared(12) + + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) + + async def test_method_missing_on_primary_falls_through(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.fallback_only() + + self.assertEqual(result, "fallback_only") + + async def test_method_only_on_primary_calls_primary(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.primary_only() + + self.assertEqual(result, "primary_only") + + async def test_method_missing_on_both_raises_attribute_error(self): + router = VehicleRouter(_FakePrimary(), _FakeFallback()) + + with self.assertRaises(AttributeError): + router.does_not_exist # noqa: B018 - triggers __getattr__ + + async def test_non_callable_attribute_prefers_primary(self): + router = VehicleRouter(_FakePrimary(), _FakeFallback()) + self.assertEqual(router.vin, "PRIMARY_VIN") + + async def test_health_check_bool_true_attempts_primary(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback, health=True) + + result = await router.shared(1) + + self.assertEqual(result, "primary:1") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) + + async def test_health_check_true_but_primary_raises_falls_back(self): + primary = _FakePrimary(fail=True) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback, health=True) + + result = await router.shared(2) + + self.assertEqual(result, "fallback:2") + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 1) + + async def test_health_check_bool_false_skips_primary(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback, health=False) + + result = await router.shared(3) + + self.assertEqual(result, "fallback:3") + # An explicit False gate must not attempt the primary at all. + self.assertEqual(primary.shared_calls, 0) + + async def test_health_check_sync_callable(self): + primary = _FakePrimary() + fallback = _FakeFallback() + state = {"healthy": False} + router = VehicleRouter(primary, fallback, health=lambda: state["healthy"]) + + self.assertEqual(await router.shared(4), "fallback:4") + state["healthy"] = True + self.assertEqual(await router.shared(5), "primary:5") + + async def test_health_check_async_callable(self): + primary = _FakePrimary() + fallback = _FakeFallback() + + async def _health() -> bool: + return False + + router = VehicleRouter(primary, fallback, health=_health) + + self.assertEqual(await router.shared(6), "fallback:6") + self.assertEqual(primary.shared_calls, 0) + + async def test_primary_and_fallback_properties(self): + primary = _FakePrimary() + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + self.assertIs(router.primary, primary) + self.assertIs(router.fallback, fallback)