Skip to content

Commit fc67c1b

Browse files
committed
cli.oad: add flash command
This adds a new `pybricksdev oad flash` command that can be used to flash a TI OAD firmware image to a device that supports the TI OAD profile.
1 parent e517d53 commit fc67c1b

File tree

9 files changed

+536
-85
lines changed

9 files changed

+536
-85
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
### Added
1010
- Added `pybricksdev oad info` command.
11+
- Added `pybricksdev oad flash` command.
1112

1213
## [1.0.0-alpha.50] - 2024-07-01
1314

pybricksdev/ble/oad/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,18 @@
77
https://software-dl.ti.com/lprf/sdg-latest/html/oad-ble-stack-3.x/oad_profile.html
88
"""
99

10-
from ._common import oad_uuid
10+
from ._common import OADReturn, oad_uuid
11+
from .control_point import OADControlPoint
12+
from .image_block import OADImageBlock
13+
from .image_identify import OADImageIdentify
1114

12-
__all__ = ["OAD_SERVICE_UUID"]
15+
__all__ = [
16+
"OAD_SERVICE_UUID",
17+
"OADReturn",
18+
"OADImageBlock",
19+
"OADControlPoint",
20+
"OADImageIdentify",
21+
]
1322

1423
OAD_SERVICE_UUID = oad_uuid(0xFFC0)
1524
"""OAD service UUID."""

pybricksdev/ble/oad/_common.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,198 @@
22
# Copyright (c) 2024 The Pybricks Authors
33

44

5+
import struct
6+
from enum import IntEnum
7+
from typing import NamedTuple
8+
9+
510
def oad_uuid(uuid16: int) -> str:
611
"""
712
Converts a 16-bit UUID to the TI OAD 128-bit UUID format.
813
"""
914
return "f000{:04x}-0451-4000-b000-000000000000".format(uuid16)
15+
16+
17+
IMAGE_ID_TI = " OAD IMG" # leading space is intentional
18+
IMAGE_ID_LEGO = "LEGO 132"
19+
20+
21+
class ImageType(IntEnum):
22+
PERSISTENT_APP = 0x00
23+
APPLICATION = 0x01
24+
STACK = 0x02
25+
APP_AND_STACK = 0x03
26+
NETWORK_PROCESSOR = 0x04
27+
BLE_FACTORY_IMAGE = 0x05
28+
BIM = 0x06
29+
MERGED = 0x07
30+
31+
USER_0F = 0x0F
32+
USER_10 = 0x10
33+
USER_11 = 0x11
34+
USER_12 = 0x12
35+
USER_13 = 0x13
36+
USER_14 = 0x14
37+
USER_15 = 0x15
38+
USER_16 = 0x16
39+
USER_17 = 0x17
40+
USER_18 = 0x18
41+
USER_19 = 0x19
42+
USER_1A = 0x1A
43+
USER_1B = 0x1B
44+
USER_1C = 0x1C
45+
USER_1D = 0x1D
46+
USER_1E = 0x1E
47+
USER_1F = 0x1F
48+
49+
HOST_20 = 0x20
50+
HOST_21 = 0x21
51+
HOST_22 = 0x22
52+
HOST_23 = 0x23
53+
HOST_24 = 0x24
54+
HOST_25 = 0x25
55+
HOST_26 = 0x26
56+
HOST_27 = 0x27
57+
HOST_28 = 0x28
58+
HOST_29 = 0x29
59+
HOST_2A = 0x2A
60+
HOST_2B = 0x2B
61+
HOST_2C = 0x2C
62+
HOST_2D = 0x2D
63+
HOST_2E = 0x2E
64+
HOST_2F = 0x2F
65+
HOST_30 = 0x30
66+
HOST_31 = 0x31
67+
HOST_32 = 0x32
68+
HOST_33 = 0x33
69+
HOST_34 = 0x34
70+
HOST_35 = 0x35
71+
HOST_36 = 0x36
72+
HOST_37 = 0x37
73+
HOST_38 = 0x38
74+
HOST_39 = 0x39
75+
HOST_3A = 0x3A
76+
HOST_3B = 0x3B
77+
HOST_3C = 0x3C
78+
HOST_3D = 0x3D
79+
HOST_3E = 0x3E
80+
HOST_3F = 0x3F
81+
82+
83+
class ImageCopyStatus(IntEnum):
84+
DEFAULT_STATUS = 0xFF
85+
IMAGE_TO_BE_COPIED = 0xFE
86+
IMAGE_COPIED = 0xFC
87+
88+
89+
class CRCStatus(IntEnum):
90+
INVALID = 0b00
91+
VALID = 0b01
92+
NOT_CALCULATED = 0b11
93+
94+
UNKNOWN = 0xFF
95+
96+
97+
DEFAULT_IMAGE_NUMBER = 0xFF
98+
99+
100+
class ImageInfo(NamedTuple):
101+
copy_status: ImageCopyStatus
102+
crc_status: CRCStatus
103+
image_type: ImageType
104+
image_num: int
105+
106+
@staticmethod
107+
def from_bytes(data: bytes) -> "ImageInfo":
108+
if len(data) != 4:
109+
raise ValueError("Expected 4 bytes")
110+
111+
return ImageInfo(
112+
ImageCopyStatus(data[0]),
113+
CRCStatus(data[1]),
114+
ImageType(data[2]),
115+
data[3],
116+
)
117+
118+
def __bytes__(self):
119+
return struct.pack(
120+
"<BBBB",
121+
self.copy_status,
122+
self.crc_status,
123+
self.image_type,
124+
self.image_num,
125+
)
126+
127+
128+
class Version(NamedTuple):
129+
major: int
130+
minor: int
131+
132+
133+
def _encode_version(version: int) -> int:
134+
return ((version // 10) << 4) | (version % 10)
135+
136+
137+
def _decode_version(v: int) -> int:
138+
return (v >> 4) * 10 + (v & 0x0F)
139+
140+
141+
class SoftwareVersion(NamedTuple):
142+
app: Version
143+
stack: Version
144+
145+
@staticmethod
146+
def from_bytes(data: bytes) -> "SoftwareVersion":
147+
if len(data) != 4:
148+
raise ValueError("Expected 4 bytes")
149+
150+
return SoftwareVersion(
151+
Version(_decode_version(data[0]), _decode_version(data[1])),
152+
Version(_decode_version(data[2]), _decode_version(data[3])),
153+
)
154+
155+
def __bytes__(self):
156+
return struct.pack(
157+
"<4B",
158+
_encode_version(self.app.major),
159+
_encode_version(self.app.minor),
160+
_encode_version(self.stack.major),
161+
_encode_version(self.stack.minor),
162+
)
163+
164+
165+
class OADReturn(IntEnum):
166+
SUCCESS = 0
167+
"""OAD succeeded"""
168+
CRC_ERR = 1
169+
"""The downloaded image’s CRC doesn’t match the one expected from the metadata"""
170+
FLASH_ERR = 2
171+
"""Flash function failure such as flashOpen/flashRead/flash write/flash erase"""
172+
BUFFER_OFL = 3
173+
"""The block number of the received packet doesn’t match the one requested, an overflow has occurred."""
174+
ALREADY_STARTED = 4
175+
"""OAD start command received, while OAD is already is progress"""
176+
NOT_STARTED = 5
177+
"""OAD data block received with OAD start process"""
178+
DL_NOT_COMPLETE = 6
179+
"""OAD enable command received without complete OAD image download"""
180+
NO_RESOURCES = 7
181+
"""Memory allocation fails/ used only for backward compatibility"""
182+
IMAGE_TOO_BIG = 8
183+
"""Image is too big"""
184+
INCOMPATIBLE_IMAGE = 9
185+
"""Stack and flash boundary mismatch, program entry mismatch"""
186+
INVALID_FILE = 10
187+
"""Invalid image ID received"""
188+
INCOMPATIBLE_FILE = 11
189+
"""BIM/image header/firmware version mismatch"""
190+
AUTH_FAIL = 12
191+
"""Start OAD process / Image Identify message/image payload authentication/validation fail"""
192+
EXT_NOT_SUPPORTED = 13
193+
"""Data length extension or OAD control point characteristic not supported"""
194+
DL_COMPLETE = 14
195+
"""OAD image payload download complete"""
196+
CCCD_NOT_ENABLED = 15
197+
"""Internal (target side) error code used to halt the process if a CCCD has not been enabled"""
198+
IMG_ID_TIMEOUT = 16
199+
"""OAD Image ID has been tried too many times and has timed out. Device will disconnect."""

pybricksdev/ble/oad/control_point.py

Lines changed: 25 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
# Copyright (c) 2024 The Pybricks Authors
33

44
import asyncio
5-
import struct
65
from enum import IntEnum
6+
from typing import AsyncGenerator
77

88
from bleak import BleakClient
99

10-
from ._common import oad_uuid
10+
from ._common import OADReturn, SoftwareVersion, oad_uuid
1111

1212
__all__ = ["OADControlPoint"]
1313

@@ -31,45 +31,11 @@ class CmdId(IntEnum):
3131
ERASE_ALL_BONDS = 0x13
3232

3333

34-
class OADReturn(IntEnum):
35-
SUCCESS = 0
36-
"""OAD succeeded"""
37-
CRC_ERR = 1
38-
"""The downloaded image’s CRC doesn’t match the one expected from the metadata"""
39-
FLASH_ERR = 2
40-
"""Flash function failure such as flashOpen/flashRead/flash write/flash erase"""
41-
BUFFER_OFL = 3
42-
"""The block number of the received packet doesn’t match the one requested, an overflow has occurred."""
43-
ALREADY_STARTED = 4
44-
"""OAD start command received, while OAD is already is progress"""
45-
NOT_STARTED = 5
46-
"""OAD data block received with OAD start process"""
47-
DL_NOT_COMPLETE = 6
48-
"""OAD enable command received without complete OAD image download"""
49-
NO_RESOURCES = 7
50-
"""Memory allocation fails/ used only for backward compatibility"""
51-
IMAGE_TOO_BIG = 8
52-
"""Image is too big"""
53-
INCOMPATIBLE_IMAGE = 9
54-
"""Stack and flash boundary mismatch, program entry mismatch"""
55-
INVALID_FILE = 10
56-
"""Invalid image ID received"""
57-
INCOMPATIBLE_FILE = 11
58-
"""BIM/image header/firmware version mismatch"""
59-
AUTH_FAIL = 12
60-
"""Start OAD process / Image Identify message/image payload authentication/validation fail"""
61-
EXT_NOT_SUPPORTED = 13
62-
"""Data length extension or OAD control point characteristic not supported"""
63-
DL_COMPLETE = 14
64-
"""OAD image payload download complete"""
65-
CCCD_NOT_ENABLED = 15
66-
"""Internal (target side) error code used to halt the process if a CCCD has not been enabled"""
67-
IMG_ID_TIMEOUT = 16
68-
"""OAD Image ID has been tried too many times and has timed out. Device will disconnect."""
69-
70-
71-
def _decode_version(v: int) -> int:
72-
return (v >> 4) * 10 + (v & 0x0F)
34+
OAD_LEGO_MARIO_DEVICE_TYPE = 0xFF150409
35+
"""Device type for LEGO Mario and friends."""
36+
37+
OAD_LEGO_TECHNIC_MOVE_DEVICE_TYPE = 0xFF160409
38+
"""Device type for LEGO Technic Move Hub."""
7339

7440

7541
class OADControlPoint:
@@ -91,7 +57,7 @@ def _notification_handler(self, sender, data):
9157

9258
async def _send_command(self, cmd_id: CmdId, payload: bytes = b""):
9359
await self._client.write_gatt_char(
94-
OAD_CONTROL_POINT_CHAR_UUID, bytes([cmd_id]) + payload
60+
OAD_CONTROL_POINT_CHAR_UUID, bytes([cmd_id]) + payload, response=False
9561
)
9662
rsp = await self._queue.get()
9763

@@ -129,18 +95,28 @@ async def set_image_count(self, count: int) -> OADReturn:
12995

13096
return OADReturn(rsp[0])
13197

132-
async def start_oad_process(self) -> int:
98+
async def start_oad_process(self) -> AsyncGenerator[tuple[OADReturn, int], None]:
13399
"""
134100
Start the OAD process.
135101
136102
Returns: Block Number
137103
"""
138-
rsp = await self._send_command(CmdId.START_OAD_PROCESS)
104+
await self._client.write_gatt_char(
105+
OAD_CONTROL_POINT_CHAR_UUID,
106+
bytes([CmdId.START_OAD_PROCESS]),
107+
response=False,
108+
)
139109

140-
if len(rsp) != 4:
141-
raise RuntimeError(f"Unexpected response: {rsp.hex(':')}")
110+
while True:
111+
rsp = await self._queue.get()
142112

143-
return int.from_bytes(rsp, "little")
113+
if len(rsp) != 6 or rsp[0] != CmdId.IMAGE_BLOCK_WRITE_CHAR:
114+
raise RuntimeError(f"Unexpected response: {rsp.hex(':')}")
115+
116+
status = OADReturn(rsp[1])
117+
block_num = int.from_bytes(rsp[2:], "little")
118+
119+
yield status, block_num
144120

145121
async def enable_oad_image(self) -> OADReturn:
146122
"""
@@ -182,7 +158,7 @@ async def disable_oad_image_block_write(self) -> OADReturn:
182158

183159
return OADReturn(rsp[0])
184160

185-
async def get_software_version(self) -> tuple[tuple[int, int], tuple[int, int]]:
161+
async def get_software_version(self) -> SoftwareVersion:
186162
"""
187163
Get the software version.
188164
@@ -193,10 +169,7 @@ async def get_software_version(self) -> tuple[tuple[int, int], tuple[int, int]]:
193169
if len(rsp) != 4:
194170
raise RuntimeError(f"Unexpected response: {rsp.hex(':')}")
195171

196-
return (
197-
(_decode_version(rsp[0]), _decode_version(rsp[1])),
198-
(_decode_version(rsp[2]), _decode_version(rsp[3])),
199-
)
172+
return SoftwareVersion.from_bytes(rsp)
200173

201174
async def get_oad_image_status(self) -> OADReturn:
202175
"""
@@ -237,21 +210,6 @@ async def get_device_type(self) -> int:
237210

238211
return int.from_bytes(rsp, "little")
239212

240-
async def image_block_write(self, prev_status: int, block_num: int) -> None:
241-
"""
242-
Write an image block.
243-
244-
Args:
245-
prev_status: Status of the previous block received
246-
block_num: Block number
247-
"""
248-
rsp = await self._send_command(
249-
CmdId.IMAGE_BLOCK_WRITE_CHAR, struct.pack("<BI", prev_status, block_num)
250-
)
251-
252-
if len(rsp) != 0:
253-
raise RuntimeError(f"Unexpected response: {rsp.hex(':')}")
254-
255213
async def erase_all_bonds(self) -> OADReturn:
256214
"""
257215
Erase all bonds.

0 commit comments

Comments
 (0)