Skip to content

feat(bluetooth): add idle BLE keepalive#63

Merged
Bre77 merged 2 commits into
mainfrom
fm/tfa-ble-keepalive-k9
Jul 10, 2026
Merged

feat(bluetooth): add idle BLE keepalive#63
Bre77 merged 2 commits into
mainfrom
fm/tfa-ble-keepalive-k9

Conversation

@Bre77

@Bre77 Bre77 commented Jul 10, 2026

Copy link
Copy Markdown
Member

Intent

Add a keepalive_interval constructor parameter to VehicleBluetooth (captain-directed feature) that keeps an otherwise idle held BLE link to the vehicle from dropping. Measured problem: an idle held BLE link dies at ~42s mean lifetime (link supervision timeout ~720ms underneath, bluetoothd-verified on macOS CoreBluetooth); a single trivial passive GATT read every ~20s extends lifetime ~10x (~400s observed).

Design decisions (captain-approved, refined to idle-triggered):

  • keepalive_interval defaults to ~20.0s (the captain's proven value, safely under the ~42s idle-drop mean); None or 0 disables. Placed alongside the existing verify_commands constructor param and threaded through the same Vehicles/VehiclesBluetooth.create* factories (Teslemetry/Tessie createBluetooth get the param for signature parity only, they raise NotImplementedError).
  • Idle-triggered, NOT periodic: _last_activity is bumped on any real GATT activity (every _send write and every _on_notify received frame); the read fires only after keepalive_interval seconds of genuine idleness, so an active session never gets extra traffic. This was a deliberate refinement over a naive fixed-interval timer.
  • The keepalive is a minimal PASSIVE GATT read of the version characteristic (VERSION_UUID) only - never a command, never anything signed/mutating, and it never wakes the car. Chosen over an RSSI read specifically because a prior crew's un-timed RSSI read hung indefinitely against a sleeping car, and a GATT attribute read is exactly what the captain's ~10x-lifetime measurement used.
  • Bounded and best-effort: every read carries a short timeout (_keepalive_timeout, 2s) and _keepalive_read swallows all Exceptions (but never CancelledError), so a sleeping car can never hang the loop and a failed keepalive never raises into user code or triggers reconnect storms - the existing connect_if_needed reconnect machinery owns link recovery.
  • One asyncio task tied to the connection lifecycle: started at the end of connect() after start_notify, cancelled-and-awaited in disconnect(), and replaced cleanly on reconnect via _start_keepalive/_stop_keepalive. No task leaks.
  • Documented the sleep tradeoff in both the class docstring and docs/bluetooth_vehicles.md: keepalive reads keep an AWAKE car awake (deferring vehicle sleep), so consumers wanting the car to sleep should disable keepalive or disconnect when idle.

Constraints followed: KISS (one asyncio task + a monotonic timestamp, no scheduling framework); one issue per PR; based on latest origin main (v1.6.2, 56ea111); code comments state single WHY constraints only, with the measured evidence summary living in AGENTS.md; live car verification was NOT required and the car was not touched. Added tests/test_ble_keepalive.py (11 tests): idle timer fires the read, activity resets the timer, disabled = zero reads, failure swallowed + loop survives, hanging read bounded by timeout, task cleanup on disconnect. Full suite (209) passes, pyright strict and ruff clean.

What Changed

  • Added an idle-triggered BLE keepalive for VehicleBluetooth, with a configurable keepalive_interval that performs bounded passive GATT reads only after connection inactivity and cleans up with the connection lifecycle.
  • Threaded keepalive_interval through Bluetooth vehicle factory methods while preserving Teslemetry/Tessie signature parity for unsupported Bluetooth creation paths.
  • Documented the new keepalive behavior and sleep tradeoff, and added focused mocked BLE keepalive tests for timer behavior, failure handling, timeout bounding, and task cleanup.

Risk Assessment

✅ Low: The change is well-bounded to BLE keepalive lifecycle plumbing with documented behavior and focused coverage, and I did not find a substantiated merge-blocking or follow-up risk in the reviewed diff.

Testing

Focused keepalive tests passed, the evidence transcript demonstrates default/factory interval handling, passive VERSION_UUID reads after idle time, no reads during simulated activity, disabled keepalive behavior, notify-based timer reset, and bounded timeout behavior; the full test suite also passed.

Evidence: BLE keepalive mock evidence
BLE keepalive evidence transcript
default_keepalive_interval=20.0
passive_version_uuid=00000214-b2d1-43f0-9b88-960cebf8b91e
factory_create_threads_interval=3.5
idle_read_count=3
idle_read_uuids=['00000214-b2d1-43f0-9b88-960cebf8b91e', '00000214-b2d1-43f0-9b88-960cebf8b91e', '00000214-b2d1-43f0-9b88-960cebf8b91e']
idle_uses_only_passive_version_read=True
busy_session_read_count=0
notify_resets_idle_timer=True
disabled_none_task=None
disabled_none_read_count=0
bounded_timeout_elapsed_seconds=0.020
bounded_timeout_returned_without_exception=True

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • uv run pytest tests/test_ble_keepalive.py -q
  • uv run python - <<'PY' > /tmp/no-mistakes-evidence/01KX5V3P8XYAANMSA7RGN0AFP7/ble_keepalive_mock_session.txt ...
  • uv run pytest tests -q
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate crewmate added 2 commits July 10, 2026 21:01
An idle held BLE link to the vehicle drops at ~42s mean lifetime. Add a
keepalive_interval constructor knob (default 20s, None/0 disables) that
issues a bounded passive GATT read after that many seconds of GATT
idleness, resetting the link supervision timer and extending link
lifetime ~10x.

- Idle-triggered: _last_activity is bumped on every send and received
  frame, so an active session gets no extra traffic; the read fires only
  during genuine idleness.
- Passive and safe: reads the version characteristic only, never a
  signed/mutating command, and never wakes the car.
- Bounded and best-effort: each read carries a short timeout and swallows
  all failures, so a sleeping car can never hang the loop and a failed
  keepalive never raises into caller code or forces a reconnect.
- One asyncio task tied to the connection lifecycle: started after
  start_notify in connect(), cancelled-and-awaited in disconnect().

Threaded through Vehicles/VehiclesBluetooth factories. Tests in
tests/test_ble_keepalive.py.
@Bre77
Bre77 merged commit 81eee50 into main Jul 10, 2026
5 checks passed
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