From 0fcb483ec396a0a4f719234620b95c79e3771f7c Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 18:25:59 +1000 Subject: [PATCH 1/3] feat(router): generalize to N backends and add EnergySiteRouter Generalize the router from a fixed primary/fallback pair to an ordered list of N backends tried in order, keeping the 2-arg call fully backward compatible. Dispatch walks the chain, skips backends missing the method, fails over on any exception, and raises the last error only when every applicable backend fails (AttributeError when none has it). Health gates only the primary; the rest of the chain is reached via per-command failover (no per-backend health matrix). Introduce a neutral entity-agnostic `Router` base with thin `VehicleRouter` and new `EnergySiteRouter` subclasses. EnergySiteRouter wraps a duck-typed local EnergySite primary (e.g. aiopowerwall) with a Teslemetry cloud fallback. Export Router/VehicleRouter/EnergySiteRouter from the vehicle and tesla packages. Extend tests for N-way ordering/failover and EnergySite usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +- tesla_fleet_api/tesla/__init__.py | 7 +- tesla_fleet_api/tesla/vehicle/__init__.py | 4 +- tesla_fleet_api/tesla/vehicle/router.py | 214 ++++++++++++++-------- tests/test_vehicle_router.py | 143 ++++++++++++++- 5 files changed, 289 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 116cd37..2745b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,9 @@ 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. +`Router` (vehicle/router.py) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. The constructor is `Router(primary, fallback, *more_backends, health=None)` — the two-argument form is fully backward compatible. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary** (the first backend); the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Note the double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend. + +`VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses of `Router`. `VehicleRouter(bluetooth_primary, teslemetry_fallback)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) fallback; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. All three are exported from `tesla/vehicle/__init__.py` (and re-exported from `tesla/__init__.py`) but have no factory on the `Vehicles`/`EnergySites` collections. ### Vehicle Collections diff --git a/tesla_fleet_api/tesla/__init__.py b/tesla_fleet_api/tesla/__init__.py index 67c40f7..12ac647 100644 --- a/tesla_fleet_api/tesla/__init__.py +++ b/tesla_fleet_api/tesla/__init__.py @@ -7,7 +7,7 @@ from tesla_fleet_api.tesla.energysite import EnergySites, EnergySite from tesla_fleet_api.tesla.partner import Partner from tesla_fleet_api.tesla.user import User -from tesla_fleet_api.tesla.vehicle import Vehicles, VehiclesBluetooth, VehicleFleet, VehicleSigned, VehicleBluetooth, Vehicle +from tesla_fleet_api.tesla.vehicle import Vehicles, VehiclesBluetooth, VehicleFleet, VehicleSigned, VehicleBluetooth, Vehicle, Router, VehicleRouter, EnergySiteRouter __all__ = [ "TeslaFleetApi", @@ -16,6 +16,7 @@ "Charging", "EnergySites", "EnergySite", + "EnergySiteRouter", "Partner", "User", "Vehicles", @@ -23,5 +24,7 @@ "VehiclesBluetooth", "VehicleFleet", "VehicleSigned", - "VehicleBluetooth" + "VehicleBluetooth", + "Router", + "VehicleRouter" ] diff --git a/tesla_fleet_api/tesla/vehicle/__init__.py b/tesla_fleet_api/tesla/vehicle/__init__.py index 87df340..b724937 100644 --- a/tesla_fleet_api/tesla/vehicle/__init__.py +++ b/tesla_fleet_api/tesla/vehicle/__init__.py @@ -4,7 +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.router import Router, VehicleRouter, EnergySiteRouter from tesla_fleet_api.tesla.vehicle.vehicle import Vehicle __all__ = [ @@ -14,5 +14,7 @@ "VehicleFleet", "VehicleBluetooth", "VehicleSigned", + "Router", "VehicleRouter", + "EnergySiteRouter", ] diff --git a/tesla_fleet_api/tesla/vehicle/router.py b/tesla_fleet_api/tesla/vehicle/router.py index 312d6d9..99bbae7 100644 --- a/tesla_fleet_api/tesla/vehicle/router.py +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -18,7 +18,7 @@ # 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). +# directly and fails over to the next backend on exception (no up-front probe). HealthCheck = Union[bool, Callable[[], bool], Callable[[], Awaitable[bool]]] @@ -29,73 +29,82 @@ async def _maybe_await(value: Any) -> Any: return value -class VehicleRouter(Generic[PrimaryT, FallbackT]): - """Routes method calls to a primary instance when healthy, else a fallback. +class Router(Generic[PrimaryT, FallbackT]): + """Routes method calls across an ordered list of backends, tried in order. - 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: + Composes two or more instances that share a common method surface (e.g. a + :class:`VehicleBluetooth` primary and a cloud ``TeslemetryVehicle`` fallback, + or a local energy site and a cloud ``TeslemetryEnergySite`` fallback). The + first two backends are the required *primary* and *fallback*; any number of + additional backends may follow and are tried, in order, after them. 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`. + - Present and callable on one or more backends -> the first such backend (in + order) is attempted (the first backend subject to an optional health check) + with automatic failover down the chain to the remaining backends that also + have the method. + - Present on no backend -> :class:`AttributeError`. - Non-callable attributes (e.g. ``vin``) resolve to the primary's value when - present, otherwise the fallback's. + Non-callable attributes (e.g. ``vin``) resolve to the value of the first + backend that has them. - 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. + Dispatch to a callable performs *per-command* failover: the backends that + expose the method are attempted in order, and if one 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 next + backend that has it, with the same arguments. The error only propagates when + every applicable backend fails, in which case the last error is raised. .. warning:: - Because a failed primary call is replayed on the fallback, a + Because a failed call is replayed on the next backend, 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. + ``door_unlock``, ``charge_start``) that fails *mid-flight* — after a + backend may have already partially applied it — can be **double-executed** + (or executed more than once across a longer chain) when it is retried on + the next backend. 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 down the chain) + or call the underlying backends 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. + callable returning ``bool``. It gates **only the first backend** (the + primary): when an explicit check evaluates ``False`` the primary is skipped + entirely and the call routes to the remaining backends; when it evaluates + ``True`` the primary is attempted with the same fall-through-on-exception + behaviour. When omitted (the default) the primary is attempted directly with + fall-through-on-exception and no up-front probe. Backends after the primary + are always reached purely through per-command failover — there is deliberately + no per-backend health matrix. 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 + In particular ``async with Router(...)`` does *not* enter a backend'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`. + done by reaching through :attr:`primary` (or :attr:`backends`). - The class is deliberately unbound over its two type parameters so the same - pattern can later wrap a pair of energy site classes. + The class is deliberately unbound over its two type parameters and + entity-agnostic so the same pattern can wrap vehicles or energy sites; the + :class:`VehicleRouter` and :class:`EnergySiteRouter` subclasses are thin + entity-specific names over it. """ - _primary: PrimaryT - _fallback: FallbackT + _backends: tuple[Any, ...] _health: HealthCheck | None def __init__( self, primary: PrimaryT, fallback: FallbackT, + *more_backends: Any, health: HealthCheck | None = None, ): - self._primary = primary - self._fallback = fallback + # The two-argument ``Router(primary, fallback, health=...)`` form is + # preserved exactly; additional positional backends extend the chain. + self._backends = (primary, fallback, *more_backends) self._health = health async def is_healthy(self) -> bool: @@ -115,19 +124,44 @@ async def is_healthy(self) -> bool: def _dispatch( self, name: str, - primary_attr: Callable[..., Any], - fallback_attr: Callable[..., Any], + targets: list[tuple[Any, Callable[..., Any]]], ) -> Callable[..., Awaitable[Any]]: - """Build an async wrapper with per-command failover for a shared method.""" + """Build an async wrapper with per-command failover down ``targets``. + + ``targets`` is the ordered list of ``(backend, callable_attr)`` pairs for + every backend that exposes ``name`` as a callable. + """ 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)) + start = 0 + # The health check gates only the primary, and only when there is + # another backend to route to. ``targets[0]`` is the primary iff the + # primary itself exposes this method. + if ( + self._health is not None + and len(targets) > 1 + and targets[0][0] is self._backends[0] + and not await self.is_healthy() + ): + start = 1 + + last_exc: BaseException | None = None + for backend, attr in targets[start:]: + try: + return await _maybe_await(attr(*args, **kwargs)) + except (Exception, TeslaFleetError) as e: # noqa: BLE001 - any failure -> next backend + last_exc = e + LOGGER.debug( + "Backend %s call %r failed, routing to next backend: %s", + type(backend).__name__, + name, + e, + ) + # The loop always runs at least once (``start`` only advances past + # the primary when a later backend remains), so a failure here means + # every applicable backend raised. + assert last_exc is not None + raise last_exc _routed.__name__ = name _routed.__qualname__ = f"{type(self).__name__}.{name}" @@ -137,44 +171,68 @@ 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"): + if name in ("_backends", "_health"): raise AttributeError(name) - primary = self._primary - fallback = self._fallback - has_primary = hasattr(primary, name) - has_fallback = hasattr(fallback, name) + backends = self._backends + having = [b for b in backends if hasattr(b, name)] - if not has_primary and not has_fallback: + if not having: 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}" + f"{type(self).__name__!r} routes to none of its " + f"{len(backends)} backends " + f"({', '.join(type(b).__name__ for b in backends)}) for {name!r}" ) - primary_attr = getattr(primary, name) if has_primary else None - fallback_attr = getattr(fallback, name) if has_fallback else None + # Non-callable attributes resolve to the first backend that has them. + first_attr = getattr(having[0], name) + if not callable(first_attr): + return first_attr - if ( - has_primary - and has_fallback - and callable(primary_attr) - and callable(fallback_attr) - ): - return self._dispatch(name, primary_attr, fallback_attr) + targets = [ + (b, attr) + for b in having + if callable(attr := getattr(b, name)) + ] + return self._dispatch(name, targets) - # 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 backends(self) -> tuple[Any, ...]: + """The ordered backends calls are routed across.""" + return self._backends @property def primary(self) -> PrimaryT: """The primary instance calls are routed to when healthy.""" - return self._primary + return self._backends[0] @property def fallback(self) -> FallbackT: - """The fallback instance calls are routed to when the primary is unhealthy.""" - return self._fallback + """The first fallback instance, tried when the primary is unhealthy or fails.""" + return self._backends[1] + + +class VehicleRouter(Router[PrimaryT, FallbackT]): + """A :class:`Router` over vehicle instances. + + Pairs (or chains) a local primary — typically a :class:`VehicleBluetooth` — + with one or more cloud fallbacks (e.g. a ``TeslemetryVehicle``), routing each + command to the primary first and failing over down the chain. See + :class:`Router` for the full dispatch, failover, and health-check semantics. + """ + + +class EnergySiteRouter(Router[PrimaryT, FallbackT]): + """A :class:`Router` over energy-site instances. + + Pairs (or chains) a local primary — a duck-typed ``EnergySite``-shaped object + such as aiopowerwall's ``PowerwallEnergySite`` — with one or more cloud + fallbacks (e.g. a ``TeslemetryEnergySite``), routing each command to the local + site first and failing over down the chain. See :class:`Router` for the full + dispatch, failover, and health-check semantics. + + Example:: + + router = EnergySiteRouter(local_energysite, teslemetry_energysite) + await router.set_operation(...) # local first, cloud on failure + """ diff --git a/tests/test_vehicle_router.py b/tests/test_vehicle_router.py index 2fb8eed..3c458b8 100644 --- a/tests/test_vehicle_router.py +++ b/tests/test_vehicle_router.py @@ -8,7 +8,11 @@ from unittest import IsolatedAsyncioTestCase from tesla_fleet_api.exceptions import BluetoothTimeout -from tesla_fleet_api.tesla.vehicle.router import VehicleRouter +from tesla_fleet_api.tesla.vehicle.router import ( + EnergySiteRouter, + Router, + VehicleRouter, +) class _FakePrimary: @@ -205,3 +209,140 @@ async def test_primary_and_fallback_properties(self): self.assertIs(router.primary, primary) self.assertIs(router.fallback, fallback) + + +class _FakeBackend: + """A generic ordered backend for N-way routing tests. + + ``has`` controls whether the shared ``cmd`` method is present at all so a + backend can be skipped entirely by the router. + """ + + def __init__(self, tag: str, *, fail: bool = False, has: bool = True): + self.tag = tag + self.fail = fail + self.calls = 0 + if has: + self.cmd = self._cmd # type: ignore[assignment] + + async def _cmd(self, value: int) -> str: + self.calls += 1 + if self.fail: + raise ConnectionError(f"{self.tag} failed") + return f"{self.tag}:{value}" + + +class RouterNWayTests(IsolatedAsyncioTestCase): + """Ordered N-backend dispatch and failover behaviour.""" + + async def test_three_backends_routes_to_first(self): + a, b, c = _FakeBackend("a"), _FakeBackend("b"), _FakeBackend("c") + router = Router(a, b, c) + + self.assertEqual(await router.cmd(1), "a:1") + self.assertEqual((a.calls, b.calls, c.calls), (1, 0, 0)) + + async def test_skips_backend_missing_the_method(self): + # ``b`` lacks ``cmd`` entirely and must be skipped, not attempted. + a = _FakeBackend("a", fail=True) + b = _FakeBackend("b", has=False) + c = _FakeBackend("c") + router = Router(a, b, c) + + self.assertEqual(await router.cmd(2), "c:2") + self.assertEqual((a.calls, b.calls, c.calls), (1, 0, 1)) + + async def test_fails_over_through_multiple_backends_to_last(self): + a = _FakeBackend("a", fail=True) + b = _FakeBackend("b", fail=True) + c = _FakeBackend("c") + router = Router(a, b, c) + + self.assertEqual(await router.cmd(3), "c:3") + self.assertEqual((a.calls, b.calls, c.calls), (1, 1, 1)) + + async def test_all_fail_raises_last_error(self): + a = _FakeBackend("a", fail=True) + b = _FakeBackend("b", fail=True) + c = _FakeBackend("c", fail=True) + router = Router(a, b, c) + + with self.assertRaises(ConnectionError) as ctx: + await router.cmd(4) + + # The *last* backend's error propagates. + self.assertIn("c failed", str(ctx.exception)) + self.assertEqual((a.calls, b.calls, c.calls), (1, 1, 1)) + + async def test_none_have_method_raises_attribute_error(self): + router = Router( + _FakeBackend("a", has=False), + _FakeBackend("b", has=False), + _FakeBackend("c", has=False), + ) + + with self.assertRaises(AttributeError): + router.cmd # noqa: B018 - triggers __getattr__ + + async def test_health_false_skips_only_primary_in_chain(self): + # Health gates the primary only; the rest of the chain is reached via + # ordinary per-command failover. + a, b, c = _FakeBackend("a"), _FakeBackend("b", fail=True), _FakeBackend("c") + router = Router(a, b, c, health=False) + + self.assertEqual(await router.cmd(5), "c:5") + # Primary skipped entirely; b attempted and failed; c succeeded. + self.assertEqual((a.calls, b.calls, c.calls), (0, 1, 1)) + + async def test_backends_property_reports_full_chain(self): + a, b, c = _FakeBackend("a"), _FakeBackend("b"), _FakeBackend("c") + router = Router(a, b, c) + + self.assertEqual(router.backends, (a, b, c)) + self.assertIs(router.primary, a) + self.assertIs(router.fallback, b) + + +class _FakeEnergySite: + """A minimal EnergySite-shaped object for EnergySiteRouter tests.""" + + def __init__(self, tag: str, site_id: int, *, fail: bool = False): + self.tag = tag + self.energy_site_id = site_id + self.fail = fail + self.calls = 0 + + async def set_operation(self, mode: str) -> str: + self.calls += 1 + if self.fail: + raise ConnectionError(f"{self.tag} unreachable") + return f"{self.tag}:{mode}" + + +class EnergySiteRouterTests(IsolatedAsyncioTestCase): + """EnergySiteRouter over a local primary and a cloud fallback.""" + + async def test_routes_to_local_primary(self): + local = _FakeEnergySite("local", 1) + cloud = _FakeEnergySite("cloud", 2) + router: EnergySiteRouter[_FakeEnergySite, _FakeEnergySite] = EnergySiteRouter( + local, cloud + ) + + self.assertEqual(await router.set_operation("self_consumption"), "local:self_consumption") + self.assertEqual((local.calls, cloud.calls), (1, 0)) + + async def test_falls_back_to_cloud(self): + local = _FakeEnergySite("local", 1, fail=True) + cloud = _FakeEnergySite("cloud", 2) + router = EnergySiteRouter(local, cloud) + + self.assertEqual(await router.set_operation("backup"), "cloud:backup") + self.assertEqual((local.calls, cloud.calls), (1, 1)) + + async def test_non_callable_attribute_prefers_local(self): + local = _FakeEnergySite("local", 111) + cloud = _FakeEnergySite("cloud", 222) + router = EnergySiteRouter(local, cloud) + + self.assertEqual(router.energy_site_id, 111) From 9d071170023a0abb67149a6a7ae5bbc28d47fcba Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 18:33:16 +1000 Subject: [PATCH 2/3] no-mistakes(document): docs: sync README router section with N-backend Router and EnergySiteRouter --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ddaaca0..015c30e 100644 --- a/README.md +++ b/README.md @@ -8,7 +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) +- Routing and failover across backends for vehicles and energy sites (e.g. Bluetooth/local primary, cloud fallback) - Teslemetry integration - Tessie integration @@ -168,7 +168,7 @@ For more detailed examples, see [Bluetooth for Vehicles](docs/bluetooth_vehicles ### 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: +The `Router` class composes an ordered list of two-or-more backends that share a common method surface and dispatches each method call down the chain, automatically failing over on error. `VehicleRouter` and `EnergySiteRouter` are thin entity-specific subclasses. 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 @@ -198,11 +198,24 @@ async def main(): 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. +The constructor is `Router(primary, fallback, *more_backends, health=None)`; the two-argument form shown above is fully backward compatible, and any number of extra backends may follow to extend the chain. Each call is tried on the first backend that has the method and, on any exception, retried on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails). Non-callable attributes (e.g. `vin`) resolve to the first backend that has them. -> **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. +By default the router attempts the primary and fails over 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 skip straight to the rest of the chain. The health check gates **only the primary** (the first backend); later backends are reached purely through per-command failover. + +`EnergySiteRouter` follows the same pattern for energy sites, pairing a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback: + +```python +from tesla_fleet_api.tesla.vehicle.router import EnergySiteRouter + +router = EnergySiteRouter(local_energysite, teslemetry_energysite) +await router.set_operation(...) # local first, cloud on failure +``` + +`Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.tesla.vehicle.router` (and from `tesla_fleet_api.tesla`). + +> **Warning:** Because a failed call is replayed on the next backend, a non-idempotent command (e.g. `honk_horn`, `actuate_trunk`, `door_unlock`, `charge_start`) that fails _mid-flight_ — after a backend may have already partially applied it — can be **double-executed** (or executed more than once across a longer chain) when it is retried on the next backend. 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 backends 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`. +> Dispatch is implemented via `__getattr__`, which does not proxy dunder methods, so `async with Router(...)` does **not** manage a backend's BLE connection lifecycle (`__aenter__`/`__aexit__`). Commands still auto-connect on send; for explicit connect/disconnect reach through `router.primary` (or `router.backends`). ### Teslemetry From a5c1d236d5b7c3fa72f8fa79ee9cc79d375b6bda Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 18:34:14 +1000 Subject: [PATCH 3/3] no-mistakes(lint): apply ruff formatting to router and init files --- tesla_fleet_api/tesla/__init__.py | 14 ++++++++++++-- tesla_fleet_api/tesla/vehicle/router.py | 6 +----- tests/test_vehicle_router.py | 4 +++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/tesla_fleet_api/tesla/__init__.py b/tesla_fleet_api/tesla/__init__.py index 12ac647..80c2db6 100644 --- a/tesla_fleet_api/tesla/__init__.py +++ b/tesla_fleet_api/tesla/__init__.py @@ -7,7 +7,17 @@ from tesla_fleet_api.tesla.energysite import EnergySites, EnergySite from tesla_fleet_api.tesla.partner import Partner from tesla_fleet_api.tesla.user import User -from tesla_fleet_api.tesla.vehicle import Vehicles, VehiclesBluetooth, VehicleFleet, VehicleSigned, VehicleBluetooth, Vehicle, Router, VehicleRouter, EnergySiteRouter +from tesla_fleet_api.tesla.vehicle import ( + Vehicles, + VehiclesBluetooth, + VehicleFleet, + VehicleSigned, + VehicleBluetooth, + Vehicle, + Router, + VehicleRouter, + EnergySiteRouter, +) __all__ = [ "TeslaFleetApi", @@ -26,5 +36,5 @@ "VehicleSigned", "VehicleBluetooth", "Router", - "VehicleRouter" + "VehicleRouter", ] diff --git a/tesla_fleet_api/tesla/vehicle/router.py b/tesla_fleet_api/tesla/vehicle/router.py index 99bbae7..d86522f 100644 --- a/tesla_fleet_api/tesla/vehicle/router.py +++ b/tesla_fleet_api/tesla/vehicle/router.py @@ -189,11 +189,7 @@ def __getattr__(self, name: str) -> Any: if not callable(first_attr): return first_attr - targets = [ - (b, attr) - for b in having - if callable(attr := getattr(b, name)) - ] + targets = [(b, attr) for b in having if callable(attr := getattr(b, name))] return self._dispatch(name, targets) @property diff --git a/tests/test_vehicle_router.py b/tests/test_vehicle_router.py index 3c458b8..c2639fe 100644 --- a/tests/test_vehicle_router.py +++ b/tests/test_vehicle_router.py @@ -329,7 +329,9 @@ async def test_routes_to_local_primary(self): local, cloud ) - self.assertEqual(await router.set_operation("self_consumption"), "local:self_consumption") + self.assertEqual( + await router.set_operation("self_consumption"), "local:self_consumption" + ) self.assertEqual((local.calls, cloud.calls), (1, 0)) async def test_falls_back_to_cloud(self):