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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te
- **Idle BLE keepalive (`keepalive_interval`)**: an idle held BLE link to the vehicle drops 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). `VehicleBluetooth.__init__`'s `keepalive_interval` (default `DEFAULT_KEEPALIVE_INTERVAL` = 20.0, `None`/`0` disables; threaded through `Vehicles`/`VehiclesBluetooth.create*`) starts one asyncio task per connection (`_keepalive_loop`, `bluetooth.py`) that reads `VERSION_UUID` only after `keepalive_interval` seconds of genuine GATT idleness. **Idle-triggered, not periodic**: `_last_activity` is bumped on every `_send` write and every `_on_notify` frame, so an active session never gets extra traffic; the loop recomputes the wait each pass and fires only when idle. The read is **bounded** (`_keepalive_timeout`, 2s) and **best-effort** - a prior un-timed RSSI read hung indefinitely against a sleeping car, so every attempt carries a timeout and swallows all failures (`_keepalive_read` catches `Exception`, never `CancelledError`); a failed keepalive never raises into user code, never triggers reconnect (the existing `connect_if_needed` machinery owns recovery), and never wakes the car. Task lifecycle is tied to the connection: started at the end of `connect()` (after `start_notify`), cancelled-and-awaited in `disconnect()` and restarted cleanly on reconnect (`_start_keepalive`/`_stop_keepalive`). **Sleep tradeoff**: these reads keep an *awake* car awake and defer vehicle sleep - consumers wanting the car to sleep should disable keepalive or disconnect when idle. Tests in `tests/test_ble_keepalive.py`.
- **Cross-transport parity (cloud REST `VehicleFleet` vs BLE `Commands`)**: the same-named command on both paths should build a semantically equivalent instruction from identical args - a divergence there is a bug, but response *bodies* legitimately differ (REST JSON dict vs decoded protobuf) and are not. `tests/test_cross_transport_parity.py` locks the equivalence in with mocked-both-transports tests. Known **non-bug FORM differences** (do not "fix"): `set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` (no proto fields), `window_control` lat/lon and `navigation_sc_request` `id` (no proto fields), `navigation_request`'s `type`/`locale`/`timestamp_ms` (REST share-intent framing), and `media_volume_up` (no Tesla REST endpoint - BLE-only; cloud raises volume via `adjust_volume`). Two **open divergences left unfixed** pending live verification: `clear_pin_to_drive_admin` builds `DrivingClearSpeedLimitPinAction` (speed-limit PIN, not PIN-to-Drive - suspected mismapping, security-sensitive), and `navigation_gps_request`'s `order` is required on BLE but optional on cloud (signature mismatch; null-order wire semantics undecided).
- **Per-command debug logging chokepoints and the `command=` name it derives**: `LOGGER.debug` lines of the form `command=<name> transport=<t> result=...` are emitted from exactly four places, not per-method - `Commands._sendVehicleSecurity`/`_getVehicleSecurity`/`_sendInfotainment`/`_getInfotainment` (`commands.py`, covers both BLE and Fleet-signed) and `TeslaFleetApi._request` (`fleet.py`, covers Fleet/Teslemetry/Tessie REST). `transport` comes from a `_transport_name` `ClassVar` set per concrete class (`"bluetooth"`/`"fleet"`/`"teslemetry"`/`"tessie"`), mirroring the existing `_auth_method` pattern - add that ClassVar to any new `Commands`/`TeslaFleetApi` subclass. For BLE/Fleet-signed, `command` is **not** the Python method name; it's derived from the populated protobuf oneof field (`vcsec_command_name`/`infotainment_command_name` in `commands.py`), e.g. `door_lock()` logs as `RKE_ACTION_LOCK` and `set_charge_limit()` as `chargingSetLimitAction` - deliberately robust to call-site changes since it reads the message being sent, not the call stack. `VehicleBluetooth`'s `verify_commands` resolution logs a second, separate line (`verify_commands=resolved`/`unresolved`) rather than duplicating the base class's raw-attempt line. `Router._dispatch` (`router/base.py`) logs `command=... backend=<ClassName> result=...` per backend tried, independent of the above. See `docs/bluetooth_vehicles.md`'s "Troubleshooting: Enable Debug Logging" section for the user-facing format; `tests/test_command_logging.py` locks in the exact line shapes.
- **`_log_request_result` (`fleet.py`) must tolerate any JSON-legal REST body, not just dicts**: it runs after the HTTP request already succeeded, so it's a logging convenience only - a non-dict body (`null`, a list, a bare scalar; live case: Teslemetry's `list_authorized_clients` returning `null`) must never raise there. It guards with `isinstance(data, dict)` before calling `.get()`, logging `result=success` and returning for anything else. Regression tests in `tests/test_command_logging.py` (`test_null_json_body_returns_none_without_raising` etc.) cover null/list/scalar bodies.

## Maintaining this file

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ Command log lines use `transport=bluetooth`, `fleet`, `teslemetry`, or `tessie`.
Routers also emit `backend=<ClassName>` lines for each backend tried. See
[Bluetooth for Vehicles](docs/bluetooth_vehicles.md#troubleshooting-enable-debug-logging)
for examples and the signed-command naming details.
REST responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.

### Teslemetry

Expand Down
2 changes: 2 additions & 0 deletions docs/bluetooth_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,8 @@ command=mediaPlayAction transport=bluetooth result=error error=BluetoothUnconfir
commands, `command` is the underlying VCSEC/infotainment field name (e.g.
`RKE_ACTION_LOCK`, `chargingSetLimitAction`), not the Python method name; for
REST commands it is the endpoint's final path segment (e.g. `set_charge_limit`).
REST responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.
For BLE commands run with `confirmation="verify"`, a resolved state-read logs a
second line with `verify_commands=resolved` and the confirmed result; an
unresolved read logs `verify_commands=unresolved` before the exception
Expand Down
3 changes: 3 additions & 0 deletions docs/fleet_api_energy_sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ logging.basicConfig(level=logging.DEBUG)
logging.getLogger("tesla_fleet_api").setLevel(logging.DEBUG)
```

Responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.

## Backup Reserve

You can adjust the backup reserve for a specific energy site using its ID:
Expand Down
3 changes: 3 additions & 0 deletions docs/fleet_api_vehicles.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ command=vehicle_data transport=fleet result=success
command=set_charge_limit transport=fleet result=True reason=
```

Responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.

## Create a Vehicle

You can create a `VehicleFleet` instance for a specific vehicle using its VIN:
Expand Down
3 changes: 3 additions & 0 deletions docs/teslemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ logging.basicConfig(level=logging.DEBUG)
logging.getLogger("tesla_fleet_api").setLevel(logging.DEBUG)
```

Responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.

## Ping

The `ping` method sends a ping request to the Teslemetry server.
Expand Down
3 changes: 3 additions & 0 deletions docs/tessie.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ logging.basicConfig(level=logging.DEBUG)
logging.getLogger("tesla_fleet_api").setLevel(logging.DEBUG)
```

Responses that are valid JSON but not objects, such as `null`, lists, or
scalars, are returned unchanged and log as `result=success`.

## Top-Level Client Methods

These methods exist on `Tessie` itself.
Expand Down
14 changes: 12 additions & 2 deletions tesla_fleet_api/tesla/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ def _normalize_query_value(value: Any) -> Any:
return value


def _log_request_result(command: str, transport: str, data: dict[str, Any]) -> None:
def _log_request_result(command: str, transport: str, data: Any) -> None:
"""Log the outcome of a completed request; a logging convenience that must
never raise, since the request it describes has already succeeded."""
if not isinstance(data, dict):
LOGGER.debug("command=%s transport=%s result=success", command, transport)
return
data = cast("dict[str, Any]", data)
response = data.get("response")
if isinstance(response, dict) and "result" in response:
result_data = cast("dict[str, Any]", response)
Expand Down Expand Up @@ -139,7 +145,11 @@ async def _request(
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = {},
) -> dict[str, Any]:
"""Send a request to the Tesla Fleet API."""
"""Send a request to the Tesla Fleet API.

Returns the decoded JSON body as provided by the service, including
JSON-legal non-object bodies such as ``null``, lists, or scalars.
"""

# Trailing path segment (e.g. "door_lock", "vehicle_data") as the
# debug-log command name; the full path (with VIN) is already logged
Expand Down
3 changes: 3 additions & 0 deletions tesla_fleet_api/teslemetry/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ async def get_networking_status(self) -> dict[str, Any]:
async def list_authorized_clients(self) -> dict[str, Any]:
"""List authorized clients on the energy gateway via the Teslemetry
custom endpoint.

Teslemetry may return JSON ``null`` when no authorized-client payload
is available; that value is returned unchanged.
"""
return await self._request(
Method.GET,
Expand Down
55 changes: 53 additions & 2 deletions tests/test_command_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,23 @@ async def test_verify_commands_resolution_logs_verified_outcome(self) -> None:
)


_UNSET = object()


def _fake_response(
*,
status: int = 200,
ok: bool = True,
content_type: str = "application/json",
json_body: object = None,
json_body: object = _UNSET,
):
resp = MagicMock()
resp.status = status
resp.ok = ok
resp.content_type = content_type
resp.url = "https://example.com/x"
resp.headers = {}
resp.json = AsyncMock(return_value=json_body if json_body is not None else {})
resp.json = AsyncMock(return_value={} if json_body is _UNSET else json_body)
resp.text = AsyncMock(return_value="")
return resp

Expand Down Expand Up @@ -230,6 +233,54 @@ async def test_fleet_failure_logs_command_transport_and_error(self) -> None:
captured.output,
)

async def test_null_json_body_returns_none_without_raising(self) -> None:
resp = _fake_response(json_body=None)
api = TeslaFleetApi(
session=_make_session(resp),
access_token="token",
server="https://fleet.example.com",
)

with self.assertLogs(LOGGER_NAME, level="DEBUG") as captured:
result = await api._request(
Method.GET, "api/1/vehicles/VIN123/list_authorized_clients"
)

self.assertIsNone(result)
self.assertTrue(
any(
"command=list_authorized_clients" in line
and "transport=fleet" in line
and "result=success" in line
for line in captured.output
),
captured.output,
)

async def test_list_json_body_returns_list_without_raising(self) -> None:
resp = _fake_response(json_body=[{"id": 1}])
api = TeslaFleetApi(
session=_make_session(resp),
access_token="token",
server="https://fleet.example.com",
)

result = await api._request(Method.GET, "api/1/vehicles/VIN123/clients")

self.assertEqual(result, [{"id": 1}])

async def test_scalar_json_body_returns_scalar_without_raising(self) -> None:
resp = _fake_response(json_body=True)
api = TeslaFleetApi(
session=_make_session(resp),
access_token="token",
server="https://fleet.example.com",
)

result = await api._request(Method.GET, "api/1/vehicles/VIN123/flag")

self.assertEqual(result, True)

async def test_teslemetry_success_logs_transport_teslemetry(self) -> None:
resp = _fake_response(json_body={"response": {"result": True}})
api = Teslemetry(session=_make_session(resp), access_token="token")
Expand Down
Loading