Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,25 @@ in `__init__`; scope flags on `TeslaFleetApi.__init__` control which are built.
### Router (command side)

`Router` (`router/base.py`) is an entity-agnostic composition wrapper, not part
of the inheritance chain: `Router(primary, secondary, *more, health=None)` chains
backends sharing a method surface and dispatches each call down the chain with
per-command failover — first backend that has the method, retried on the next on
any exception, returning the first success (last error if all fail,
`AttributeError` if none has the method). Non-callable attributes resolve to the
first backend that has them.
of the inheritance chain: `Router(primary, secondary, *more, health=None,
on_error=None)` chains backends sharing a method surface and dispatches each
call down the chain with per-command failover — first backend that has the
method, retried on the next on any exception, returning the first success
(last error if all fail, `AttributeError` if none has the method).
Non-callable attributes resolve to the first backend that has them.

- The health check gates **only the primary**; the rest of the chain is reached
purely through per-command failover. There is deliberately no per-backend
health matrix.
- `on_error(exception, backend, method_name)` (sync or async) is called after
every dispatched call, success (`exception=None`, return ignored — the only
success hook) and failure alike; on failure (every per-command failover
exception except `BluetoothUnconfirmedCommand`) returning `False` stops
failover and re-raises immediately instead of trying the next backend,
letting a caller (e.g. Home Assistant reacting to a BLE key rejection via
`exceptions.is_key_rejected`) veto a retry it already knows will fail and run
its own side effect at the point of failure. Never fires for plain attribute
access, only for a dispatched callable.
- Failover can double-execute a non-idempotent command that failed mid-flight.
`BluetoothUnconfirmedCommand` is the one exception: it propagates without replay.

Expand Down Expand Up @@ -169,6 +178,11 @@ would not match. Sibling repos each carry their own copy rather than calling it.
`raise_for_status()` raises the right one. Signed-command faults have separate
hierarchies (`TeslaFleetInformationFault`, `TeslaFleetMessageFault`,
`SignedMessageInformationFault`, `WhitelistOperationStatus`).
`exceptions.is_key_rejected(exc)` is the positive allowlist of faults across
those hierarchies that specifically mean the vehicle didn't recognize/accept
our signing key (verified against each fault's proto enum meaning, not its
name) — e.g. excludes `TeslaFleetMessageFaultKeychainIsFull`, whose proto
meaning is "no room for another key", not "this key was rejected".

**All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately not
`Exception`.** A bare `except Exception` (e.g. a retry loop around BLE reads)
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,29 @@ async def main():
asyncio.run(main())
```

The constructor is `Router(primary, secondary, *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 except `BluetoothUnconfirmedCommand`, 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.
The constructor is `Router(primary, secondary, *more_backends, health=None, on_error=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 except `BluetoothUnconfirmedCommand`, 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.

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.

You can also pass `on_error` — a sync or async callable `(exception, backend, method_name) -> bool` — to hook into every dispatched call's outcome, success or failure. On a backend exception during failover (every one except `BluetoothUnconfirmedCommand`) returning `True` lets failover continue to the next backend as normal, while `False` stops it and re-raises that exception immediately, so a command already known to fail on the next backend never gets sent there. On a successful call it's called the same way with `exception=None` and its return value ignored — the only hook available to observe a dispatch succeeding, e.g. to clear a repair a prior failure raised. For example, treating a BLE key rejection as terminal instead of falling over to the cloud, and clearing the repair once a command succeeds again:

```python
from tesla_fleet_api.exceptions import is_key_rejected

def on_error(exc, backend, method_name):
if exc is None:
clear_repair(backend) # a dispatched call just succeeded
return True
if is_key_rejected(exc):
raise_repair(backend) # your own repair/notification logic
return False # don't send this command to the cloud too
return True

vehicle = VehicleRouter(primary, secondary, on_error=on_error)
```

`tesla_fleet_api.exceptions.is_key_rejected(exc)` reports whether a fault means the vehicle didn't recognize our signing key as paired/authorized (e.g. `NotOnWhitelistFault`), as opposed to any other signed-command fault.

`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
Expand Down
28 changes: 28 additions & 0 deletions tesla_fleet_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,34 @@ class WhitelistOperationLocalEntityAuthFailedCancelled(WhitelistOperationStatus)
]


