Skip to content

feat(router): generalize router to N backends and add EnergySiteRouter - #38

Merged
Bre77 merged 3 commits into
mainfrom
fm/energy-router-r4
Jul 2, 2026
Merged

feat(router): generalize router to N backends and add EnergySiteRouter#38
Bre77 merged 3 commits into
mainfrom
fm/energy-router-r4

Conversation

@Bre77

@Bre77 Bre77 commented Jul 2, 2026

Copy link
Copy Markdown
Member

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

  • Generalized the vehicle router from a fixed primary/fallback pair into an entity-agnostic Router(Generic[PrimaryT, FallbackT]) base with the signature Router(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, and AttributeError if none has it. The 2-arg call and health-gates-only-the-primary behavior are preserved (health is now keyword-only).
  • Added thin entity-specific subclasses: VehicleRouter (pairs a VehicleBluetooth primary with a cloud fallback) and new EnergySiteRouter (routes to a duck-typed local EnergySite-shaped primary, then falls back to a TeslemetryEnergySite, with no new dependency). All three are exported from tesla/vehicle/__init__.py and re-exported from tesla/__init__.py.
  • Extended 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, and EnergySiteRouter routing and fallback; updated AGENTS.md, CLAUDE.md, and README.md to document the N-backend router and EnergySiteRouter.

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/EnergySiteRouter classes 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'

======================================================================
1. Backward-compatible 2-arg VehicleRouter(primary, fallback)
======================================================================
  primary healthy  -> honk_horn() = 'honked via BLE-primary'
  non-callable vin resolves to primary -> 'VIN-BLE-primary'
  primary down     -> honk_horn() fails over = 'honked via Teslemetry-cloud'

======================================================================
2. N-backend ordered chain (3 backends), multi-hop failover
======================================================================
  A down, B lacks method, C ok -> 'honked via backend-C'
  chain length reported by .backends = 3

  all three down -> last error propagates:
    raised: C: BLE/transport failure

  none has the method -> AttributeError:
    raised: AttributeError('Router' routes to none of its 2 backends (Vehicle, Vehicle) for 'nonexistent_command')

======================================================================
3. Health check gates ONLY the primary (first backend)
======================================================================
  health=False -> A skipped, B fails, C ok -> 'honked via C'

======================================================================
4. EnergySiteRouter: local primary, Teslemetry cloud fallback
======================================================================
  local healthy -> 'PowerwallLocal set operation -> self_consumption'
  energy_site_id resolves to local -> 111
  local down    -> falls back to cloud = 'TeslemetryCloud set operation -> backup'
Evidence: Demo script (public-API composition)
"""End-to-end demonstration of the generalized Router + EnergySiteRouter.

Uses in-process fakes standing in for a BLE VehicleBluetooth primary, a
TeslemetryVehicle cloud fallback, and EnergySite-shaped objects — exactly how a
consumer of the public API would compose them.
"""
import asyncio
from tesla_fleet_api.tesla import Router, VehicleRouter, EnergySiteRouter


class Vehicle:
    def __init__(self, name, *, down=False, has_cmd=True):
        self.name = name
        self.vin = f"VIN-{name}"
        self.down = down
        if has_cmd:
            self.honk_horn = self._honk

    async def _honk(self):
        if self.down:
            raise ConnectionError(f"{self.name}: BLE/transport failure")
        return f"honked via {self.name}"


class EnergySite:
    def __init__(self, name, site_id, *, down=False):
        self.name = name
        self.energy_site_id = site_id
        self.down = down

    async def set_operation(self, mode):
        if self.down:
            raise ConnectionError(f"{self.name}: unreachable")
        return f"{self.name} set operation -> {mode}"


async def main():
    print("=" * 70)
    print("1. Backward-compatible 2-arg VehicleRouter(primary, fallback)")
    print("=" * 70)
    ble, cloud = Vehicle("BLE-primary"), Vehicle("Teslemetry-cloud")
    r = VehicleRouter(ble, cloud)
    print(f"  primary healthy  -> honk_horn() = {await r.honk_horn()!r}")
    print(f"  non-callable vin resolves to primary -> {r.vin!r}")

    ble_down = Vehicle("BLE-primary", down=True)
    r = VehicleRouter(ble_down, cloud)
    print(f"  primary down     -> honk_horn() fails over = {await r.honk_horn()!r}")

    print()
    print("=" * 70)
    print("2. N-backend ordered chain (3 backends), multi-hop failover")
    print("=" * 70)
    a = Vehicle("backend-A", down=True)
    b = Vehicle("backend-B", has_cmd=False)   # lacks honk_horn entirely -> skipped
    c = Vehicle("backend-C")                  # succeeds
    r = Router(a, b, c)
    print(f"  A down, B lacks method, C ok -> {await r.honk_horn()!r}")
    print(f"  chain length reported by .backends = {len(r.backends)}")

    print()
    print("  all three down -> last error propagates:")
    r = Router(Vehicle("A", down=True), Vehicle("B", down=True), Vehicle("C", down=True))
    try:
        await r.honk_horn()
    except ConnectionError as e:
        print(f"    raised: {e}")

    print()
    print("  none has the method -> AttributeError:")
    r = Router(Vehicle("A", has_cmd=False), Vehicle("B", has_cmd=False))
    try:
        r.nonexistent_command
    except AttributeError as e:
        print(f"    raised: AttributeError({e})")

    print()
    print("=" * 70)
    print("3. Health check gates ONLY the primary (first backend)")
    print("=" * 70)
    a2, b2, c2 = Vehicle("A"), Vehicle("B", down=True), Vehicle("C")
    r = Router(a2, b2, c2, health=False)   # primary skipped, chain via failover
    print(f"  health=False -> A skipped, B fails, C ok -> {await r.honk_horn()!r}")

    print()
    print("=" * 70)
    print("4. EnergySiteRouter: local primary, Teslemetry cloud fallback")
    print("=" * 70)
    local, cloudsite = EnergySite("PowerwallLocal", 111), EnergySite("TeslemetryCloud", 222)
    er = EnergySiteRouter(local, cloudsite)
    print(f"  local healthy -> {await er.set_operation('self_consumption')!r}")
    print(f"  energy_site_id resolves to local -> {er.energy_site_id}")

    er = EnergySiteRouter(EnergySite("PowerwallLocal", 111, down=True), cloudsite)
    print(f"  local down    -> falls back to cloud = {await er.set_operation('backup')!r}")


asyncio.run(main())

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ 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 pass
  • uv run pytest tests -q — full suite: 36 passed, 8 subtests passed
  • Verified Router/VehicleRouter/EnergySiteRouter export from both tesla_fleet_api.tesla and tesla_fleet_api.tesla.vehicle, and constructor signature (primary, fallback, *more_backends, health=None) preserves the 2-arg form
  • Ran demo.py end-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.

firstmate crewmate and others added 3 commits July 2, 2026 18:25
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>
@Bre77
Bre77 merged commit 54813c5 into main Jul 2, 2026
3 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant