tesla-fleet-api 1.0.10__py3-none-any.whl → 1.0.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tesla_fleet_api/const.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from enum import Enum
4
4
  import logging
5
5
 
6
- VERSION = "1.0.10"
6
+ VERSION = "1.0.12"
7
7
  LOGGER = logging.getLogger(__package__)
8
8
  SERVERS = {
9
9
  "na": "https://fleet-api.prd.na.vn.cloud.tesla.com",
@@ -1,10 +1,16 @@
1
1
  """Bluetooth only interface."""
2
2
 
3
+ import asyncio
3
4
  import hashlib
4
5
  import re
6
+ from bleak import BleakClient
7
+ from bleak.backends.device import BLEDevice
8
+ from bleak_retry_connector import establish_connection
5
9
  from google.protobuf.json_format import MessageToJson, MessageToDict
6
10
 
11
+ from tesla_fleet_api.const import LOGGER
7
12
  from tesla_fleet_api.tesla.tesla import Tesla
13
+ from tesla_fleet_api.tesla.vehicle.bluetooth import NAME_UUID
8
14
  from tesla_fleet_api.tesla.vehicle.vehicles import VehiclesBluetooth
9
15
 
10
16
  class TeslaBluetooth(Tesla):
@@ -27,6 +33,30 @@ class TeslaBluetooth(Tesla):
27
33
  """Get the name of a vehicle."""
28
34
  return "S" + hashlib.sha1(vin.encode('utf-8')).hexdigest()[:16] + "C"
29
35
 
36
+ async def query_display_name(self, device: BLEDevice, max_attempts=5) -> str | None:
37
+ """Queries the name of a bluetooth vehicle."""
38
+ client = await establish_connection(
39
+ BleakClient,
40
+ device,
41
+ device.name or "Unknown",
42
+ max_attempts=max_attempts
43
+ )
44
+ name: str | None = None
45
+ for i in range(max_attempts):
46
+ try:
47
+ # Standard GATT Device Name characteristic (0x2A00)
48
+ device_name = (await client.read_gatt_char(NAME_UUID)).decode('utf-8')
49
+ if device_name.startswith("🔑 "):
50
+ name = device_name.replace("🔑 ","")
51
+ break
52
+ await asyncio.sleep(1)
53
+ LOGGER.debug(f"Attempt {i+1} to query display name failed, {device_name}")
54
+ except Exception as e:
55
+ LOGGER.error(f"Failed to read device name: {e}")
56
+
57
+ await client.disconnect()
58
+ return name
59
+
30
60
 
31
61
  # Helpers
32
62
 
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import asyncio
5
- from typing import TYPE_CHECKING
5
+ import struct
6
+ from typing import TYPE_CHECKING, Callable, Any
6
7
  from google.protobuf.message import DecodeError
7
8
  from bleak_retry_connector import establish_connection, MAX_CONNECT_ATTEMPTS
8
9
  from bleak import BleakClient, BleakScanner
@@ -75,16 +76,79 @@ def prependLength(message: bytes) -> bytearray:
75
76
  """Prepend a 2-byte length to the payload."""
76
77
  return bytearray([len(message) >> 8, len(message) & 0xFF]) + message
77
78
 
79
+ class ReassemblingBuffer:
80
+ """
81
+ Reassembles bytearray streams where the first two bytes indicate the length of the message.
82
+ Handles potential packet corruption by discarding *entire* packets and retrying.
83
+ Uses a callback to process parsed messages.
84
+ """
85
+
86
+ def __init__(self, callback: Callable[[RoutableMessage], None]):
87
+ """
88
+ Initializes the buffer.
89
+
90
+ Args:
91
+ message_type: The protobuf message type (e.g., RoutableMessage) to parse the assembled data.
92
+ callback: A function that will be called with each parsed message.
93
+ """
94
+ self.buffer = bytearray()
95
+ self.expected_length = None
96
+ self.packet_starts = []
97
+ self.callback = callback
98
+
99
+ def receive_data(self, data: bytearray):
100
+ """
101
+ Receives a chunk of bytearray data and attempts to assemble a complete message.
102
+
103
+ Args:
104
+ data: The received bytearray data.
105
+ """
106
+ self.packet_starts.append(len(self.buffer))
107
+ self.buffer.extend(data)
108
+
109
+ while True:
110
+ if self.expected_length is None and len(self.buffer) >= 2:
111
+ self.expected_length = struct.unpack(">H", self.buffer[:2])[0] + 2
112
+
113
+ LOGGER.info(f"Buffer length: {len(self.buffer)}, Packet starts: {self.packet_starts}, Expected length: {self.expected_length}")
114
+
115
+ if self.expected_length is not None and self.expected_length > 1024:
116
+ LOGGER.warning(f"Expected length too large: {self.expected_length}")
117
+ self.discard_packet()
118
+
119
+ elif self.expected_length is not None and len(self.buffer) >= self.expected_length:
120
+ try:
121
+ message = RoutableMessage()
122
+ message.ParseFromString(bytes(self.buffer[2:self.expected_length]))
123
+ self.buffer = self.buffer[self.expected_length:]
124
+ self.packet_starts = [x - self.expected_length for x in self.packet_starts if x >= self.expected_length]
125
+ self.expected_length = None
126
+ self.callback(message) # Call the callback with the parsed message
127
+
128
+ except DecodeError:
129
+ self.discard_packet()
130
+ else:
131
+ return
132
+
133
+ def discard_packet(self):
134
+ self.packet_starts.pop(0)
135
+ if len(self.packet_starts) > 0:
136
+ self.buffer = self.buffer[self.packet_starts[0]:]
137
+ self.packet_starts = [x - self.packet_starts[0] for x in self.packet_starts]
138
+ else:
139
+ self.buffer = bytearray()
140
+ self.packet_starts = []
141
+ self.expected_length = None
142
+
78
143
  class VehicleBluetooth(Commands):
79
144
  """Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""
80
145
 
81
146
  ble_name: str
82
- device: BLEDevice
83
- client: BleakClient
147
+ device: BLEDevice | None = None
148
+ client: BleakClient | None = None
84
149
  _queues: dict[Domain, asyncio.Queue]
85
150
  _ekey: ec.EllipticCurvePublicKey
86
- _recv: bytearray = bytearray()
87
- _recv_len: int = 0
151
+ _buffer: ReassemblingBuffer
88
152
  _auth_method = "aes"
89
153
 
90
154
  def __init__(
@@ -96,8 +160,9 @@ class VehicleBluetooth(Commands):
96
160
  Domain.DOMAIN_VEHICLE_SECURITY: asyncio.Queue(),
97
161
  Domain.DOMAIN_INFOTAINMENT: asyncio.Queue(),
98
162
  }
99
- if device is not None:
100
- self.device = device
163
+ self.device = device
164
+ self._connect_lock = asyncio.Lock()
165
+ self._buffer = ReassemblingBuffer(self._on_message)
101
166
 
102
167
  async def find_vehicle(self, name: str | None = None, address: str | None = None, scanner: BleakScanner | None = None) -> BLEDevice:
103
168
  """Find the Tesla BLE device."""
@@ -119,12 +184,12 @@ class VehicleBluetooth(Commands):
119
184
  def set_device(self, device: BLEDevice) -> None:
120
185
  self.device = device
121
186
 
122
- def get_device(self) -> BLEDevice:
187
+ def get_device(self) -> BLEDevice | None:
123
188
  return self.device
124
189
 
125
190
  async def connect(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None:
126
191
  """Connect to the Tesla BLE device."""
127
- if not hasattr(self, 'device'):
192
+ if not self.device:
128
193
  raise ValueError(f"BLEDevice {self.ble_name} has not been found or set")
129
194
  self.client = await establish_connection(
130
195
  BleakClient,
@@ -138,8 +203,17 @@ class VehicleBluetooth(Commands):
138
203
 
139
204
  async def disconnect(self) -> bool:
140
205
  """Disconnect from the Tesla BLE device."""
206
+ if not self.client:
207
+ return False
141
208
  return await self.client.disconnect()
142
209
 
210
+ async def connect_if_needed(self, max_attempts: int = MAX_CONNECT_ATTEMPTS) -> None:
211
+ """Connect to the Tesla BLE device if not already connected."""
212
+ async with self._connect_lock:
213
+ if not self.client or not self.client.is_connected:
214
+ LOGGER.info(f"Reconnecting to {self.ble_name}")
215
+ await self.connect(max_attempts=max_attempts)
216
+
143
217
  async def __aenter__(self) -> VehicleBluetooth:
144
218
  """Enter the async context."""
145
219
  await self.connect()
@@ -149,66 +223,44 @@ class VehicleBluetooth(Commands):
149
223
  """Exit the async context."""
150
224
  await self.disconnect()
151
225
 
152
- async def _on_notify(self,sender: BleakGATTCharacteristic,data : bytearray) -> None:
226
+ def _on_notify(self,sender: BleakGATTCharacteristic, data: bytearray) -> None:
153
227
  """Receive data from the Tesla BLE device."""
154
- if self._recv_len:
155
- self._recv += data
156
- else:
157
- self._recv_len = int.from_bytes(data[:2], 'big')
158
- if self._recv_len > 1024:
159
- LOGGER.error("Parsed very large message length")
160
- self._recv = bytearray()
161
- self._recv_len = 0
162
- return
163
- self._recv = data[2:]
164
- #while len(self._recv) > self._recv_len:
165
- #
166
- # # Maybe this needs to trigger a reset
167
- # await self._on_message(bytes(self._recv[:self._recv_len]))
168
- # self._recv_len = int.from_bytes(self._recv[self._recv_len:self._recv_len+2], 'big')
169
- # self._recv = self._recv[self._recv_len+2:]
170
- # continue
171
- if len(self._recv) >= self._recv_len:
172
- if len(self._recv) > self._recv_len:
173
- LOGGER.debug(f"Received more data than expected: {len(self._recv)} > {self._recv_len}")
174
- try:
175
- msg = RoutableMessage.FromString(bytes(self._recv[:self._recv_len]))
176
- await self._on_message(msg)
177
- self._recv = bytearray()
178
- self._recv_len = 0
179
- except DecodeError:
180
- # Attempt parsing the whole payload
181
- msg = RoutableMessage.FromString(bytes(self._recv))
182
- LOGGER.warn(f"Parsed more data than length: {len(self._recv)} > {self._recv_len}")
183
- await self._on_message(msg)
184
- self._recv = bytearray()
185
- self._recv_len = 0
186
-
187
- async def _on_message(self, msg: RoutableMessage) -> None:
228
+ if sender.uuid != READ_UUID:
229
+ LOGGER.error(f"Unexpected sender: {sender}")
230
+ return
231
+ self._buffer.receive_data(data)
232
+
233
+ def _on_message(self, msg: RoutableMessage) -> None:
188
234
  """Receive messages from the Tesla BLE data."""
189
235
 
190
236
  if(msg.to_destination.routing_address != self._from_destination):
191
237
  # Ignore ephemeral key broadcasts
192
238
  return
193
239
 
194
- LOGGER.debug(f"Received response: {msg}")
195
- await self._queues[msg.from_destination.domain].put(msg)
240
+ LOGGER.info(f"Received response: {msg}")
241
+ self._queues[msg.from_destination.domain].put_nowait(msg)
196
242
 
197
- async def _send(self, msg: RoutableMessage, requires: str, timeout: int = 2) -> RoutableMessage:
243
+ async def _send(self, msg: RoutableMessage, requires: str, timeout: int = 5) -> RoutableMessage:
198
244
  """Serialize a message and send to the vehicle and wait for a response."""
245
+
199
246
  domain = msg.to_destination.domain
200
247
  async with self._sessions[domain].lock:
201
- LOGGER.debug(f"Sending message {msg}")
248
+ LOGGER.info(f"Sending message {msg}")
202
249
 
203
250
  payload = prependLength(msg.SerializeToString())
204
251
 
205
252
  # Empty the queue before sending the message
206
253
  while not self._queues[domain].empty():
207
- await self._queues[domain].get()
254
+ msg = await self._queues[domain].get()
255
+ LOGGER.warning(f"Discarded message {msg}")
256
+
257
+ await self.connect_if_needed()
258
+ assert self.client is not None
208
259
  await self.client.write_gatt_char(WRITE_UUID, payload, True)
209
260
 
210
261
  # Process the response
211
262
  async with asyncio.timeout(timeout):
263
+ LOGGER.info(f"Waiting for response with {requires}")
212
264
  while True:
213
265
  resp = await self._queues[domain].get()
214
266
  LOGGER.debug(f"Received message {resp}")
@@ -217,12 +269,16 @@ class VehicleBluetooth(Commands):
217
269
 
218
270
  if resp.HasField(requires):
219
271
  return resp
272
+ else:
273
+ LOGGER.warning(f"Ignoring message since it does not contain the required field {requires}, {resp.HasField(requires)}")
220
274
 
221
275
  async def query_display_name(self, max_attempts=5) -> str | None:
222
276
  """Read the device name via GATT characteristic if available"""
223
277
  for i in range(max_attempts):
224
278
  try:
225
279
  # Standard GATT Device Name characteristic (0x2A00)
280
+ await self.connect_if_needed()
281
+ assert self.client
226
282
  device_name = (await self.client.read_gatt_char(NAME_UUID)).decode('utf-8')
227
283
  if device_name.startswith("🔑 "):
228
284
  return device_name.replace("🔑 ","")
@@ -235,6 +291,8 @@ class VehicleBluetooth(Commands):
235
291
  """Read the device appearance via GATT characteristic if available"""
236
292
  try:
237
293
  # Standard GATT Appearance characteristic (0x2A01)
294
+ await self.connect_if_needed()
295
+ assert self.client
238
296
  return await self.client.read_gatt_char(APPEARANCE_UUID)
239
297
  except Exception as e:
240
298
  LOGGER.error(f"Failed to read device appearance: {e}")
@@ -244,6 +302,8 @@ class VehicleBluetooth(Commands):
244
302
  """Read the device version via GATT characteristic if available"""
245
303
  try:
246
304
  # Custom GATT Version characteristic (0x2A02)
305
+ await self.connect_if_needed()
306
+ assert self.client
247
307
  device_version = await self.client.read_gatt_char(VERSION_UUID)
248
308
  # Convert the bytes to an integer
249
309
  if device_version and len(device_version) > 0:
@@ -252,6 +312,11 @@ class VehicleBluetooth(Commands):
252
312
  LOGGER.error(f"Failed to read device version: {e}")
253
313
  return None
254
314
 
315
+ async def _command(self, domain: Domain, command: bytes, attempt: int = 0) -> dict[str, Any]:
316
+ """Serialize a message and send to the signed command endpoint."""
317
+ await self.connect_if_needed()
318
+ return await super()._command(domain, command, attempt)
319
+
255
320
  async def pair(self, role: Role = Role.ROLE_OWNER, form: KeyFormFactor = KeyFormFactor.KEY_FORM_FACTOR_CLOUD_KEY, timeout: int = 60):
256
321
  """Pair the key."""
257
322
 
@@ -269,7 +269,6 @@ class Commands(Vehicle):
269
269
  if msg.signedMessageStatus.signed_message_fault > 0:
270
270
  raise MESSAGE_FAULTS[msg.signedMessageStatus.signed_message_fault]
271
271
 
272
- @abstractmethod
273
272
  async def _command(self, domain: Domain, command: bytes, attempt: int = 0) -> dict[str, Any]:
274
273
  """Serialize a message and send to the signed command endpoint."""
275
274
  session = self._sessions[domain]
@@ -73,27 +73,3 @@ class VehiclesBluetooth(dict[str, Vehicle]):
73
73
  vehicle = self.Bluetooth(self._parent, vin, key, device)
74
74
  self[vin] = vehicle
75
75
  return vehicle
76
-
77
- async def query_display_name(self, device: BLEDevice, max_attempts=5) -> str | None:
78
- """Queries the name of a bluetooth vehicle."""
79
- client = await establish_connection(
80
- BleakClient,
81
- device,
82
- device.name or "Unknown",
83
- max_attempts=max_attempts
84
- )
85
- name: str | None = None
86
- for i in range(max_attempts):
87
- try:
88
- # Standard GATT Device Name characteristic (0x2A00)
89
- device_name = (await client.read_gatt_char(NAME_UUID)).decode('utf-8')
90
- if device_name.startswith("🔑 "):
91
- name = device_name.replace("🔑 ","")
92
- break
93
- await asyncio.sleep(1)
94
- LOGGER.debug(f"Attempt {i+1} to query display name failed, {device_name}")
95
- except Exception as e:
96
- LOGGER.error(f"Failed to read device name: {e}")
97
-
98
- await client.disconnect()
99
- return name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: tesla_fleet_api
3
- Version: 1.0.10
3
+ Version: 1.0.12
4
4
  Summary: Tesla Fleet API library for Python
5
5
  Home-page: https://github.com/Teslemetry/python-tesla-fleet-api
6
6
  Author: Brett Adams
@@ -1,9 +1,9 @@
1
1
  tesla_fleet_api/__init__.py,sha256=3DZMoZ-5srW-7SooAjqcRubQDuZPY8rMKH7eqIp4qtg,392
2
- tesla_fleet_api/const.py,sha256=iX1zonX_WX3NYpR_I9RAoVx3eM2iLHQjSD8C3QcBdTk,3749
2
+ tesla_fleet_api/const.py,sha256=GyNepuHCbWnq7w_1TgJT5PjBpTNQhoCZdWCwIPKz4a8,3749
3
3
  tesla_fleet_api/exceptions.py,sha256=GvjJBR77xGt2g3vhiAxcNtysj-e1Me7F-9PwO9dyzOo,35430
4
4
  tesla_fleet_api/ratecalculator.py,sha256=4lz8yruUeouHXh_3ezsXX-CTpIegp1T1J4VuRV_qdHA,1791
5
5
  tesla_fleet_api/tesla/__init__.py,sha256=Cvpqu8OaOFmbuwu9KjgYrje8eVluDp2IU_zwdtXbmO0,282
6
- tesla_fleet_api/tesla/bluetooth.py,sha256=vxDPTM-Uq9IAVJjK54-p_llj26hssEEXAQI5-lVGx8Q,1137
6
+ tesla_fleet_api/tesla/bluetooth.py,sha256=lyPRVf1YdcElrYBsKOMCaLwMPE9rO7Iw1a6nE7VUZ94,2369
7
7
  tesla_fleet_api/tesla/charging.py,sha256=D7I7cAf-3-95sIjyP6wpVqCq9Cppj6U-VPFQGpQQ8bs,1704
8
8
  tesla_fleet_api/tesla/energysite.py,sha256=vStffklBQfQNAO_1wrHLFu7BlBCTVVbLh7_IrAUL3wg,6131
9
9
  tesla_fleet_api/tesla/fleet.py,sha256=zfmagXF4TbbVOPWcngCSKebaGB1daXbw8mTJ9o8einY,5432
@@ -12,12 +12,12 @@ tesla_fleet_api/tesla/partner.py,sha256=e-l6sEP6-IupjFEQieSUjhhvRXF3aL4ebPNahcGF
12
12
  tesla_fleet_api/tesla/tesla.py,sha256=Gs8-L3OsEMhs1N_vdDx-E46bOHKGowXTBxmRiP8NKh4,2391
13
13
  tesla_fleet_api/tesla/user.py,sha256=w8rwiAOIFjuDus8M0RpZ0wucJtw8kYFKtJfYVk7Ekr0,1194
14
14
  tesla_fleet_api/tesla/vehicle/__init__.py,sha256=3A5_wTQHofRShof4pUNOtF78-7lUh62uz2jq2ecnmRY,381
15
- tesla_fleet_api/tesla/vehicle/bluetooth.py,sha256=CJgyE99Q4ezWjDjEaRD5hA6_-bHPpztXXdQcKQh9GZ0,18334
16
- tesla_fleet_api/tesla/vehicle/commands.py,sha256=_LXAtuAIkeojJuw8k1QjEabAQQH2MLD5YZyUH58QoSg,51288
15
+ tesla_fleet_api/tesla/vehicle/bluetooth.py,sha256=rMxKOrc8T3Wz_sbuGYFot4ffCbOVP2jFSCd5tVuSWv0,21035
16
+ tesla_fleet_api/tesla/vehicle/commands.py,sha256=L3apfGwry-IBuSSLbuoPPjW_q4new2xTr6Iy_kUa2pA,51268
17
17
  tesla_fleet_api/tesla/vehicle/fleet.py,sha256=K9BVZj6CChJSDSMFroa7Cz0KrsYWj32ILtQumarkLaU,32080
18
18
  tesla_fleet_api/tesla/vehicle/signed.py,sha256=RUzVnZIfykz3YZW2gaxd1iaN1i8LkLaEoiXrbqZn9kg,1339
19
19
  tesla_fleet_api/tesla/vehicle/vehicle.py,sha256=IBvRO6qGkUf4bi-pFongA9fIe9iScvz_wGXtzEJXilY,870
20
- tesla_fleet_api/tesla/vehicle/vehicles.py,sha256=jLE_T_fWEwFjuYU5_AlJ0KhEvWAPx2wybENESksJ2R8,3777
20
+ tesla_fleet_api/tesla/vehicle/vehicles.py,sha256=wU0wXa57BD7InaUC1xoRhVc7oI5CemsocSryuUItyGI,2792
21
21
  tesla_fleet_api/tesla/vehicle/proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  tesla_fleet_api/tesla/vehicle/proto/__init__.pyi,sha256=qFXWNIgl71wB260u-XPzaAwWAHL6krw21q-aXnBtop0,252
23
23
  tesla_fleet_api/tesla/vehicle/proto/car_server_pb2.py,sha256=v_eb4NDIkx_ZYPYW29_EFRky5vQ4b2q14gwuQSbouYw,29202
@@ -44,8 +44,8 @@ tesla_fleet_api/teslemetry/vehicles.py,sha256=9nybVg7VHKLa2woMG6fzMmQP6xJIE_jdAd
44
44
  tesla_fleet_api/tessie/__init__.py,sha256=9lhQJaB6X4PObUL9QdaaZYqs2BxiTidu3zmHcBESLVw,78
45
45
  tesla_fleet_api/tessie/tessie.py,sha256=uhg0oOIxpwDvlvdBhKHeF3AGR2PzmdBgzh2-_EkmSq0,2617
46
46
  tesla_fleet_api/tessie/vehicles.py,sha256=gfEatilI_ct-R4CM5xYhrlduqCR9IHlyc56WmJf7v7k,1149
47
- tesla_fleet_api-1.0.10.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
48
- tesla_fleet_api-1.0.10.dist-info/METADATA,sha256=Dr_QRBUiyoZ4PUgBBipQkoTBbqxW7qtE-vJ9GxzY8io,4383
49
- tesla_fleet_api-1.0.10.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
50
- tesla_fleet_api-1.0.10.dist-info/top_level.txt,sha256=jeNbog_1saXBFrGpom9WyPWmilxsyP3szL_G7JLWQfM,16
51
- tesla_fleet_api-1.0.10.dist-info/RECORD,,
47
+ tesla_fleet_api-1.0.12.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
48
+ tesla_fleet_api-1.0.12.dist-info/METADATA,sha256=Z3qVhTn4BJs_P54gNAn4W02VUgvFhXkRh7yTveJvPn4,4383
49
+ tesla_fleet_api-1.0.12.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
50
+ tesla_fleet_api-1.0.12.dist-info/top_level.txt,sha256=jeNbog_1saXBFrGpom9WyPWmilxsyP3szL_G7JLWQfM,16
51
+ tesla_fleet_api-1.0.12.dist-info/RECORD,,