feat(router): generalize router to N backends and add EnergySiteRouter - #38
Merged
Conversation
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) <noreply@anthropic.com>
…d Router and EnergySiteRouter
Bre77
added a commit
that referenced
this pull request
Jul 2, 2026
…router.py (#39) * 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) <noreply@anthropic.com> * no-mistakes(document): docs already in sync with router relocation --------- Co-authored-by: firstmate crewmate <crewmate@firstmate.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Extend the library's router in two ways while preserving full backward compatibility. (1) Generalize the fixed primary/fallback pair router into an entity-agnostic ordered list of N backends (2, 3, 4...) tried in order — keeping the exact 2-arg 'Router(primary, fallback, health=None)' call working unchanged, adding extra backends via variadic '*more_backends' (health made keyword-only, which is safe: no positional health usage existed anywhere). Dispatch walks backends in order, skips any missing the attribute, attempts the first that has it, fails over on ANY exception to the next that has it, returns first success, raises the last error only when all applicable backends fail, and AttributeError when none has it; non-callable attributes resolve to the first backend that has them. Deliberate complexity guard honored per the task: the health check gates ONLY the primary (first backend) exactly as before — there is intentionally NO per-backend health matrix; the rest of the chain is reached purely via per-command failover. The documented double-execution warning for non-idempotent commands is preserved and generalized to the whole chain. (2) Make the router officially usable for energy sites: renamed the implementation class to a neutral 'Router(Generic[PrimaryT, FallbackT])' base and added two thin entity-specific subclasses — 'VehicleRouter' (unchanged public name, kept for backward compat) and new 'EnergySiteRouter'. EnergySiteRouter(local_energysite, teslemetry_energysite) routes commands to a duck-typed local EnergySite-shaped primary (e.g. aiopowerwall's PowerwallEnergySite — deliberately NO aiopowerwall dependency added, it's duck-typed) first and falls back to the Teslemetry cloud EnergySite, same pattern as vehicles. Exported Router/VehicleRouter/EnergySiteRouter from tesla/vehicle/init.py and re-exported from tesla/init.py. Extended tests/test_vehicle_router.py (all backends mocked, no hardware/network) covering: unchanged 2-arg API and health behavior; N-backend (3+) ordering, skip-missing, multi-hop failover to last, all-fail-raises-last, AttributeError-when-none; and EnergySiteRouter over two mocked EnergySite-shaped objects routing and falling back. Updated AGENTS.md/CLAUDE.md router description. Full suite (36 tests), pyright strict, and ruff all pass locally.
What Changed
Router(Generic[PrimaryT, FallbackT])base with the signatureRouter(primary, fallback, *more_backends, health=None), dispatching each call down an ordered list of N backends: skip backends missing the attribute, attempt the first that has it, fail over on any exception to the next, return the first success, raise the last error only if all applicable backends fail, andAttributeErrorif none has it. The 2-arg call and health-gates-only-the-primary behavior are preserved (healthis now keyword-only).VehicleRouter(pairs aVehicleBluetoothprimary with a cloud fallback) and newEnergySiteRouter(routes to a duck-typed localEnergySite-shaped primary, then falls back to aTeslemetryEnergySite, with no new dependency). All three are exported fromtesla/vehicle/__init__.pyand re-exported fromtesla/__init__.py.tests/test_vehicle_router.py(25 router tests) covering 2-arg backward compatibility and health behavior, N-backend ordering/skip-missing/multi-hop failover/all-fail-raises-last/AttributeError-when-none, andEnergySiteRouterrouting and fallback; updatedAGENTS.md,CLAUDE.md, andREADME.mdto document the N-backend router andEnergySiteRouter.Risk Assessment
✅ Low: The change is a well-bounded, backward-compatible generalization with correct failover/health-gate logic, no broken call sites, and thorough new test coverage; the only observation is a trivial non-functional redundancy.
Testing
Ran the existing baseline pytest suite (36 tests, all passing including the 25 router tests covering the 2-arg API, N-backend ordering/skip/failover, all-fail and none-have-method cases, health gating, and EnergySiteRouter fallback). Since this is a library change with no UI surface, I additionally authored and ran an end-to-end demo driving the public
Router/VehicleRouter/EnergySiteRouterclasses exactly as a consumer would compose them, capturing a CLI transcript that shows each intended routing behavior working. No visual artifact applies (no rendered end-user surface exists for a Python routing class). No failures or setup issues.Evidence: End-to-end routing demo transcript
1. VehicleRouter(primary, fallback): primary honk -> 'honked via BLE-primary'; vin -> 'VIN-BLE-primary'; primary down -> 'honked via Teslemetry-cloud' 2. Router(a,b,c): A down + B missing method + C ok -> 'honked via backend-C'; all down -> raises last error 'C: BLE/transport failure'; none has method -> AttributeError 3. Router(a,b,c, health=False): A skipped, B fails, C ok -> 'honked via C' 4. EnergySiteRouter(local, cloud): local -> 'PowerwallLocal set operation -> self_consumption'; energy_site_id -> 111; local down -> 'TeslemetryCloud set operation -> backup'Evidence: Demo script (public-API composition)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
tesla_fleet_api/tesla/vehicle/router.py:192- In getattr (router.py:188-196), getattr(having[0], name) is evaluated as first_attr and then re-evaluated inside the targets comprehension's walrus (getattr(b, name) for b in having). Combined with the hasattr scan on line 178, the first backend's attribute getter runs three times per lookup (others twice). For plain bound methods this is harmless, but a backend exposing the routed name as a @Property with side effects or nontrivial cost would fire it repeatedly. Reusing first_attr for having[0] would remove the extra call.✅ **Test** - passed
✅ No issues found.
uv run pytest tests/test_vehicle_router.py -v— 25 router tests passuv run pytest tests -q— full suite: 36 passed, 8 subtests passedVerifiedRouter/VehicleRouter/EnergySiteRouterexport from bothtesla_fleet_api.teslaandtesla_fleet_api.tesla.vehicle, and constructor signature(primary, fallback, *more_backends, health=None)preserves the 2-arg formRandemo.pyend-to-end exercising 2-arg backcompat, 3-backend ordered failover, skip-missing-method, all-fail-raises-last, AttributeError-when-none, health=False gating only the primary, and EnergySiteRouter local→cloud fallback✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.