# Every fault a vehicle can return that means it did not recognize/accept our
# signing key as authorized to issue commands, verified against each fault's
# proto enum meaning (``MessageFault_E``/``SignedMessage_information_E`` in
# tesla-protocol's vcsec/universal_message protos) rather than its name alone.
# ``TeslaFleetMessageFaultKeychainIsFull`` is deliberately excluded: its proto
# meaning is "no room to add another key", not "this key was rejected".
KEY_REJECTED_FAULTS: tuple[type[TeslaFleetError], ...] = (
NotOnWhitelistFault,
CouldNotRetrieveKeyFault,
SignedMessageInformationFaultNotOnWhitelist,
SignedMessageInformationFaultCouldNotRetrieveKey,
TeslaFleetMessageFaultUnknownKeyId,
TeslaFleetMessageFaultInactiveKey,
TeslaFleetMessageFaultInvalidKeyHandle,
)


def is_key_rejected(exc: BaseException) -> bool:
"""Whether ``exc`` means the vehicle rejected our signing key.

True for a fault meaning the vehicle does not recognize our key as
whitelisted/paired (or could not retrieve/validate it), false for every
other fault, including transport errors and faults about an unrelated
signed-command condition (e.g. a full keychain).
"""
return isinstance(exc, KEY_REJECTED_FAULTS)


async def raise_for_status(resp: aiohttp.ClientResponse) -> None:
"""Raise an exception if the response status code is >=400."""
# https://developer.tesla.com/docs/fleet-api#response-codes
Expand Down
3 changes: 2 additions & 1 deletion tesla_fleet_api/router/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Routing wrappers with per-command failover across backends."""

from tesla_fleet_api.router.base import HealthCheck, Router
from tesla_fleet_api.router.base import ErrorHandler, HealthCheck, Router
from tesla_fleet_api.router.vehicle import VehicleRouter
from tesla_fleet_api.router.energysite import (
EnergySiteRouter,
Expand All @@ -16,6 +16,7 @@
"VehicleRouter",
"EnergySiteRouter",
"HealthCheck",
"ErrorHandler",
"LOCAL_LIVE_STATUS_KEYS",
"LOCAL_SITE_INFO_KEYS",
"merge_local_into_cloud",
Expand Down
37 changes: 36 additions & 1 deletion tesla_fleet_api/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
# directly and fails over to the next backend on exception (no up-front probe).
HealthCheck = Union[bool, Callable[[], bool], Callable[[], Awaitable[bool]]]

# Called with (exception, backend, method_name) after every dispatched call.
# On failure (any backend exception during per-command failover except
# BluetoothUnconfirmedCommand) its return decides whether failover proceeds to
# the next backend (True) or stops and re-raises that exception immediately
# (False). On success it is called with exception=None and its return is
# ignored - this is the only way to observe a *successful* dispatch (e.g. to
# clear a repair raised by an earlier failure), since Router has no other
# success hook.
ErrorHandler = Callable[
[Union[BaseException, None], Any, str], Union[bool, Awaitable[bool]]
]


async def _maybe_await(value: Any) -> Any:
"""Await ``value`` if it is awaitable, otherwise return it unchanged."""
Expand Down Expand Up @@ -88,6 +100,20 @@ class Router(Generic[PrimaryT, SecondaryT]):
are always reached purely through per-command failover — there is deliberately
no per-backend health matrix.

An optional ``on_error`` handler is called ``(exception, backend, method_name)``
after every dispatched call, success or failure — it is the only hook into
dispatch outcomes, so it doubles as the success notification. On failure
(a backend raising during per-command failover; ``BluetoothUnconfirmedCommand``
excepted — it never reaches the handler) its return value, sync or async,
decides whether failover continues to the next backend (``True``) or stops
and re-raises that exception immediately (``False``), which lets a caller
both suppress a failover it knows would also fail and run side effects (e.g.
raising a repair) at the point of failure. On success it is called with
``exception=None`` and its return value is ignored — e.g. to clear a repair
raised by an earlier failure once a command goes through again. A handler
that itself raises propagates as-is, uncaught. The handler only ever fires
for a dispatched (callable) attribute, never for plain attribute access.

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 Router(...)`` does *not* enter a backend's async
Expand All @@ -104,18 +130,21 @@ class Router(Generic[PrimaryT, SecondaryT]):

_backends: tuple[Any, ...]
_health: HealthCheck | None
_on_error: ErrorHandler | None

def __init__(
self,
primary: PrimaryT,
secondary: SecondaryT,
*more_backends: Any,
health: HealthCheck | None = None,
on_error: ErrorHandler | None = None,
) -> None:
# The two-argument ``Router(primary, secondary, health=...)`` form is
# preserved exactly; additional positional backends extend the chain.
self._backends = (primary, secondary, *more_backends)
self._health = health
self._on_error = on_error

async def is_healthy(self) -> bool:
"""Resolve an explicit health check to a bool.
Expand Down Expand Up @@ -180,10 +209,16 @@ async def _routed(*args: Any, **kwargs: Any) -> Any:
type(e).__name__,
e,
)
if self._on_error is not None and not await _maybe_await(
self._on_error(e, backend, name)
):
raise
continue
LOGGER.debug(
"command=%s backend=%s result=success", name, type(backend).__name__
)
if self._on_error is not None:
await _maybe_await(self._on_error(None, backend, name))
return result
# The loop always runs at least once (``start`` only advances past
# the primary when a later backend remains), so a failure here means
Expand All @@ -199,7 +234,7 @@ 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 ("_backends", "_health"):
if name in ("_backends", "_health", "_on_error"):
raise AttributeError(name)

