Python library (tesla_fleet_api) with async clients for Tesla Fleet API,
Teslemetry, Tessie and vehicle BLE. Published to PyPI as tesla-fleet-api.
uv sync # install
uv run pyright tesla_fleet_api # type check (strict)
uv run ruff check tesla_fleet_api tests # lint (as CI runs it)
uv run pytest testsCI does not check formatting and the tree is not ruff format-clean. Run
ruff format only on the files you change; on a whole directory it rewrites
unrelated files.
Tests use unittest.IsolatedAsyncioTestCase collected by pytest (no
pytest-asyncio), except tests/powerwall/, whose plain async def tests run
through its conftest.py. BLE command tests build on MockedBleTransportTestCase
(tests/ble_mocked_transport.py), which needs no real BLE/GATT connection.
- Tesla Fleet: https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-endpoints
- Tessie: https://developer.tessie.com/llms.txt
- Teslemetry: http://api.teslemetry.com/openapi.yaml
- Library docs:
docs/. Measured live-vehicle BLE timings:OBSERVATIONS.md(record new experiment results there).
No release automation; the release skill has the steps. Bump version in
pyproject.toml and __version__ in tesla_fleet_api/__init__.py, run uv lock
(CI uses uv sync --locked), and merge a Bump version to X.Y.Z PR that changes
only those three files. Then push a vX.Y.Z tag on the merge commit.
release.yml publishes to PyPI via OIDC trusted publishing in the pypi
environment, which has no required reviewers: merging the version-bump PR is
the publish approval. Keep release.yml a plain top-level workflow, never
workflow_call — the trusted publisher is bound to release.yml + pypi.
- Protobuf bindings come from the
tesla-protocolPyPI package (tesla_protocol.command.<module>_pb2);tesla/vehicle/proto/holds only re-export shims. There is no local regeneration step: bump the pin instead, floor and major cap together (>=X.Y.Z,<X+1). A major release renames fields. - Home Assistant pin: protobuf refuses gencode newer than the runtime. Before
bumping
tesla-protocol, check HA core'sprotobufpin (homeassistant/package_constraints.txt) and keep ourprotobuffloor in sync. - Do not add dependencies on aiopowerwall or
teslemetry-stream. The Powerwall LAN client lives intesla/powerwall/;BleBroadcastStreamGluewrites to the structuralStreamSinkProtocol. firmware_compare/firmware_at_least(util.py) must be used for firmware strings ("2025.10" < "2025.9"as strings). No version-parsing dependency.
- pyright strict;
TYPE_CHECKINGguards for circular imports. - Enums are the custom
StrEnum/IntEnuminconst.py, not stdlib.Regionis aLiteral. camelCase for attributes that mirror API structure (energySites,createFleet); snake_case for endpoint methods. - All exceptions inherit
TeslaFleetError(BaseException), notException, on purpose.except Exceptiondoes not catch them. The exception istesla/powerwall/exceptions.py, which keepsException(moved from aiopowerwall). - BLE transport catch sites must catch both
bleak.exc.BleakErrorand builtinTimeoutError(bleak-esphome raises a bareTimeoutError). exceptions.is_key_rejectedis a positive allowlist verified against each fault's proto enum meaning, not its name.- Full proto coverage:
tests/test_proto_coverage_lock.pyfails on any unwrappedVehicleAction/GetVehicleDatafield. On atesla-protocolbump, wrap each new field. Allowlist a field only with a written reason, in one of the reasoned sets; never special-case it in the test logic. - Seat indexing:
Seatis 0-indexed and only forremote_seat_heater_request.AutoSeatis 1-indexed and is the type forremote_auto_seat_climate_requeston both backends. ASeatthere is off by one. remote_seat_heater_request/remote_seat_cooler_requestbuild protobuf messages from string kwargs, so pyright does not check them. Cross-check field names againsttesla_protocol.command.car_server_pb2.navigation_gps_request: pass the protobuf enumorderas a bare int; theEnumTypeWrapperis not callable like anIntEnum.legacy_vehicle_state()(getLegacyVehicleState),current_vehicle_state()(getVehicleState) andvehicle_state()(VCSECVehicleStatus) are three different messages.- Typed accessors over undocumented raw-dict responses: check
key in payload, neverpayload.get(key) or default. ANonebody or unknown shape raisesInvalidResponse; only a well-formed empty response parses to empty. Widenconst.pyenums only against a live sample. Keep the untyped escape hatch. register_client()is Teslemetry-only. Do not add Fleet API or Tessie equivalents speculatively.- SS256 JWS signing (
tesla/jws.py) is locked by Go SDK vectors intests/fixtures/ss256_vectors.json: regenerate withss256_vectors_gen.go, never hand-edit, and do not add low-snormalisation.
Routerhealth check gates only the primary. There is deliberately no per-backend health matrix.- Failover can double-execute a non-idempotent command that failed mid-flight.
BluetoothUnconfirmedCommandmust propagate without replay. set_island_mode/go_off_grid/reconnect_gridmust never send the unsignedgrpc_command: it is acknowledged but does not move the contactor. Without a signing key, FleetEnergySiteraisesSignedCommandRequired, andTeslemetryEnergySitecalls Teslemetry's server-signed route. Only a signed path actuates; verify state after.TeslemetryEnergySitewithout a signing key calls only dedicated Teslemetry api routes, never the generic command route. If a route is missing, add it to the api first; do not ship a fallback. With a key, it signs and usesdevice_command.- Never route
operation,backupandgrid_import_exportfor one site to different backends. All three write the gatewayconfig.json, so a split lets one write overwrite the other.
- A funnel, not a selector. Do not add source health, availability, grace windows, priority, stickiness or per-field selection. Unavailability is a value a source reports, never inferred from a dropped link.
- The only arbitration (drop older observations, skip unchanged values) is hard-coded, not configurable.
- The module must stay fully synchronous and never originate a request;
TestFunnelCannotOriginateWorkenforces this. Polling belongs to the consumer. - Translations are positive allowlists: an unmapped value emits nothing, not a guess. Fields are deliberately limited to three.
- Add a
_transport_nameClassVarto every newCommands/TeslaFleetApisubclass; command log line shapes are locked bytests/test_command_logging.py. _log_request_result(fleet.py) must never raise on any JSON-legal body.- The same-named command on REST
VehicleFleetand BLECommandsmust build an equivalent instruction from identical args. Checktests/test_cross_transport_parity.pyfor known form gaps before "fixing" one.
Vehicle behaviours that look like library bugs but are not:
remote_heater_control_enabled=false(read-only, no command) makes every remote comfort action return"cabin comfort remote settings not enabled".scheduled_charging_modeis shared byset_scheduled_chargingandset_scheduled_departure. Readcharge_state()first and restore the prior mode.set_scheduled_departure: a present preconditioning/off-peak block turns the feature on, so the signed path omits it when disabled (as Tesla's proxy does).charge_standard()returnsalready_standardas a failure, not a no-op.
User-facing behaviour lives in docs/bluetooth_vehicles.md. Do not break these:
- Never pass
service_uuids=[SERVICE_UUID]toBleakScanner: a Tesla advertises only its VIN-derived name, in the scan response. Scan unfiltered with active scanning and match by name. - Reference
bleak.BleakClient/bleak.BleakScannerat call time, neverfrom bleak import ...: Home Assistant replaces them at runtime. Tests patch thebleak.*names. _on_messagemust look up_queueswith.get(); aKeyErroraborts the whole notification's reassembly.- Mutating-command timeouts are inconclusive: the car may have executed.
Never blind-retry a non-idempotent command (toggle, step, schedule add/remove)
on a timeout; verify by absolute state, not invocation count
(
Commands._commandalready re-sends onWAIT/epoch faults). BluetoothUnconfirmedCommandmeans unresolved;BluetoothCommandFailedmeans a state check proved it did not apply and does not subclassBluetoothTimeout. Plain reads raise plainBluetoothTimeout.- In
_send, onlyBleakCharacteristicNotFoundErroris provably pre-submission and may becomeBluetoothTransportError(retryable). Every other write error is delivery-unknown. "verify"plans cover only clearly derivable absolute commands. Do not add plans for toggles, relative steps or ack-only actions.- VCSEC actuations reply with a single bare ACK; callers declare
expects_data=Falsefor them. pair()must never re-send the whitelist op — it re-prompts the user.- A
session_inforeply that fails authentication is re-requested, never trusted. - Keepalive reads must never raise into user code, trigger reconnect or wake the car. They delay sleep; disable keepalive or disconnect when the car should sleep.
False, notNone, disables signing forprivate_key/key.Nonefalls back to the parent key and must keep raisingValueError("No private key."). Test identity (is False/is not None), never truthiness.pair()'s fast path skips_handshake, so any new signed-session entry point needs its own guard.
Vehicle-side BLE behaviours (not library bugs):
- Infotainment answers a handshake 4-16s after VCSEC wake. Readiness is a fresh INFO handshake, never VCSEC status or a broadcast.
- Two or more
vehicle_data()endpoints together raiseTeslaFleetMessageFaultResponseSizeExceedsMTU; that is why BLEvehicle_data()has no all-endpoints default. - Individual doors have no reliable powered close (Model 3). Never automate a snapshot→act→verify→restore cycle across an individual door-open command.
- Media track/favourite commands are not reliably state-observable (Spotify).
Verify them by ACK; use
audio_volume/media_playback_statusas provers.
Keep only context an agent cannot get by reading the code. Point to the authoritative file or command instead of restating it. Prefer pruning to appending; deep task-specific knowledge belongs in a repo skill.