From 218d2c5c9850ba452dfc98214f53c041fca7f0f4 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 20:36:06 +1000 Subject: [PATCH 1/2] refactor(router): move Router module out of vehicle/ to tesla/router.py The router module exports the entity-agnostic Router base plus the VehicleRouter and EnergySiteRouter peers, so it is not vehicle-specific. Relocate it next to energysite.py and fix every import site. - git mv tesla/vehicle/router.py -> tesla/router.py (logic unchanged) - git mv tests/test_vehicle_router.py -> tests/test_router.py (now also covers energy-site and N-way routing); broaden its module docstring - tesla/__init__.py: import the three names from tesla.router; __all__ still exports all three (top-level public API preserved) - tesla/vehicle/__init__.py: drop the router re-exports - README.md / AGENTS.md: point docs at tesla.router Clean break, no back-compat shim: these classes are unreleased (landed in #37/#38, after v1.4.7), so no published artifact exposed the old tesla.vehicle.router path. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 ++-- README.md | 6 +++--- tesla_fleet_api/tesla/__init__.py | 4 +--- tesla_fleet_api/tesla/{vehicle => }/router.py | 0 tesla_fleet_api/tesla/vehicle/__init__.py | 4 ---- tests/{test_vehicle_router.py => test_router.py} | 9 +++++---- 6 files changed, 11 insertions(+), 16 deletions(-) rename tesla_fleet_api/tesla/{vehicle => }/router.py (100%) rename tests/{test_vehicle_router.py => test_router.py} (97%) diff --git a/AGENTS.md b/AGENTS.md index 2745b21..a471e33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +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. -`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. +`Router` (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. +`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 live in `tesla/router.py` and are re-exported from `tesla/__init__.py` (importable as `tesla_fleet_api.tesla.Router` etc.) but have no factory on the `Vehicles`/`EnergySites` collections. ### Vehicle Collections diff --git a/README.md b/README.md index 015c30e..cc97503 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ The `Router` class composes an ordered list of two-or-more backends that share a import asyncio import aiohttp from tesla_fleet_api import TeslaBluetooth, Teslemetry -from tesla_fleet_api.tesla.vehicle.router import VehicleRouter +from tesla_fleet_api.tesla.router import VehicleRouter from tesla_fleet_api.exceptions import TeslaFleetError async def main(): @@ -205,13 +205,13 @@ By default the router attempts the primary and fails over on any error, with no `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 +from tesla_fleet_api.tesla.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`). +`Router`, `VehicleRouter`, and `EnergySiteRouter` are all importable from `tesla_fleet_api.tesla.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. > diff --git a/tesla_fleet_api/tesla/__init__.py b/tesla_fleet_api/tesla/__init__.py index 80c2db6..091c2e5 100644 --- a/tesla_fleet_api/tesla/__init__.py +++ b/tesla_fleet_api/tesla/__init__.py @@ -6,6 +6,7 @@ from tesla_fleet_api.tesla.charging import Charging from tesla_fleet_api.tesla.energysite import EnergySites, EnergySite from tesla_fleet_api.tesla.partner import Partner +from tesla_fleet_api.tesla.router import Router, VehicleRouter, EnergySiteRouter from tesla_fleet_api.tesla.user import User from tesla_fleet_api.tesla.vehicle import ( Vehicles, @@ -14,9 +15,6 @@ VehicleSigned, VehicleBluetooth, Vehicle, - Router, - VehicleRouter, - EnergySiteRouter, ) __all__ = [ diff --git a/tesla_fleet_api/tesla/vehicle/router.py b/tesla_fleet_api/tesla/router.py similarity index 100% rename from tesla_fleet_api/tesla/vehicle/router.py rename to tesla_fleet_api/tesla/router.py diff --git a/tesla_fleet_api/tesla/vehicle/__init__.py b/tesla_fleet_api/tesla/vehicle/__init__.py index b724937..4f1b223 100644 --- a/tesla_fleet_api/tesla/vehicle/__init__.py +++ b/tesla_fleet_api/tesla/vehicle/__init__.py @@ -4,7 +4,6 @@ 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 Router, VehicleRouter, EnergySiteRouter from tesla_fleet_api.tesla.vehicle.vehicle import Vehicle __all__ = [ @@ -14,7 +13,4 @@ "VehicleFleet", "VehicleBluetooth", "VehicleSigned", - "Router", - "VehicleRouter", - "EnergySiteRouter", ] diff --git a/tests/test_vehicle_router.py b/tests/test_router.py similarity index 97% rename from tests/test_vehicle_router.py rename to tests/test_router.py index c2639fe..85e32ac 100644 --- a/tests/test_vehicle_router.py +++ b/tests/test_router.py @@ -1,14 +1,15 @@ -"""Unit tests for VehicleRouter per-command failover and fall-through. +"""Unit tests for the Router family (Router, VehicleRouter, EnergySiteRouter). -These use plain fakes for the two composed vehicle instances so no real BLE -hardware or network access is required. +Covers per-command failover and fall-through for vehicle routing, N-way (>2) +backend chains, and energy-site routing. These use plain fakes for the composed +backend 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 ( +from tesla_fleet_api.tesla.router import ( EnergySiteRouter, Router, VehicleRouter, From 93885b8ce78b88b2b54b569c4c8b855cc64fcbc2 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 2 Jul 2026 20:41:01 +1000 Subject: [PATCH 2/2] no-mistakes(document): docs already in sync with router relocation --- tesla_fleet_api.egg-info/PKG-INFO | 25 +++++++++++++++++++------ tesla_fleet_api.egg-info/SOURCES.txt | 6 +++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/tesla_fleet_api.egg-info/PKG-INFO b/tesla_fleet_api.egg-info/PKG-INFO index e7caa3f..d94af44 100644 --- a/tesla_fleet_api.egg-info/PKG-INFO +++ b/tesla_fleet_api.egg-info/PKG-INFO @@ -30,7 +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) +- Routing and failover across backends for vehicles and energy sites (e.g. Bluetooth/local primary, cloud fallback) - Teslemetry integration - Tessie integration @@ -190,13 +190,13 @@ 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 import aiohttp from tesla_fleet_api import TeslaBluetooth, Teslemetry -from tesla_fleet_api.tesla.vehicle.router import VehicleRouter +from tesla_fleet_api.tesla.router import VehicleRouter from tesla_fleet_api.exceptions import TeslaFleetError async def main(): @@ -220,11 +220,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.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.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 diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index 6b52ff8..95cc502 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -17,13 +17,13 @@ tesla_fleet_api/tesla/energysite.py tesla_fleet_api/tesla/fleet.py tesla_fleet_api/tesla/oauth.py tesla_fleet_api/tesla/partner.py +tesla_fleet_api/tesla/router.py tesla_fleet_api/tesla/tesla.py tesla_fleet_api/tesla/user.py 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 @@ -55,5 +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 -tests/test_vehicle_router.py \ No newline at end of file +tests/test_router.py +tests/test_tessie_vehicle_params.py \ No newline at end of file