backends = self._backends
Expand Down
52 changes: 52 additions & 0 deletions tests/test_is_key_rejected.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Unit tests for exceptions.is_key_rejected."""

from unittest import TestCase

from tesla_fleet_api.exceptions import (
BluetoothTimeout,
CouldNotRetrieveKeyFault,
NotOnWhitelistFault,
SignedMessageInformationFaultCouldNotRetrieveKey,
SignedMessageInformationFaultNotOnWhitelist,
SignedMessageInformationFaultTimeExpired,
TeslaFleetMessageFaultInactiveKey,
TeslaFleetMessageFaultInvalidKeyHandle,
TeslaFleetMessageFaultKeychainIsFull,
TeslaFleetMessageFaultUnknownKeyId,
TimeExpiredFault,
is_key_rejected,
)


class IsKeyRejectedTests(TestCase):
def test_true_for_not_on_whitelist_fault(self):
self.assertTrue(is_key_rejected(NotOnWhitelistFault()))

def test_true_for_signed_message_not_on_whitelist(self):
self.assertTrue(is_key_rejected(SignedMessageInformationFaultNotOnWhitelist()))

def test_true_for_could_not_retrieve_key_faults(self):
self.assertTrue(is_key_rejected(CouldNotRetrieveKeyFault()))
self.assertTrue(
is_key_rejected(SignedMessageInformationFaultCouldNotRetrieveKey())
)

def test_true_for_unknown_key_id_and_inactive_key(self):
self.assertTrue(is_key_rejected(TeslaFleetMessageFaultUnknownKeyId()))
self.assertTrue(is_key_rejected(TeslaFleetMessageFaultInactiveKey()))

def test_true_for_invalid_key_handle(self):
self.assertTrue(is_key_rejected(TeslaFleetMessageFaultInvalidKeyHandle()))

def test_false_for_keychain_full(self):
# A full keychain means no room to add another key, not that this
# already-paired key was rejected.
self.assertFalse(is_key_rejected(TeslaFleetMessageFaultKeychainIsFull()))

def test_false_for_unrelated_faults(self):
self.assertFalse(is_key_rejected(TimeExpiredFault()))
self.assertFalse(is_key_rejected(SignedMessageInformationFaultTimeExpired()))

def test_false_for_transport_errors(self):
self.assertFalse(is_key_rejected(BluetoothTimeout()))
self.assertFalse(is_key_rejected(ConnectionError()))
Loading
Loading