Skip to content

Commit cdff21f

Browse files
committed
Working Bluetooth
1 parent 5457668 commit cdff21f

File tree

3 files changed

+83
-91
lines changed

3 files changed

+83
-91
lines changed

tesla_fleet_api/tesla/bluetooth.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Bluetooth only interface."""
22

3+
import re
34
from tesla_fleet_api.tesla.tesla import Tesla
45
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
56

@@ -13,6 +14,10 @@ def __init__(
1314

1415
self.vehicles = Vehicles(self)
1516

17+
def valid_name(self, name: str) -> bool:
18+
"""Check if a BLE device name is a valid Tesla vehicle."""
19+
return bool(re.match("^S[a-f0-9]{16}[A-F]$", name))
20+
1621
class Vehicles(dict[str, VehicleBluetooth]):
1722
"""Class containing and creating vehicles."""
1823

tesla_fleet_api/tesla/vehicle/bluetooth.py

Lines changed: 26 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
from __future__ import annotations
22

33
import hashlib
4-
import hmac
5-
import struct
6-
from asyncio import Future, sleep, get_running_loop
7-
from random import randbytes
8-
from typing import TYPE_CHECKING, Any, List
4+
from asyncio import Future, get_running_loop
5+
from typing import TYPE_CHECKING
96
from google.protobuf.message import DecodeError
107

118
from bleak import BleakClient, BleakScanner
@@ -22,50 +19,29 @@
2219
)
2320
from ...exceptions import (
2421
MESSAGE_FAULTS,
25-
SIGNED_MESSAGE_INFORMATION_FAULTS,
2622
WHITELIST_OPERATION_STATUS,
27-
TeslaFleetMessageFaultIncorrectEpoch,
28-
TeslaFleetMessageFaultInvalidTokenOrCounter,
2923
)
3024

3125
# Protocol
32-
from .proto.errors_pb2 import GenericError_E
3326
from .proto.car_server_pb2 import (
3427
Response,
3528
)
3629
from .proto.signatures_pb2 import (
37-
SIGNATURE_TYPE_HMAC_PERSONALIZED,
38-
TAG_COUNTER,
39-
TAG_DOMAIN,
40-
TAG_END,
41-
TAG_EPOCH,
42-
TAG_EXPIRES_AT,
43-
TAG_PERSONALIZATION,
44-
TAG_SIGNATURE_TYPE,
4530
SessionInfo,
4631
)
4732
from .proto.universal_message_pb2 import (
48-
DOMAIN_INFOTAINMENT,
49-
DOMAIN_VEHICLE_SECURITY,
50-
OPERATIONSTATUS_ERROR,
51-
OPERATIONSTATUS_WAIT,
5233
Destination,
5334
Domain,
5435
RoutableMessage,
5536
)
5637
from .proto.vcsec_pb2 import (
57-
OPERATIONSTATUS_OK,
5838
FromVCSECMessage,
5939
KeyFormFactor,
6040
KeyMetadata,
6141
PermissionChange,
6242
PublicKey,
63-
SignatureType,
64-
SignedMessage,
65-
ToVCSECMessage,
6643
UnsignedMessage,
6744
WhitelistOperation,
68-
WhitelistOperation_information_E,
6945

7046
)
7147

@@ -84,9 +60,9 @@ def prependLength(message: bytes) -> bytearray:
8460
class VehicleBluetooth(Commands):
8561
"""Class describing the Tesla Fleet API vehicle endpoints and commands for a specific vehicle with command signing."""
8662

87-
_ble_name: str
63+
ble_name: str
64+
client: BleakClient
8865
_device: BLEDevice
89-
_client: BleakClient
9066
_futures: dict[Domain, Future]
9167
_ekey: ec.EllipticCurvePublicKey
9268
_recv: bytearray = bytearray()
@@ -97,24 +73,30 @@ def __init__(
9773
self, parent: Tesla, vin: str, key: ec.EllipticCurvePrivateKey | None = None
9874
):
9975
super().__init__(parent, vin, key)
100-
self._ble_name = "S" + hashlib.sha1(vin.encode('utf-8')).hexdigest()[:16] + "C"
76+
self.ble_name = "S" + hashlib.sha1(vin.encode('utf-8')).hexdigest()[:16] + "C"
10177
self._futures = {}
10278

10379
async def discover(self, scanner: BleakScanner = BleakScanner()) -> BleakClient:
10480
"""Find the Tesla BLE device."""
10581

106-
device = await scanner.find_device_by_name(self._ble_name)
82+
device = await scanner.find_device_by_name(self.ble_name)
10783
if not device:
108-
raise ValueError(f"Device {self._ble_name} not found")
84+
raise ValueError(f"Device {self.ble_name} not found")
10985
self._device = device
110-
self._client = BleakClient(self._device)
86+
self.client = BleakClient(self._device, services=[SERVICE_UUID])
11187
LOGGER.info(f"Discovered device {self._device.name} {self._device.address}")
112-
return self._client
88+
return self.client
11389

114-
async def connect(self):
90+
async def connect(self, mac:str | None = None) -> None:
11591
"""Connect to the Tesla BLE device."""
116-
await self._client.connect()
117-
await self._client.start_notify(READ_UUID, self._on_notify)
92+
if mac is not None:
93+
self.client = BleakClient(mac, services=[SERVICE_UUID])
94+
await self.client.connect()
95+
await self.client.start_notify(READ_UUID, self._on_notify)
96+
97+
async def disconnect(self) -> bool:
98+
"""Disconnect from the Tesla BLE device."""
99+
return await self.client.disconnect()
118100

119101
def _on_notify(self,sender: BleakGATTCharacteristic,data : bytearray):
120102
"""Receive data from the Tesla BLE device."""
@@ -126,19 +108,19 @@ def _on_notify(self,sender: BleakGATTCharacteristic,data : bytearray):
126108
LOGGER.debug(f"Received {len(self._recv)} of {self._recv_len} bytes")
127109
while len(self._recv) > self._recv_len:
128110
LOGGER.warn(f"Received more data than expected: {len(self._recv)} > {self._recv_len}")
129-
self._on_message(self._recv[:self._recv_len])
111+
self._on_message(bytes(self._recv[:self._recv_len]))
130112
self._recv_len = int.from_bytes(self._recv[self._recv_len:self._recv_len+2], 'big')
131113
self._recv = self._recv[self._recv_len+2:]
132114
continue
133115
if len(self._recv) == self._recv_len:
134-
self._on_message(self._recv)
116+
self._on_message(bytes(self._recv))
135117
self._recv = bytearray()
136118
self._recv_len = 0
137119

138-
def _on_message(self, data:bytearray):
120+
def _on_message(self, data:bytes):
139121
"""Receive messages from the Tesla BLE data."""
140122
try:
141-
msg = RoutableMessage.FromString(bytes(data))
123+
msg = RoutableMessage.FromString(data)
142124
except DecodeError as e:
143125
LOGGER.error(f"Error parsing message: {e}")
144126
return
@@ -165,12 +147,6 @@ def _on_message(self, data:bytearray):
165147
self._futures[msg.from_destination.domain].set_result(msg)
166148
return
167149

168-
169-
async def disconnect(self):
170-
"""Disconnect from the Tesla BLE device."""
171-
await self._client.stop_notify(READ_UUID)
172-
await self._client.disconnect()
173-
174150
async def _create_future(self, domain: Domain) -> Future:
175151
if(not self._sessions[domain].lock.locked):
176152
raise ValueError("Session is not locked")
@@ -185,7 +161,9 @@ async def _send(self, msg: RoutableMessage) -> RoutableMessage:
185161
future = await self._create_future(domain)
186162
payload = prependLength(msg.SerializeToString())
187163
LOGGER.info(f"Payload: {payload}")
188-
await self._client.write_gatt_char(WRITE_UUID, payload, False)
164+
165+
await self.client.write_gatt_char(WRITE_UUID, payload, True)
166+
189167
resp = await future
190168
LOGGER.info(f"Received message {resp}")
191169

@@ -194,7 +172,7 @@ async def _send(self, msg: RoutableMessage) -> RoutableMessage:
194172

195173
return resp
196174

197-
async def pair(self, role: Role = Role.ROLE_DRIVER, form: KeyFormFactor = KeyFormFactor.KEY_FORM_FACTOR_ANDROID_DEVICE):
175+
async def pair(self, role: Role = Role.ROLE_OWNER, form: KeyFormFactor = KeyFormFactor.KEY_FORM_FACTOR_CLOUD_KEY):
198176
"""Pair the key."""
199177

200178
request = UnsignedMessage(

tesla_fleet_api/tesla/vehicle/commands.py

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from asyncio import Lock, sleep
1515

1616
from ...exceptions import (
17-
MESSAGE_FAULTS,
1817
SIGNED_MESSAGE_INFORMATION_FAULTS,
1918
TeslaFleetMessageFaultIncorrectEpoch,
2019
TeslaFleetMessageFaultInvalidTokenOrCounter,
@@ -38,7 +37,6 @@
3837
Response,
3938
)
4039
from .proto.signatures_pb2 import (
41-
SIGNATURE_TYPE_AES_GCM,
4240
SIGNATURE_TYPE_AES_GCM_PERSONALIZED,
4341
SIGNATURE_TYPE_HMAC_PERSONALIZED,
4442
TAG_COUNTER,
@@ -49,24 +47,24 @@
4947
TAG_PERSONALIZATION,
5048
TAG_SIGNATURE_TYPE,
5149
AES_GCM_Personalized_Signature_Data,
50+
KeyIdentity,
5251
SessionInfo,
52+
SignatureData,
5353
)
5454
from .proto.universal_message_pb2 import (
5555
DOMAIN_INFOTAINMENT,
5656
DOMAIN_VEHICLE_SECURITY,
5757
OPERATIONSTATUS_ERROR,
5858
OPERATIONSTATUS_WAIT,
59+
Destination,
5960
Domain,
6061
RoutableMessage,
62+
SessionInfoRequest,
6163
)
6264
from .proto.vcsec_pb2 import (
6365
OPERATIONSTATUS_OK,
6466
FromVCSECMessage,
6567
)
66-
from .proto.universal_message_pb2 import (
67-
Domain,
68-
RoutableMessage,
69-
)
7068
from .proto.car_server_pb2 import (
7169
Action,
7270
MediaPlayAction,
@@ -98,7 +96,7 @@
9896
VehicleControlWindowAction,
9997
HvacBioweaponModeAction,
10098
AutoSeatClimateAction,
101-
# Ping,
99+
Ping,
102100
ScheduledChargingAction,
103101
ScheduledDepartureAction,
104102
HvacClimateKeeperAction,
@@ -122,7 +120,6 @@
122120
ClosureMoveType_E,
123121
)
124122
from .proto.signatures_pb2 import (
125-
SessionInfo,
126123
HMAC_Personalized_Signature_Data,
127124
)
128125
from .proto.common_pb2 import (
@@ -200,7 +197,7 @@ def update(self, sessionInfo: SessionInfo):
200197

201198
def hmac_personalized(self) -> HMAC_Personalized_Signature_Data:
202199
"""Sign a command and return session metadata"""
203-
self.counter = self.counter+1
200+
self.counter += 1
204201
return HMAC_Personalized_Signature_Data(
205202
epoch=self.epoch,
206203
counter=self.counter,
@@ -209,7 +206,7 @@ def hmac_personalized(self) -> HMAC_Personalized_Signature_Data:
209206

210207
def aes_gcm_personalized(self) -> AES_GCM_Personalized_Signature_Data:
211208
"""Sign a command and return session metadata"""
212-
self.counter = self.counter+1
209+
self.counter += 1
213210
return AES_GCM_Personalized_Signature_Data(
214211
epoch=self.epoch,
215212
nonce=randbytes(12),
@@ -381,15 +378,22 @@ async def _commandHmac(self, session: Session, command: bytes, attempt: int = 1)
381378
session.hmac, metadata + command, hashlib.sha256
382379
).digest()
383380

384-
msg = RoutableMessage()
385-
msg.to_destination.domain = session.domain
386-
msg.from_destination.routing_address = self._from_destination
387-
msg.protobuf_message_as_bytes = command
388-
msg.uuid = randbytes(16)
389-
msg.signature_data.HMAC_Personalized_data.CopyFrom(hmac_personalized)
390-
msg.signature_data.signer_identity.public_key = self._public_key
391-
392-
return msg
381+
return RoutableMessage(
382+
to_destination=Destination(
383+
domain=session.domain,
384+
),
385+
from_destination=Destination(
386+
routing_address=self._from_destination
387+
),
388+
protobuf_message_as_bytes=command,
389+
uuid=randbytes(16),
390+
signature_data=SignatureData(
391+
signer_identity=KeyIdentity(
392+
public_key=self._public_key
393+
),
394+
HMAC_Personalized_data=hmac_personalized,
395+
)
396+
)
393397

394398
async def _commandAes(self, session: Session, command: bytes, attempt: int = 1) -> RoutableMessage:
395399
"""Create an encrypted message."""
@@ -428,15 +432,22 @@ async def _commandAes(self, session: Session, command: bytes, attempt: int = 1)
428432
aes_personalized.tag = ct[-16:]
429433

430434
# I think this whole section could be improved
431-
msg = RoutableMessage()
432-
msg.to_destination.domain = session.domain
433-
msg.from_destination.routing_address = self._from_destination
434-
msg.protobuf_message_as_bytes = ct[:-16]
435-
msg.uuid = randbytes(16)
436-
msg.signature_data.AES_GCM_Personalized_data.CopyFrom(aes_personalized)
437-
msg.signature_data.signer_identity.public_key = self._public_key
438-
439-
return msg
435+
return RoutableMessage(
436+
to_destination=Destination(
437+
domain=session.domain,
438+
),
439+
from_destination=Destination(
440+
routing_address=self._from_destination
441+
),
442+
protobuf_message_as_bytes=ct[:-16],
443+
uuid=randbytes(16),
444+
signature_data=SignatureData(
445+
signer_identity=KeyIdentity(
446+
public_key=self._public_key
447+
),
448+
AES_GCM_Personalized_data=aes_personalized,
449+
)
450+
)
440451

441452

442453
async def _sendVehicleSecurity(self, command: UnsignedMessage) -> dict[str, Any]:
@@ -448,33 +459,31 @@ async def _sendInfotainment(self, command: Action) -> dict[str, Any]:
448459
return await self._command(Domain.DOMAIN_INFOTAINMENT, command.SerializeToString())
449460

450461

451-
async def _handle_command(self, domain: Domain, command: str, attempt: int, retry) -> dict[str, Any]:
452-
"""Handle a command."""
453-
if attempt > MAX_RETRIES:
454-
return {"response": {"result": False, "reason": "Too many retries"}}
455-
async with session.lock:
456-
await sleep(2)
457-
return await self._commandAes(domain, command, attempt)
458-
459462
async def _handshake(self, domain: Domain) -> None:
460463
"""Perform a handshake with the vehicle."""
461464

462465
LOGGER.debug(f"Handshake with domain {Domain.Name(domain)}")
463-
msg = RoutableMessage()
464-
msg.to_destination.domain = domain
465-
msg.from_destination.routing_address = self._from_destination
466-
msg.session_info_request.public_key = self._public_key
467-
msg.uuid = randbytes(16)
466+
msg = RoutableMessage(
467+
to_destination=Destination(
468+
domain=domain,
469+
),
470+
from_destination=Destination(
471+
routing_address=self._from_destination
472+
),
473+
session_info_request=SessionInfoRequest(
474+
public_key=self._public_key
475+
),
476+
uuid=randbytes(16)
477+
)
468478

469-
# Send handshake message
470479
await self._send(msg)
471480

472481
async def ping(self) -> dict[str, Any]:
473482
"""Ping the vehicle."""
474483
return await self._sendInfotainment(
475484
Action(
476485
vehicleAction=VehicleAction(
477-
ping=None
486+
ping=Ping(ping_id=0)
478487
)
479488
)
480489
)

0 commit comments

Comments
 (0)