From b1901be4e3b28febf21db90072f61c436cb0b1fb Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 17:08:27 +1000 Subject: [PATCH 1/5] Add VehicleRouter for health-gated primary/fallback dispatch Compose two vehicle instances (e.g. VehicleBluetooth primary + cloud TeslemetryVehicle fallback) and route each call to the primary when healthy, otherwise the fallback. Methods present only on the fallback fall through transparently; methods on neither raise AttributeError. Health check accepts a bool, sync callable, or async callable; the default feature-detects the primary's connection signal (BLE connect_if_needed / client.is_connected) and routes to the fallback on connection failure. Generic over two unbound type params so the same pattern can wrap energy sites later. Co-Authored-By: Claude Opus 4.8 (1M context) --- tesla_fleet_api/tesla/vehicle/__init__.py | 2 + tesla_fleet_api/tesla/vehicle/router.py | 167 ++++++++++++++++++++ tests/test_vehicle_router.py | 183 ++++++++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 tesla_fleet_api/tesla/vehicle/router.py create mode 100644 tests/test_vehicle_router.py 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..d173c91 --- /dev/null +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import inspect +from typing import ( + Any, + Awaitable, + Callable, + Generic, + TypeVar, + Union, +) + +from tesla_fleet_api.const import LOGGER + +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 derives a default from +# the primary instance (see ``_default_health``). +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 health check decides: primary when + healthy, otherwise 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. + + The health check may be provided as a ``bool``, a sync callable, or an async + callable returning ``bool``. When omitted, a default is derived from the + primary that treats "can establish a Bluetooth connection" as healthy and + routes to the fallback on any connection failure. The default feature-detects + its health signal, so it is not hard-wired to Bluetooth: a primary without a + recognised connection signal is simply treated as always healthy. + + 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 _default_health(self) -> bool: + """Derive health from the primary by feature-detecting a connection signal. + + For a Bluetooth primary this attempts ``connect_if_needed()`` and treats a + successful connection as healthy, returning ``False`` on any connection + failure so calls route to the fallback. A primary exposing only a + ``client.is_connected`` flag is judged by that flag. A primary with no + recognised signal is treated as healthy. + """ + connect_if_needed = getattr(self._primary, "connect_if_needed", None) + if callable(connect_if_needed): + try: + await _maybe_await(connect_if_needed()) + return True + except Exception as e: # noqa: BLE001 - any connection failure -> fallback + LOGGER.debug("Primary health check failed, routing to fallback: %s", e) + return False + + client = getattr(self._primary, "client", None) + if client is not None: + return bool(getattr(client, "is_connected", False)) + + return True + + async def is_healthy(self) -> bool: + """Resolve the configured (or default) health check to a bool.""" + health = self._health + if health is None: + return await self._default_health() + 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 a health-gated async wrapper for a method on both instances.""" + + async def _routed(*args: Any, **kwargs: Any) -> Any: + if await self.is_healthy(): + return await _maybe_await(primary_attr(*args, **kwargs)) + 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..aeab383 --- /dev/null +++ b/tests/test_vehicle_router.py @@ -0,0 +1,183 @@ +"""Unit tests for VehicleRouter health-gated dispatch and fall-through. + +These use plain fakes for the two composed vehicle instances so no real BLE +hardware or network access is required. +""" + +from unittest import IsolatedAsyncioTestCase + +from tesla_fleet_api.tesla.vehicle.router import VehicleRouter + + +class _FakePrimary: + """A fake Bluetooth-style primary with a connect_if_needed health signal.""" + + def __init__(self, *, can_connect: bool = True): + self.vin = "PRIMARY_VIN" + self.can_connect = can_connect + self.connect_calls = 0 + self.shared_calls = 0 + + async def connect_if_needed(self) -> None: + self.connect_calls += 1 + if not self.can_connect: + raise ConnectionError("BLE unreachable") + + async def shared(self, value: int) -> str: + self.shared_calls += 1 + 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): + self.vin = "FALLBACK_VIN" + self.shared_calls = 0 + + async def shared(self, value: int) -> str: + self.shared_calls += 1 + return f"fallback:{value}" + + async def fallback_only(self) -> str: + return "fallback_only" + + +class VehicleRouterTests(IsolatedAsyncioTestCase): + """Behavioural tests for VehicleRouter.""" + + async def test_primary_healthy_routes_to_primary(self): + primary = _FakePrimary(can_connect=True) + 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) + # Default health should have attempted a connection. + self.assertEqual(primary.connect_calls, 1) + + async def test_primary_unhealthy_routes_to_fallback(self): + primary = _FakePrimary(can_connect=False) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.shared(9) + + self.assertEqual(result, "fallback:9") + self.assertEqual(primary.shared_calls, 0) + self.assertEqual(fallback.shared_calls, 1) + + async def test_method_missing_on_primary_falls_through(self): + primary = _FakePrimary(can_connect=True) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback) + + result = await router.fallback_only() + + self.assertEqual(result, "fallback_only") + # Pure fall-through: no health gate, so no connection attempt. + self.assertEqual(primary.connect_calls, 0) + + async def test_method_only_on_primary_calls_primary(self): + primary = _FakePrimary(can_connect=True) + 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(self): + primary = _FakePrimary(can_connect=False) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback, health=True) + + result = await router.shared(1) + + self.assertEqual(result, "primary:1") + # Static bool health should not consult the connection signal. + self.assertEqual(primary.connect_calls, 0) + + async def test_health_check_bool_false(self): + primary = _FakePrimary(can_connect=True) + fallback = _FakeFallback() + router = VehicleRouter(primary, fallback, health=False) + + result = await router.shared(2) + + self.assertEqual(result, "fallback:2") + + async def test_health_check_sync_callable(self): + primary = _FakePrimary(can_connect=True) + fallback = _FakeFallback() + state = {"healthy": False} + router = VehicleRouter(primary, fallback, health=lambda: state["healthy"]) + + self.assertEqual(await router.shared(3), "fallback:3") + state["healthy"] = True + self.assertEqual(await router.shared(4), "primary:4") + + async def test_health_check_async_callable(self): + primary = _FakePrimary(can_connect=True) + fallback = _FakeFallback() + + async def _health() -> bool: + return False + + router = VehicleRouter(primary, fallback, health=_health) + + self.assertEqual(await router.shared(5), "fallback:5") + + async def test_default_health_client_is_connected(self): + """A primary without connect_if_needed uses client.is_connected.""" + + class _ClientPrimary: + def __init__(self, connected: bool): + self.client = type("C", (), {"is_connected": connected})() + + async def shared(self, value: int) -> str: + return f"primary:{value}" + + fallback = _FakeFallback() + + connected = VehicleRouter(_ClientPrimary(True), fallback) + self.assertEqual(await connected.shared(1), "primary:1") + + disconnected = VehicleRouter(_ClientPrimary(False), fallback) + self.assertEqual(await disconnected.shared(1), "fallback:1") + + async def test_default_health_no_signal_is_healthy(self): + """A primary with no recognised connection signal is treated healthy.""" + + class _PlainPrimary: + async def shared(self, value: int) -> str: + return f"primary:{value}" + + router = VehicleRouter(_PlainPrimary(), _FakeFallback()) + self.assertEqual(await router.shared(1), "primary:1") + + 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) From 6d2ff694c323af568b415ecfd2586b407ae1fdab Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 17:21:09 +1000 Subject: [PATCH 2/5] =?UTF-8?q?no-mistakes(review):=20Add=20per-command=20?= =?UTF-8?q?primary=E2=86=92fallback=20failover=20to=20VehicleRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tesla_fleet_api.egg-info/PKG-INFO | 14 +-- tesla_fleet_api.egg-info/SOURCES.txt | 4 +- tesla_fleet_api.egg-info/requires.txt | 14 +-- tesla_fleet_api/tesla/vehicle/router.py | 65 ++++++------- tests/test_vehicle_router.py | 117 +++++++++++------------- 5 files changed, 100 insertions(+), 114 deletions(-) diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index 3c24f02..d5b08ae 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 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/router.py b/tesla_fleet_api/tesla/vehicle/router.py index d173c91..003df91 100644 --- a/tesla_fleet_api/tesla/vehicle/router.py +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -16,8 +16,8 @@ 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 derives a default from -# the primary instance (see ``_default_health``). +# 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]]] @@ -36,8 +36,8 @@ class VehicleRouter(Generic[PrimaryT, FallbackT]): :class:`VehicleFleet`/``TeslemetryVehicle`` fallback). Method calls are dispatched dynamically: - - Present and callable on *both* -> the health check decides: primary when - healthy, otherwise fallback. + - 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`. @@ -45,12 +45,18 @@ class VehicleRouter(Generic[PrimaryT, FallbackT]): 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. + The health check may be provided as a ``bool``, a sync callable, or an async - callable returning ``bool``. When omitted, a default is derived from the - primary that treats "can establish a Bluetooth connection" as healthy and - routes to the fallback on any connection failure. The default feature-detects - its health signal, so it is not hard-wired to Bluetooth: a primary without a - recognised connection signal is simply treated as always healthy. + 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. The class is deliberately unbound over its two type parameters so the same pattern can later wrap a pair of energy site classes. @@ -70,35 +76,16 @@ def __init__( self._fallback = fallback self._health = health - async def _default_health(self) -> bool: - """Derive health from the primary by feature-detecting a connection signal. + async def is_healthy(self) -> bool: + """Resolve an explicit health check to a bool. - For a Bluetooth primary this attempts ``connect_if_needed()`` and treats a - successful connection as healthy, returning ``False`` on any connection - failure so calls route to the fallback. A primary exposing only a - ``client.is_connected`` flag is judged by that flag. A primary with no - recognised signal is treated as healthy. + 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. """ - connect_if_needed = getattr(self._primary, "connect_if_needed", None) - if callable(connect_if_needed): - try: - await _maybe_await(connect_if_needed()) - return True - except Exception as e: # noqa: BLE001 - any connection failure -> fallback - LOGGER.debug("Primary health check failed, routing to fallback: %s", e) - return False - - client = getattr(self._primary, "client", None) - if client is not None: - return bool(getattr(client, "is_connected", False)) - - return True - - async def is_healthy(self) -> bool: - """Resolve the configured (or default) health check to a bool.""" health = self._health if health is None: - return await self._default_health() + return True if isinstance(health, bool): return health return bool(await _maybe_await(health())) @@ -109,12 +96,16 @@ def _dispatch( primary_attr: Callable[..., Any], fallback_attr: Callable[..., Any], ) -> Callable[..., Awaitable[Any]]: - """Build a health-gated async wrapper for a method on both instances.""" + """Build an async wrapper with per-command failover for a shared method.""" async def _routed(*args: Any, **kwargs: Any) -> Any: - if await self.is_healthy(): + 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)) - return await _maybe_await(fallback_attr(*args, **kwargs)) + except Exception 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}" diff --git a/tests/test_vehicle_router.py b/tests/test_vehicle_router.py index aeab383..6149200 100644 --- a/tests/test_vehicle_router.py +++ b/tests/test_vehicle_router.py @@ -1,4 +1,4 @@ -"""Unit tests for VehicleRouter health-gated dispatch and fall-through. +"""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. @@ -10,21 +10,17 @@ class _FakePrimary: - """A fake Bluetooth-style primary with a connect_if_needed health signal.""" + """A fake primary whose shared command can be made to raise on demand.""" - def __init__(self, *, can_connect: bool = True): + def __init__(self, *, fail: bool = False): self.vin = "PRIMARY_VIN" - self.can_connect = can_connect - self.connect_calls = 0 + self.fail = fail self.shared_calls = 0 - async def connect_if_needed(self) -> None: - self.connect_calls += 1 - if not self.can_connect: - raise ConnectionError("BLE unreachable") - async def shared(self, value: int) -> str: self.shared_calls += 1 + if self.fail: + raise ConnectionError("BLE command failed") return f"primary:{value}" async def primary_only(self) -> str: @@ -34,12 +30,15 @@ async def primary_only(self) -> str: class _FakeFallback: """A fake cloud-style fallback exposing the shared surface plus extras.""" - def __init__(self): + 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: @@ -49,8 +48,8 @@ async def fallback_only(self) -> str: class VehicleRouterTests(IsolatedAsyncioTestCase): """Behavioural tests for VehicleRouter.""" - async def test_primary_healthy_routes_to_primary(self): - primary = _FakePrimary(can_connect=True) + async def test_primary_succeeds_routes_to_primary(self): + primary = _FakePrimary() fallback = _FakeFallback() router: VehicleRouter[_FakePrimary, _FakeFallback] = VehicleRouter( primary, fallback @@ -61,33 +60,41 @@ async def test_primary_healthy_routes_to_primary(self): self.assertEqual(result, "primary:7") self.assertEqual(primary.shared_calls, 1) self.assertEqual(fallback.shared_calls, 0) - # Default health should have attempted a connection. - self.assertEqual(primary.connect_calls, 1) - async def test_primary_unhealthy_routes_to_fallback(self): - primary = _FakePrimary(can_connect=False) + 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, 0) + 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_method_missing_on_primary_falls_through(self): - primary = _FakePrimary(can_connect=True) + primary = _FakePrimary() fallback = _FakeFallback() router = VehicleRouter(primary, fallback) result = await router.fallback_only() self.assertEqual(result, "fallback_only") - # Pure fall-through: no health gate, so no connection attempt. - self.assertEqual(primary.connect_calls, 0) async def test_method_only_on_primary_calls_primary(self): - primary = _FakePrimary(can_connect=True) + primary = _FakePrimary() fallback = _FakeFallback() router = VehicleRouter(primary, fallback) @@ -105,38 +112,51 @@ 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(self): - primary = _FakePrimary(can_connect=False) + 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") - # Static bool health should not consult the connection signal. - self.assertEqual(primary.connect_calls, 0) + self.assertEqual(primary.shared_calls, 1) + self.assertEqual(fallback.shared_calls, 0) - async def test_health_check_bool_false(self): - primary = _FakePrimary(can_connect=True) + async def test_health_check_true_but_primary_raises_falls_back(self): + primary = _FakePrimary(fail=True) fallback = _FakeFallback() - router = VehicleRouter(primary, fallback, health=False) + 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(can_connect=True) + primary = _FakePrimary() fallback = _FakeFallback() state = {"healthy": False} router = VehicleRouter(primary, fallback, health=lambda: state["healthy"]) - self.assertEqual(await router.shared(3), "fallback:3") + self.assertEqual(await router.shared(4), "fallback:4") state["healthy"] = True - self.assertEqual(await router.shared(4), "primary:4") + self.assertEqual(await router.shared(5), "primary:5") async def test_health_check_async_callable(self): - primary = _FakePrimary(can_connect=True) + primary = _FakePrimary() fallback = _FakeFallback() async def _health() -> bool: @@ -144,35 +164,8 @@ async def _health() -> bool: router = VehicleRouter(primary, fallback, health=_health) - self.assertEqual(await router.shared(5), "fallback:5") - - async def test_default_health_client_is_connected(self): - """A primary without connect_if_needed uses client.is_connected.""" - - class _ClientPrimary: - def __init__(self, connected: bool): - self.client = type("C", (), {"is_connected": connected})() - - async def shared(self, value: int) -> str: - return f"primary:{value}" - - fallback = _FakeFallback() - - connected = VehicleRouter(_ClientPrimary(True), fallback) - self.assertEqual(await connected.shared(1), "primary:1") - - disconnected = VehicleRouter(_ClientPrimary(False), fallback) - self.assertEqual(await disconnected.shared(1), "fallback:1") - - async def test_default_health_no_signal_is_healthy(self): - """A primary with no recognised connection signal is treated healthy.""" - - class _PlainPrimary: - async def shared(self, value: int) -> str: - return f"primary:{value}" - - router = VehicleRouter(_PlainPrimary(), _FakeFallback()) - self.assertEqual(await router.shared(1), "primary:1") + self.assertEqual(await router.shared(6), "fallback:6") + self.assertEqual(primary.shared_calls, 0) async def test_primary_and_fallback_properties(self): primary = _FakePrimary() From 55899027bc8b990eff95f376afbfa0fa8c7fbce1 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 17:24:38 +1000 Subject: [PATCH 3/5] no-mistakes(review): Catch TeslaFleetError faults in VehicleRouter per-command failover --- tesla_fleet_api/tesla/vehicle/router.py | 3 ++- tests/test_vehicle_router.py | 33 ++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tesla_fleet_api/tesla/vehicle/router.py b/tesla_fleet_api/tesla/vehicle/router.py index 003df91..4577b69 100644 --- a/tesla_fleet_api/tesla/vehicle/router.py +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -11,6 +11,7 @@ ) from tesla_fleet_api.const import LOGGER +from tesla_fleet_api.exceptions import TeslaFleetError PrimaryT = TypeVar("PrimaryT") FallbackT = TypeVar("FallbackT") @@ -103,7 +104,7 @@ async def _routed(*args: Any, **kwargs: Any) -> Any: return await _maybe_await(fallback_attr(*args, **kwargs)) try: return await _maybe_await(primary_attr(*args, **kwargs)) - except Exception as e: # noqa: BLE001 - any primary failure -> fallback + 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)) diff --git a/tests/test_vehicle_router.py b/tests/test_vehicle_router.py index 6149200..2fb8eed 100644 --- a/tests/test_vehicle_router.py +++ b/tests/test_vehicle_router.py @@ -4,21 +4,26 @@ 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): + 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}" @@ -84,6 +89,32 @@ async def test_both_raise_propagates(self): 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() From 2661bdfd65aa5062aa5383073713ca9a30e48785 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 17:30:06 +1000 Subject: [PATCH 4/5] no-mistakes(document): Document VehicleRouter in README and architecture docs --- AGENTS.md | 2 ++ README.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) 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..0747179 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,40 @@ 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. + ### Teslemetry The `Teslemetry` class provides methods to interact with the Teslemetry service. Here's a basic example: From c7ff21d9ab1b68b41ee48514b46e0b78c888b232 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 17:37:01 +1000 Subject: [PATCH 5/5] no-mistakes(review): Document double-execution and dunder-proxy caveats in VehicleRouter --- README.md | 4 +++ tesla_fleet_api.egg-info/PKG-INFO | 39 +++++++++++++++++++++++++ tesla_fleet_api/tesla/vehicle/router.py | 21 +++++++++++++ 3 files changed, 64 insertions(+) diff --git a/README.md b/README.md index 0747179..ddaaca0 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,10 @@ 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 d5b08ae..e7caa3f 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -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/tesla/vehicle/router.py b/tesla_fleet_api/tesla/vehicle/router.py index 4577b69..312d6d9 100644 --- a/tesla_fleet_api/tesla/vehicle/router.py +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -52,6 +52,19 @@ class VehicleRouter(Generic[PrimaryT, FallbackT]): 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; @@ -59,6 +72,14 @@ class VehicleRouter(Generic[PrimaryT, FallbackT]): 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. """