Skip to content

feat(router): add on_error handler for per-command failover - #150

Merged
Bre77 merged 2 commits into
mainfrom
fm/tfa-router-error-handler
Sep 14, 2026
Merged

Bre77 merged 2 commits into
mainfrom
fm/tfa-router-error-handler

Conversation

@Bre77

@Bre77 Bre77 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Intent

Captain 2026-09-14, verbatim, about the Home Assistant PR home-assistant/core#181997 which today wraps VehicleBluetooth in a _KeyRejectionWatcher proxy to catch NotOnWhitelistFault and raise a repair: "i suspect this wrapper isnt going to be accepted and well need a way to pass an error handler into the Bluetooth vehicle class, or into the vehicle router (probably cleaner) so we can actually catch these. This error habdler can probably return a Boolean if the command should go to the second method or not, enabling the ability the prevent a command going to cloud if we actually know it would fail, and to inject the repair handler."

Standing rule he applies: library-first - machinery lives in tesla-fleet-api, Home Assistant keeps only platform glue.

Outcome: Router (tesla_fleet_api/router/base.py, and therefore VehicleRouter and EnergySiteRouter) accepts an optional error handler. When a backend raises during per-command failover, the router calls the handler with what happened and the handler's boolean decides whether the command proceeds to the next backend; a False stops failover and re-raises that backend's exception. Home Assistant will pass a handler that raises its repair on a key rejection and lets the command fail over to the cloud, which signs with its own key; it returns False only where the cloud path is known to fail too, so such a command is never sent.

Additions from the Home Assistant side, folded into the same PR: (a) the handler must also be told about primary SUCCESS so HA can clear a repair on the next good Bluetooth command - implemented by calling the same handler with exception=None on a successful dispatch (return value ignored). (b) export a predicate function named is_key_rejected taking one exception and returning bool, in tesla_fleet_api.exceptions, that says which faults mean the vehicle rejected our key: NotOnWhitelistFault plus the keychain / unknown-key / inactive-key message faults and CouldNotRetrieveKeyFault where that is what they mean - verified against proto fault meanings, not guessed. (c) the hook fires only for dispatched calls, never for attribute access such as listen_* seams.

What Changed

  • Router.__init__ (tesla_fleet_api/router/base.py) accepts an optional on_error: ErrorHandler callback (new ErrorHandler type, re-exported from tesla_fleet_api/router/__init__.py). _dispatch calls it (exception, backend, method_name) after every dispatched call: on failure during per-command failover its (possibly async) return value decides whether to continue to the next backend (True) or stop and re-raise immediately (False); on success it is called with exception=None (return ignored) so a caller can clear state set by an earlier failure. BluetoothUnconfirmedCommand still bypasses the handler and re-raises without failover, unchanged.
  • Added is_key_rejected(exc) to tesla_fleet_api/exceptions.py, a predicate over a new KEY_REJECTED_FAULTS allowlist (NotOnWhitelistFault, CouldNotRetrieveKeyFault, their SignedMessageInformationFault counterparts, and the TeslaFleetMessageFault unknown-key/inactive-key/invalid-key-handle faults) identifying faults meaning the vehicle rejected the signing key, verified against each fault's proto meaning rather than its name.
  • Added tests/test_is_key_rejected.py and expanded tests/test_router.py covering the new handler's success/failure paths, the False veto, and the BluetoothUnconfirmedCommand exclusion; updated AGENTS.md and README.md documentation to describe the handler and predicate.

Risk Assessment

✅ Low: The fix round correctly adds TeslaFleetMessageFaultInvalidKeyHandle (proto meaning: revoked/expired key) to KEY_REJECTED_FAULTS, resolving the sole round-1 finding; the on_error handler implementation in router/base.py precisely matches the intent (fires only for dispatched calls, success and failure, BluetoothUnconfirmedCommand bypasses it, boolean return gates failover), docs/README/AGENTS.md are consistent with the code, and the added tests are behavioral (exercise Router/is_key_rejected via real calls) rather than source-content matches.

Testing

Ran the targeted unit tests for the Router error-handler feature and the is_key_rejected predicate (44/44 passed), then wrote and ran a standalone manual script reproducing the actual Home Assistant PR scenario described in the user intent — a BLE-primary/cloud-fallback VehicleRouter with an on_error handler that raises a repair and vetoes cloud failover on key rejection, and clears the repair on the next successful command — confirming all three required behaviors (veto-prevents-cloud-call, success-clears-repair, unrelated-failures-still-fail-over) end-to-end; no issues found.

Evidence: HA key-rejection scenario CLI transcript

door_lock() raised NotOnWhitelistFault, cloud.calls=0 (failover vetoed), repair raised; subsequent good command cleared repair; unrelated BluetoothTimeout still failed over to cloud normally (cloud3.calls=1). ALL SCENARIO ASSERTIONS PASSED.

=== Step 1: key-rejected BLE command must NOT reach the cloud ===
door_lock() raised as expected: NotOnWhitelistFault: Not on whitelist fault on signed command.
cloud.calls = 0 (expected 0 - failover vetoed)
repairs.active = True (expected True)
log: ['REPAIR RAISED: BLE key rejected on door_lock']

=== Step 2: after re-pairing, a good BLE command clears the repair ===
door_lock() result = {'result': True, 'reason': ''}
repairs.active = False (expected False)
log: ['REPAIR RAISED: BLE key rejected on door_lock', 'REPAIR CLEARED']

=== Step 3: an unrelated failure still fails over to cloud normally ===
door_lock() result = {'result': True, 'reason': ''}
cloud3.calls = 1 (expected 1 - normal failover proceeds)

ALL SCENARIO ASSERTIONS PASSED

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed ✅
  • ⚠️ tesla_fleet_api/exceptions.py:1299 - KEY_REJECTED_FAULTS (tesla_fleet_api/exceptions.py:1299-1306) omits TeslaFleetMessageFaultInvalidKeyHandle (code 27, proto MESSAGEFAULT_ERROR_INVALID_KEY_HANDLE), whose own docstring reads "The key handle is not valid. The key may have been revoked or expired" — the same 'this key is no longer accepted' meaning as the included UnknownKeyId/InactiveKey faults. The intent requires the allowlist be 'verified against proto fault meanings, not guessed,' and explicitly enumerates 'the keychain / unknown-key / inactive-key message faults' without mentioning this one, so it's unclear whether the omission was a deliberate scoping decision (e.g. 'key handle' meaning an ephemeral per-session handle rather than the long-lived signing key) or an oversight.

🔧 Fix: Add InvalidKeyHandle fault to key-rejection allowlist
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • uv run pytest tests/test_router.py tests/test_is_key_rejected.py -v — 44 passed
  • Manual end-to-end script simulating the Home Assistant integration scenario from the user intent: VehicleRouter(ble_primary, cloud_fallback, on_error=handler) where the handler raises a repair and returns False on is_key_rejected(exc), verifying (1) a NotOnWhitelistFault on the BLE primary propagates without ever calling the cloud fallback, (2) a subsequent successful BLE command clears the repair via the exception=None success callback, and (3) an unrelated BluetoothTimeout still fails over to cloud normally
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Router now accepts an optional on_error(exception, backend, method_name)
handler, called after every dispatched call (BluetoothUnconfirmedCommand
excepted). On a backend failure its return value decides whether failover
proceeds to the next backend or stops and re-raises immediately; on a
successful dispatch it's called with exception=None so a caller can also
observe recovery (e.g. clear a repair). VehicleRouter/EnergySiteRouter
inherit it unchanged since neither overrides __init__.

Also adds exceptions.is_key_rejected(exc), a verified allowlist of the
faults that mean the vehicle didn't recognize our signing key, so a
caller like Home Assistant can gate a repair/no-cloud-fallback decision
on it without reimplementing fault classification.
@Bre77 Bre77 added the fm Opened by a Firstmate crewmate label Sep 14, 2026
@Bre77
Bre77 merged commit 33af795 into main Sep 14, 2026
6 checks passed
@Bre77 Bre77 mentioned this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fm Opened by a Firstmate crewmate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant