odrive 0.6.9.dev0__py37-none-win_amd64.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.
- odrive/__init__.py +142 -0
- odrive/api_client.py +54 -0
- odrive/config.py +1296 -0
- odrive/crypto.py +369 -0
- odrive/data/brakeRs.json +18 -0
- odrive/data/drvs.json +82 -0
- odrive/data/encoders.json +86 -0
- odrive/data/motors.json +128 -0
- odrive/data/odrive-micro-x1.json +48 -0
- odrive/data/odrive-micro-x3.json +40 -0
- odrive/data/odrive-micro-x4.json +40 -0
- odrive/data/odrive-s1-x4.json +52 -0
- odrive/data/odrive-v4.4.json +58 -0
- odrive/data/schema-brakeR.json +6 -0
- odrive/data/schema-drv.json +6 -0
- odrive/data/schema-encoders.json +6 -0
- odrive/data/schema-motors.json +6 -0
- odrive/data/schema-odrive.json +5 -0
- odrive/data/schema.json +244 -0
- odrive/database.py +184 -0
- odrive/dfu.py +195 -0
- odrive/dfuse/DfuDevice.py +297 -0
- odrive/dfuse/__init__.py +1 -0
- odrive/enums.py +415 -0
- odrive/firmware.py +102 -0
- odrive/hw_version.py +67 -0
- odrive/legacy.py +73 -0
- odrive/legacy_config.py +123 -0
- odrive/legacy_dfu.py +442 -0
- odrive/lib/libodrive-windows-x64.dll +0 -0
- odrive/libodrive.py +325 -0
- odrive/pyfibre/fibre/__init__.py +4 -0
- odrive/pyfibre/fibre/libfibre-windows-amd64.dll +0 -0
- odrive/pyfibre/fibre/libfibre.py +1078 -0
- odrive/pyfibre/fibre/libwinpthread-1.dll +0 -0
- odrive/pyfibre/fibre/shell.py +162 -0
- odrive/pyfibre/fibre/utils.py +133 -0
- odrive/release_api.py +184 -0
- odrive/rich_text.py +134 -0
- odrive/shell.py +144 -0
- odrive/utils.py +881 -0
- odrive/version.py +1 -0
- odrive-0.6.9.dev0.data/scripts/odrive_demo.py +50 -0
- odrive-0.6.9.dev0.data/scripts/odrivetool +263 -0
- odrive-0.6.9.dev0.data/scripts/odrivetool.bat +2 -0
- odrive-0.6.9.dev0.dist-info/METADATA +22 -0
- odrive-0.6.9.dev0.dist-info/RECORD +49 -0
- odrive-0.6.9.dev0.dist-info/WHEEL +5 -0
- odrive-0.6.9.dev0.dist-info/top_level.txt +1 -0
odrive/database.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from odrive.hw_version import HwVersion
|
|
4
|
+
|
|
5
|
+
script_dir = os.path.dirname(os.path.realpath(__file__))
|
|
6
|
+
|
|
7
|
+
class NotFoundError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class Database():
|
|
11
|
+
def __init__(self, data):
|
|
12
|
+
self._data = data
|
|
13
|
+
|
|
14
|
+
def get_odrive_versions(self):
|
|
15
|
+
"""
|
|
16
|
+
Returns all known ODrive board versions and their data as a collection
|
|
17
|
+
of tuples.
|
|
18
|
+
"""
|
|
19
|
+
print("TODO: deprecated")
|
|
20
|
+
return self._data['odrives'].items()
|
|
21
|
+
|
|
22
|
+
def get_products(self):
|
|
23
|
+
"""
|
|
24
|
+
Returns all known ODrive Robotics product line and version combinations.
|
|
25
|
+
The return type is a generator of tuples of the form
|
|
26
|
+
(product_name, product_data).
|
|
27
|
+
"""
|
|
28
|
+
return [(k, v) for k, v in self._data['odrives'].items()]
|
|
29
|
+
|
|
30
|
+
def get_product(self, board: HwVersion):
|
|
31
|
+
"""
|
|
32
|
+
Loads data for a particular ODrive Robotics board.
|
|
33
|
+
board: e.g. (4, 4, 58)
|
|
34
|
+
"""
|
|
35
|
+
assert isinstance(board, HwVersion)
|
|
36
|
+
key = board
|
|
37
|
+
if not key in self._data['odrives']:
|
|
38
|
+
raise NotFoundError(f"{key} not found in product database")
|
|
39
|
+
return self._data['odrives'][key]
|
|
40
|
+
|
|
41
|
+
def get_motor(self, name: str):
|
|
42
|
+
"""
|
|
43
|
+
Loads data for a particular motor model.
|
|
44
|
+
name: e.g. "D6374-150KV"
|
|
45
|
+
"""
|
|
46
|
+
return self._data['motors'][name]
|
|
47
|
+
|
|
48
|
+
def get_brakeR(self, name: str):
|
|
49
|
+
"""
|
|
50
|
+
Loads data for a particular brake resistor.
|
|
51
|
+
name: e.g. "500w2rj"
|
|
52
|
+
"""
|
|
53
|
+
return self._data['brakeRs'][name]
|
|
54
|
+
|
|
55
|
+
def get_encoder(self, name: str):
|
|
56
|
+
"""
|
|
57
|
+
Loads data for a particular encoder model.
|
|
58
|
+
name: e.g. "AMT10x"
|
|
59
|
+
"""
|
|
60
|
+
return self._data['encoders'][name]
|
|
61
|
+
|
|
62
|
+
def get_encoders(self):
|
|
63
|
+
return list(self._data['encoders'].keys())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _process_motor(motor):
|
|
67
|
+
if "kv" in motor:
|
|
68
|
+
motor["torque_constant"] = 8.27 / motor["kv"]
|
|
69
|
+
else:
|
|
70
|
+
motor["kv"] = 8.27 / motor["torque_constant"]
|
|
71
|
+
|
|
72
|
+
def _process_nothing(x):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load(path = None, validate = False):
|
|
77
|
+
"""
|
|
78
|
+
path: Path of the database folder. If none, the path is detected automatically.
|
|
79
|
+
validate: Validates all JSON files that are being loaded against their schema.
|
|
80
|
+
If this feature is used jsonschema must be installed.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
db_dir0 = os.path.join(script_dir, 'data') # When running from pip install
|
|
84
|
+
db_dir1 = os.path.join(os.path.dirname(os.path.dirname(script_dir)), 'data') # When running from Git repo
|
|
85
|
+
|
|
86
|
+
if path is None:
|
|
87
|
+
if os.path.isdir(db_dir0):
|
|
88
|
+
path = db_dir0
|
|
89
|
+
elif os.path.isdir(db_dir1):
|
|
90
|
+
path = db_dir1
|
|
91
|
+
else:
|
|
92
|
+
raise Exception("Database not found.")
|
|
93
|
+
|
|
94
|
+
data = {
|
|
95
|
+
'odrives': {},
|
|
96
|
+
'drvs': {},
|
|
97
|
+
'motors': {},
|
|
98
|
+
'encoders': {},
|
|
99
|
+
'brakeRs': {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
loaders = {
|
|
103
|
+
'odrive': [_process_nothing, None],
|
|
104
|
+
'drv': [_process_nothing, None],
|
|
105
|
+
'motor': [_process_motor, None],
|
|
106
|
+
'encoder': [_process_nothing, None],
|
|
107
|
+
'brakeR': [_process_nothing, None]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if validate:
|
|
111
|
+
import jsonschema
|
|
112
|
+
with open(os.path.join(path, "schema.json")) as fp:
|
|
113
|
+
schema = json.load(fp)
|
|
114
|
+
loaders['odrive'][1] = jsonschema.Draft4Validator({**schema, **{"$ref": "#/$defs/odrive"}})
|
|
115
|
+
loaders['drv'][1] = jsonschema.Draft4Validator({**schema, **{"$ref": "#/$defs/drv"}})
|
|
116
|
+
loaders['motor'][1] = jsonschema.Draft4Validator({**schema, **{"$ref": "#/$defs/motor"}})
|
|
117
|
+
loaders['encoder'][1] = jsonschema.Draft4Validator({**schema, **{"$ref": "#/$defs/encoder"}})
|
|
118
|
+
loaders['brakeR'][1] = jsonschema.Draft4Validator({**schema, **{"$ref": "#/$defs/brakeR"}})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
for file in os.listdir(path):
|
|
122
|
+
name, ext = os.path.splitext(file)
|
|
123
|
+
file = os.path.join(path, file)
|
|
124
|
+
if os.path.isfile(file) and ext.lower() == '.json':
|
|
125
|
+
for k, (processor, validator) in loaders.items():
|
|
126
|
+
try:
|
|
127
|
+
if name.startswith(k + '-') and ext.lower() == '.json':
|
|
128
|
+
with open(file) as fp:
|
|
129
|
+
item = json.load(fp)
|
|
130
|
+
items = {item['name']: item}
|
|
131
|
+
|
|
132
|
+
elif name == k + 's':
|
|
133
|
+
with open(file) as fp:
|
|
134
|
+
items = json.load(fp)
|
|
135
|
+
assert(isinstance(items, dict))
|
|
136
|
+
|
|
137
|
+
else:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if validate:
|
|
141
|
+
for item in items.values():
|
|
142
|
+
validator.validate(item)
|
|
143
|
+
|
|
144
|
+
for name, item in items.items():
|
|
145
|
+
processor(item)
|
|
146
|
+
data[k + 's'][name] = item
|
|
147
|
+
|
|
148
|
+
except Exception as ex:
|
|
149
|
+
raise Exception("error while processing " + file) from ex
|
|
150
|
+
|
|
151
|
+
# Postprocessing: load metadata of gate driver chip for each inverter
|
|
152
|
+
for odrive in data['odrives'].values():
|
|
153
|
+
for inv in odrive['inverters']:
|
|
154
|
+
if len(inv['drv'].keys()) == 1 and '$ref' in inv['drv'].keys():
|
|
155
|
+
inv['drv'] = data['drvs'][inv['drv']['$ref']]
|
|
156
|
+
|
|
157
|
+
# Postprocessing: transform product string keys to board version triplets
|
|
158
|
+
data['odrives'] = {
|
|
159
|
+
{
|
|
160
|
+
'ODrive v3.6-24V': HwVersion(3, 6, 24),
|
|
161
|
+
'ODrive v3.6-56V': HwVersion(3, 6, 56),
|
|
162
|
+
'ODrive Pro v4.2-58V': HwVersion(4, 2, 58),
|
|
163
|
+
'ODrive Pro v4.3-58V': HwVersion(4, 3, 58),
|
|
164
|
+
'ODrive Pro v4.4-58V': HwVersion(4, 4, 58),
|
|
165
|
+
'ODrive S1 X1': HwVersion(5, 0, 0),
|
|
166
|
+
'ODrive S1 X3': HwVersion(5, 1, 0),
|
|
167
|
+
'ODrive S1 X4': HwVersion(5, 2, 0),
|
|
168
|
+
'ODrive Micro X1': HwVersion(6, 0, 0),
|
|
169
|
+
'ODrive Micro X3': HwVersion(6, 1, 0),
|
|
170
|
+
'ODrive Micro X4': HwVersion(6, 2, 0),
|
|
171
|
+
}[k]: v
|
|
172
|
+
for k, v in data['odrives'].items()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Postprocessing: include inherited properties for each encoder
|
|
176
|
+
for key, encoder in list(data['encoders'].items()):
|
|
177
|
+
while 'inherits' in encoder:
|
|
178
|
+
inherited_encoder = data['encoders'][encoder['inherits']]
|
|
179
|
+
encoder.pop('inherits')
|
|
180
|
+
encoder = {**inherited_encoder, **encoder}
|
|
181
|
+
data['encoders'][key] = encoder
|
|
182
|
+
|
|
183
|
+
return Database(data)
|
|
184
|
+
|
odrive/dfu.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import fibre
|
|
7
|
+
import odrive
|
|
8
|
+
import odrive.firmware
|
|
9
|
+
from odrive.release_api import VersionRelationship, format_version
|
|
10
|
+
from .hw_version import HwVersion
|
|
11
|
+
from .libodrive import Interface, LibODrive, Device, Firmware
|
|
12
|
+
|
|
13
|
+
class DfuError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
async def enter_dfu_mode(device, discoverer: Interface) -> Device:
|
|
17
|
+
"""
|
|
18
|
+
Puts the specified device into (new) DFU mode.
|
|
19
|
+
"""
|
|
20
|
+
serial_number = "{:08X}".format(device.serial_number)
|
|
21
|
+
print("Putting device {:08X} into DFU mode...".format(device.serial_number))
|
|
22
|
+
try:
|
|
23
|
+
result = device.enter_dfu_mode2()
|
|
24
|
+
except fibre.ObjectLostError:
|
|
25
|
+
result = True # this is expected because the device reboots
|
|
26
|
+
if not result:
|
|
27
|
+
raise DfuError("Failed to enter DFU mode.")
|
|
28
|
+
|
|
29
|
+
device = await discoverer.wait_for(serial_number, is_bootloader=True)
|
|
30
|
+
await device.connect_bootloader()
|
|
31
|
+
return device
|
|
32
|
+
|
|
33
|
+
async def write_firmware(device: Device, fw: Firmware, erase_all: bool):
|
|
34
|
+
def print_progress(new_action_group: bool, action_string: str, action_index: int, n_actions: int):
|
|
35
|
+
if new_action_group and action_index != 0:
|
|
36
|
+
print()
|
|
37
|
+
print(f"DFU: {action_string} ", end='\r')
|
|
38
|
+
try:
|
|
39
|
+
await device.run_installation(fw, erase_all, print_progress)
|
|
40
|
+
finally:
|
|
41
|
+
print()
|
|
42
|
+
|
|
43
|
+
async def await_first(tasks):
|
|
44
|
+
"""
|
|
45
|
+
Awaits the first of several tasks or futures and cancels the others.
|
|
46
|
+
"""
|
|
47
|
+
# TODO: is there any standard way to do this in one line?
|
|
48
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
49
|
+
for t in pending:
|
|
50
|
+
t.cancel()
|
|
51
|
+
for t in pending:
|
|
52
|
+
try:
|
|
53
|
+
await t
|
|
54
|
+
except asyncio.CancelledError:
|
|
55
|
+
pass
|
|
56
|
+
return (await asyncio.gather(*done))[0]
|
|
57
|
+
|
|
58
|
+
async def get_firmware(board: HwVersion, current_build_id_short: Optional[str], channel: Optional[str], version: Optional[str], logger, interactive: bool, release_type: str = 'firmware'):
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
if channel:
|
|
61
|
+
logger.info(f"Checking online for latest {board.display_name} {release_type} on channel {channel}...")
|
|
62
|
+
else:
|
|
63
|
+
logger.info(f"Checking online for {board.display_name} {release_type} version {format_version(version)}...")
|
|
64
|
+
api_client = odrive.api_client.ApiClient(session)
|
|
65
|
+
release_api = odrive.release_api.ReleaseApi(api_client)
|
|
66
|
+
|
|
67
|
+
firmware_index = await release_api.get_index(release_type)
|
|
68
|
+
|
|
69
|
+
# If we're fetching normal firmware, use whatever file the release
|
|
70
|
+
# server returns as preferred file URL.
|
|
71
|
+
# If we're fetching the bootloader, need to select between multiple
|
|
72
|
+
# files on the release server.
|
|
73
|
+
file = 'bootloader_installer.elf' if release_type == 'bootloader' else None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
if channel:
|
|
77
|
+
manifest = firmware_index.get_latest(channel, app='default', board=board, file=file)
|
|
78
|
+
else:
|
|
79
|
+
manifest = firmware_index.get_version(version, app='default', board=board, file=file)
|
|
80
|
+
except odrive.release_api.ChannelNotFoundError as ex:
|
|
81
|
+
raise DfuError(ex)
|
|
82
|
+
except odrive.release_api.FirmwareNotFoundError:
|
|
83
|
+
raise DfuError(f"No {release_type} found matching the specified criteria.")
|
|
84
|
+
|
|
85
|
+
if interactive:
|
|
86
|
+
version_relationship = firmware_index.compare(current_build_id_short, manifest['commit_hash'], channel, app='default', board=board)
|
|
87
|
+
prompt = {
|
|
88
|
+
VersionRelationship.UNKNOWN: "Found compatible firmware ({to_version}). Install now?",
|
|
89
|
+
VersionRelationship.EQUAL: "Your current firmware ({to_version}) is up to date. Do you want to reinstall this version?",
|
|
90
|
+
VersionRelationship.UPGRADE: "Found new firmware ({from_hash} => {to_version}). Install now?",
|
|
91
|
+
VersionRelationship.DOWNGRADE: "Found older firmware ({from_hash} => {to_version}). Install now?",
|
|
92
|
+
}[version_relationship]
|
|
93
|
+
|
|
94
|
+
if not odrive.utils.yes_no_prompt(prompt.format(from_hash=current_build_id_short, to_version=format_version(manifest['commit_hash'])), True):
|
|
95
|
+
raise odrive.utils.OperationAbortedException()
|
|
96
|
+
|
|
97
|
+
logger.info(f"Downloading {release_type}...")
|
|
98
|
+
return odrive.firmware.FirmwareFile.from_file(await release_api.load(manifest))
|
|
99
|
+
|
|
100
|
+
async def run_dfu(libodrive: LibODrive, dfu_discoverer: Interface, serial_number: Optional[str], path: Optional[str], channel: Optional[str], version: Optional[str], erase_all: bool, logger, interactive: bool = True):
|
|
101
|
+
"""
|
|
102
|
+
See dfu_ui for description.
|
|
103
|
+
"""
|
|
104
|
+
assert sum([bool(path), bool(channel), bool(version)]) == 1
|
|
105
|
+
|
|
106
|
+
logger.info("Waiting for ODrive...")
|
|
107
|
+
|
|
108
|
+
# Wait for device either in DFU mode or in normal mode, whichever is
|
|
109
|
+
# found first.
|
|
110
|
+
device = await await_first([
|
|
111
|
+
asyncio.create_task(odrive.find_any_async(serial_number=serial_number)),
|
|
112
|
+
asyncio.create_task(dfu_discoverer.wait_for(serial_number=serial_number, is_bootloader=True))
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
found_in_dfu = isinstance(device, Device)
|
|
116
|
+
|
|
117
|
+
if not found_in_dfu:
|
|
118
|
+
bootloader_version = device.bootloader_version if hasattr(device, "bootloader_version") else 0
|
|
119
|
+
if bootloader_version == 0:
|
|
120
|
+
raise DfuError(
|
|
121
|
+
"New DFU system not installed on device {:08X}.\n"
|
|
122
|
+
"Please follow instructions for one-time setup here:\n"
|
|
123
|
+
"https://docs.odriverobotics.com/v/latest/guides/new-dfu.html\n"
|
|
124
|
+
"or use the legacy DFU system (odrivetool legacy-dfu)."
|
|
125
|
+
.format(device.serial_number)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Note: we don't do ahead of time compatibility check until the
|
|
129
|
+
# bootloader version has stabilized. Just-in-time checking is
|
|
130
|
+
# handled in libodrive (when bootloader is already started).
|
|
131
|
+
|
|
132
|
+
if found_in_dfu:
|
|
133
|
+
await device.connect_bootloader()
|
|
134
|
+
|
|
135
|
+
if path:
|
|
136
|
+
assert os.path.isfile(path)
|
|
137
|
+
assert channel is None
|
|
138
|
+
file = odrive.firmware.FirmwareFile.from_file(path)
|
|
139
|
+
else:
|
|
140
|
+
board: HwVersion = device.hw_version if isinstance(device, Device) else device._board
|
|
141
|
+
build_id_short: Optional[str] = "{:08x}".format(device.commit_hash) if hasattr(device, 'commit_hash') else None
|
|
142
|
+
file = await get_firmware(board, build_id_short, channel, version, logger, interactive)
|
|
143
|
+
|
|
144
|
+
with libodrive.open_firmware(file.as_buffer()) as firmware:
|
|
145
|
+
print("loaded firmware: ")
|
|
146
|
+
print(" Version: " + str(".".join(str(n) for n in firmware.fw_version)))
|
|
147
|
+
print(" Build ID: " + "".join(f"{b:02x}" for b in firmware.build))
|
|
148
|
+
print(" Hardware: " + firmware.hw_version.display_name)
|
|
149
|
+
|
|
150
|
+
if not isinstance(device, Device):
|
|
151
|
+
device = await enter_dfu_mode(device, dfu_discoverer)
|
|
152
|
+
|
|
153
|
+
assert device.is_bootloader
|
|
154
|
+
await write_firmware(device, firmware, erase_all)
|
|
155
|
+
|
|
156
|
+
logger.info("Waiting for the device to reappear...")
|
|
157
|
+
device = await odrive.find_any_async(odrive.default_usb_search_path, serial_number)
|
|
158
|
+
logger.success("Device firmware update successful.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def dfu_ui(serial_number: Optional[str], path: Optional[str], channel: Optional[str], version: Optional[str], erase_all: bool, logger, interactive: bool = True):
|
|
162
|
+
"""
|
|
163
|
+
Runs the complete interactive DFU process:
|
|
164
|
+
|
|
165
|
+
1. Wait for device in either DFU mode or normal mode. If `serial_number` is
|
|
166
|
+
None, the first discovered device is selected, otherwise only the
|
|
167
|
+
specified device is accepted.
|
|
168
|
+
|
|
169
|
+
2. If `path` is None, check for the latest or specified firmware, present it
|
|
170
|
+
to the user and ask whether to continue. Otherwise don't ask and always
|
|
171
|
+
continue.
|
|
172
|
+
|
|
173
|
+
3. If the device is in normal mode, put it into DFU mode.
|
|
174
|
+
|
|
175
|
+
4. Erase, write and verify flash memory.
|
|
176
|
+
|
|
177
|
+
5. Exit DFU mode.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
path: Path to a .elf path or None to check online.
|
|
182
|
+
channel: Channel on which to check for firmware (master, devel, ...)
|
|
183
|
+
version: Exact firmware version
|
|
184
|
+
"""
|
|
185
|
+
libodrive = LibODrive(loop=asyncio.get_running_loop())
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with libodrive.start_usb_discovery() as dfu_discoverer:
|
|
189
|
+
libodrive._worker_thread.start()
|
|
190
|
+
try:
|
|
191
|
+
await run_dfu(libodrive, dfu_discoverer, serial_number, path, channel, version, erase_all, logger, interactive)
|
|
192
|
+
finally:
|
|
193
|
+
libodrive.stop_thread()
|
|
194
|
+
finally:
|
|
195
|
+
libodrive.deinit()
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import usb.util
|
|
2
|
+
import time
|
|
3
|
+
import fractions
|
|
4
|
+
import array
|
|
5
|
+
from enum import Enum
|
|
6
|
+
import time
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
class RequestType(Enum):
|
|
10
|
+
SEND = 0x21
|
|
11
|
+
RECEIVE = 0xa1
|
|
12
|
+
|
|
13
|
+
class Request(Enum):
|
|
14
|
+
DETACH = 0x00
|
|
15
|
+
DNLOAD = 0x01
|
|
16
|
+
UPLOAD = 0x02
|
|
17
|
+
GETSTATUS = 0x03
|
|
18
|
+
CLRSTATUS = 0x04
|
|
19
|
+
GETSTATE = 0x05
|
|
20
|
+
ABORT = 0x06
|
|
21
|
+
|
|
22
|
+
class Command(Enum):
|
|
23
|
+
SET_ADDRESS_POINTER = 0x21
|
|
24
|
+
ERASE = 0x41
|
|
25
|
+
READ_UNPROTECT = 0x92
|
|
26
|
+
|
|
27
|
+
class DfuState(Enum):
|
|
28
|
+
APP_IDLE = 0x00
|
|
29
|
+
APP_DETACH = 0x01
|
|
30
|
+
DFU_IDLE = 0x02
|
|
31
|
+
DFU_DOWNLOAD_SYNC = 0x03
|
|
32
|
+
DFU_DOWNLOAD_BUSY = 0x04
|
|
33
|
+
DFU_DOWNLOAD_IDLE = 0x05
|
|
34
|
+
DFU_MANIFEST_SYNC = 0x06
|
|
35
|
+
DFU_MANIFEST = 0x07
|
|
36
|
+
DFU_MANIFEST_WAIT_RESET = 0x08
|
|
37
|
+
DFU_UPLOAD_IDLE = 0x09
|
|
38
|
+
DFU_ERROR = 0x0a
|
|
39
|
+
|
|
40
|
+
class DfuStatus(Enum):
|
|
41
|
+
OK = 0x00
|
|
42
|
+
ERROR_TARGET = 0x01
|
|
43
|
+
ERROR_FILE = 0x02
|
|
44
|
+
ERROR_WRITE = 0x03
|
|
45
|
+
ERROR_ERASE = 0x04
|
|
46
|
+
ERROR_CHECK_ERASED = 0x05
|
|
47
|
+
ERROR_PROG = 0x06
|
|
48
|
+
ERROR_VERIFY = 0x07
|
|
49
|
+
ERROR_ADDRESS = 0x08
|
|
50
|
+
ERROR_NOTDONE = 0x09
|
|
51
|
+
ERROR_FIRMWARE = 0x0a
|
|
52
|
+
ERROR_VENDOR = 0x0b
|
|
53
|
+
ERROR_USBR = 0x0c
|
|
54
|
+
ERROR_POR = 0x0d
|
|
55
|
+
ERROR_UNKNOWN = 0x0e
|
|
56
|
+
ERROR_STALLEDPKT = 0x0f
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SIZE_MULTIPLIERS = {' ': 1, 'K': 1024, 'M' : 1024*1024}
|
|
60
|
+
MAX_TRANSFER_SIZE = 2048
|
|
61
|
+
|
|
62
|
+
# Order is LSB first
|
|
63
|
+
def _address_to_4bytes(a):
|
|
64
|
+
return [ a % 256, (a >> 8)%256, (a >> 16)%256, (a >> 24)%256 ]
|
|
65
|
+
|
|
66
|
+
def _get_dfu_functional_descriptors(interfaces):
|
|
67
|
+
for intf in interfaces:
|
|
68
|
+
pos = 0
|
|
69
|
+
desc = intf.extra_descriptors
|
|
70
|
+
while len(desc):
|
|
71
|
+
current_desc = desc[:desc[0]]
|
|
72
|
+
desc = desc[desc[0]:]
|
|
73
|
+
if current_desc[1] == 0x21:
|
|
74
|
+
yield current_desc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DfuError(Exception):
|
|
78
|
+
def __init__(self, message: str, status: DfuStatus, state: DfuState, text: str):
|
|
79
|
+
super().__init__(message + " Device responded with {}, {}, \"{}\".".format(status, state, text))
|
|
80
|
+
|
|
81
|
+
class DfuDevice:
|
|
82
|
+
def __init__(self, device):
|
|
83
|
+
self._dev = device
|
|
84
|
+
self._memory = None
|
|
85
|
+
|
|
86
|
+
def init(self):
|
|
87
|
+
cfg = self._dev[0]
|
|
88
|
+
cfg.set()
|
|
89
|
+
|
|
90
|
+
dfu_desc = list(_get_dfu_functional_descriptors(cfg.interfaces()))
|
|
91
|
+
assert len(dfu_desc) == 1
|
|
92
|
+
dfu_desc = dfu_desc[0]
|
|
93
|
+
|
|
94
|
+
self._max_transfer_size = dfu_desc[5] + (dfu_desc[6] << 8)
|
|
95
|
+
assert self._max_transfer_size <= MAX_TRANSFER_SIZE
|
|
96
|
+
|
|
97
|
+
self.memories = {}
|
|
98
|
+
for intf in cfg.interfaces():
|
|
99
|
+
# example for intf_name:
|
|
100
|
+
# '@Internal Flash /0x08000000/04*016Kg,01*064Kg,07*128Kg'
|
|
101
|
+
intf_name = usb.util.get_string(self._dev, intf.iInterface)
|
|
102
|
+
if intf_name.count('/') != 2:
|
|
103
|
+
raise Exception(f"invalid interface name for interface {intf.iInterface}: {intf_name}")
|
|
104
|
+
label, baseaddr, layout = intf_name.split('/')
|
|
105
|
+
baseaddr = int(baseaddr, 0) # convert hex to decimal
|
|
106
|
+
addr = baseaddr
|
|
107
|
+
|
|
108
|
+
memory = {
|
|
109
|
+
'intf': intf,
|
|
110
|
+
'alt': intf.bAlternateSetting,
|
|
111
|
+
'sectors': []
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for sector in layout.split(','):
|
|
115
|
+
repeat, size = map(int, sector[:-2].split('*'))
|
|
116
|
+
size *= SIZE_MULTIPLIERS[sector[-2].upper()]
|
|
117
|
+
mode = sector[-1]
|
|
118
|
+
|
|
119
|
+
while repeat > 0:
|
|
120
|
+
# TODO: verify if the section is writable
|
|
121
|
+
memory['sectors'].append({
|
|
122
|
+
'addr': addr,
|
|
123
|
+
'len': size,
|
|
124
|
+
'mode': mode
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
addr += size
|
|
128
|
+
repeat -= 1
|
|
129
|
+
|
|
130
|
+
name = label.rstrip().lstrip('@')
|
|
131
|
+
self.memories[name] = memory
|
|
132
|
+
|
|
133
|
+
def select_memory(self, name):
|
|
134
|
+
self._memory = self.memories[name]
|
|
135
|
+
self._memory['intf'].set_altsetting()
|
|
136
|
+
|
|
137
|
+
def _control_msg(self, requestType: RequestType, request: Request, value: int, buffer):
|
|
138
|
+
if self._memory is None:
|
|
139
|
+
raise Exception("no memory selected")
|
|
140
|
+
return self._dev.ctrl_transfer(requestType.value, request.value, value, self._memory['intf'].bInterfaceNumber, buffer, timeout=60000)
|
|
141
|
+
|
|
142
|
+
def _detach(self, timeout):
|
|
143
|
+
"""
|
|
144
|
+
timeout: Timeout in [??]
|
|
145
|
+
"""
|
|
146
|
+
return self._control_msg(RequestType.SEND, Request.DETACH, timeout, None)
|
|
147
|
+
|
|
148
|
+
def _dnload(self, blockNum, data):
|
|
149
|
+
"""
|
|
150
|
+
The device's current state must be dfuIDLE or dfuDNLOAD-IDLE state for
|
|
151
|
+
this command to work (see STM AN3156 Fig 5).
|
|
152
|
+
|
|
153
|
+
blockNum: block to write to
|
|
154
|
+
data: byte-array-like data to write
|
|
155
|
+
Returns: number of bytes that were written.
|
|
156
|
+
"""
|
|
157
|
+
cnt = self._control_msg(RequestType.SEND, Request.DNLOAD, blockNum, list(data))
|
|
158
|
+
return cnt
|
|
159
|
+
|
|
160
|
+
def _upload(self, blockNum, size):
|
|
161
|
+
"""
|
|
162
|
+
The device's current state must be dfuIDLE or dfuUPLOAD-IDLE state for
|
|
163
|
+
this command to work (see STM AN3156 Fig 3).
|
|
164
|
+
|
|
165
|
+
blockNum: block to read from
|
|
166
|
+
size: number of bytes to read
|
|
167
|
+
Returns: byte array containing the data that was read
|
|
168
|
+
"""
|
|
169
|
+
return self._control_msg(RequestType.RECEIVE, Request.UPLOAD, blockNum, size)
|
|
170
|
+
|
|
171
|
+
def _get_status(self):
|
|
172
|
+
"""
|
|
173
|
+
Returns: A tuple of the form (status: DfuStatus, state: DfuState, text: str)
|
|
174
|
+
"""
|
|
175
|
+
msg = self._control_msg(RequestType.RECEIVE, Request.GETSTATUS, 0, 6)
|
|
176
|
+
|
|
177
|
+
status = DfuStatus(msg[0])
|
|
178
|
+
state = DfuState(msg[4])
|
|
179
|
+
poll_timeout_ms = msg[1] + (msg[2] << 8) + (msg[3] << 16)
|
|
180
|
+
text = usb.util.get_string(self._dev, msg[5])
|
|
181
|
+
|
|
182
|
+
if poll_timeout_ms > 10000:
|
|
183
|
+
raise Exception("Device requested an unreasonable timeout: {} ms".format(poll_timeout_ms))
|
|
184
|
+
|
|
185
|
+
time.sleep(float(poll_timeout_ms) / 1000.0)
|
|
186
|
+
return status, state, text
|
|
187
|
+
|
|
188
|
+
def clear_status(self):
|
|
189
|
+
if self._get_state() == DfuState.DFU_ERROR:
|
|
190
|
+
self._control_msg(RequestType.SEND, Request.CLRSTATUS, 0, None)
|
|
191
|
+
|
|
192
|
+
def _get_state(self):
|
|
193
|
+
msg = self._control_msg(RequestType.RECEIVE, Request.GETSTATE, 0, 1)
|
|
194
|
+
return DfuState(msg[0])
|
|
195
|
+
|
|
196
|
+
def _abort(self):
|
|
197
|
+
self._control_msg(RequestType.SEND, Request.ABORT, 0, None)
|
|
198
|
+
|
|
199
|
+
def _read(self, block, size):
|
|
200
|
+
return self._upload(block + 2, size)
|
|
201
|
+
|
|
202
|
+
def _write(self, block, data):
|
|
203
|
+
return self._dnload(block + 2, data)
|
|
204
|
+
|
|
205
|
+
def _set_address(self, addr):
|
|
206
|
+
self._dnload(0x0, [Command.SET_ADDRESS_POINTER.value] + _address_to_4bytes(addr))
|
|
207
|
+
self._expect_state(
|
|
208
|
+
[DfuState.DFU_DOWNLOAD_BUSY], [DfuState.DFU_DOWNLOAD_IDLE],
|
|
209
|
+
"Failed to set address 0x{:08x}.".format(addr)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def unprotect(self):
|
|
213
|
+
self._dnload(0x0, [Command.READ_UNPROTECT])
|
|
214
|
+
self._expect_state(
|
|
215
|
+
[DfuState.DFU_DOWNLOAD_BUSY], [DfuState.DFU_DOWNLOAD_IDLE],
|
|
216
|
+
"Failed to unprotect."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _erase(self, addr):
|
|
220
|
+
self._dnload(0x0, [Command.ERASE.value] + _address_to_4bytes(addr))
|
|
221
|
+
self._expect_state(
|
|
222
|
+
[DfuState.DFU_DOWNLOAD_BUSY], [DfuState.DFU_DOWNLOAD_IDLE],
|
|
223
|
+
"Failed to erase sector at 0x{:08x}.".format(addr)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _leave(self):
|
|
227
|
+
return self._dnload(0x0, []) # Just send an empty data.
|
|
228
|
+
|
|
229
|
+
def _expect_state(self, busy_states, target_states, error_text):
|
|
230
|
+
while True:
|
|
231
|
+
status, state, text = self._get_status()
|
|
232
|
+
if state in target_states:
|
|
233
|
+
return
|
|
234
|
+
if state not in busy_states:
|
|
235
|
+
raise DfuError(error_text, status, state, text)
|
|
236
|
+
|
|
237
|
+
def erase_sector(self, sector):
|
|
238
|
+
self._expect_state([], [DfuState.DFU_IDLE, DfuState.DFU_DOWNLOAD_IDLE], "Cannot erase sector")
|
|
239
|
+
self._erase(sector['addr'])
|
|
240
|
+
|
|
241
|
+
def write_sector(self, sector, data):
|
|
242
|
+
self._expect_state([], [DfuState.DFU_IDLE, DfuState.DFU_DOWNLOAD_IDLE], "Cannot write sector")
|
|
243
|
+
|
|
244
|
+
status, state, text = self._get_status()
|
|
245
|
+
if state not in [DfuState.DFU_IDLE, DfuState.DFU_DOWNLOAD_IDLE]:
|
|
246
|
+
raise DfuError("Cannot write sector.", status, state, text)
|
|
247
|
+
|
|
248
|
+
self._set_address(sector['addr'])
|
|
249
|
+
|
|
250
|
+
transfer_size = math.gcd(sector['len'], self._max_transfer_size)
|
|
251
|
+
|
|
252
|
+
for blocknum in range(int(sector['len'] / transfer_size)):
|
|
253
|
+
block = data[(blocknum * transfer_size):((blocknum + 1) * transfer_size)]
|
|
254
|
+
n_written = self._write(blocknum, block)
|
|
255
|
+
assert n_written == len(block), n_written
|
|
256
|
+
self._expect_state(
|
|
257
|
+
[DfuState.DFU_DOWNLOAD_BUSY], [DfuState.DFU_DOWNLOAD_IDLE],
|
|
258
|
+
"Failed to write sector at 0x{:08x}, block {}".format(sector['addr'], blocknum)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def read_sector(self, sector):
|
|
262
|
+
"""
|
|
263
|
+
Reads data from the specified sector
|
|
264
|
+
Returns: a byte array containing the data
|
|
265
|
+
"""
|
|
266
|
+
self._expect_state([], [DfuState.DFU_IDLE, DfuState.DFU_DOWNLOAD_IDLE], "Cannot read sector.")
|
|
267
|
+
self._set_address(sector['addr'])
|
|
268
|
+
|
|
269
|
+
self._abort() # exit DNLOAD_IDLE state
|
|
270
|
+
|
|
271
|
+
transfer_size = math.gcd(sector['len'], self._max_transfer_size)
|
|
272
|
+
|
|
273
|
+
# Device's current state must be dfuIDLE or dfuUPLOAD-IDLE for the subsequent read() commands to work.
|
|
274
|
+
|
|
275
|
+
data = array.array(u'B')
|
|
276
|
+
for blocknum in range(int(sector['len'] / transfer_size)):
|
|
277
|
+
device_block = self._read(blocknum, transfer_size)
|
|
278
|
+
assert len(device_block) == transfer_size, len(device_block)
|
|
279
|
+
data.extend(device_block)
|
|
280
|
+
|
|
281
|
+
self._abort() # take device into DFU_IDLE
|
|
282
|
+
return data
|
|
283
|
+
|
|
284
|
+
def jump_to_application(self, address):
|
|
285
|
+
self._set_address(address)
|
|
286
|
+
self._leave()
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
self._expect_state(
|
|
290
|
+
[DfuState.DFU_MANIFEST_SYNC], [DfuState.DFU_MANIFEST],
|
|
291
|
+
"Failed to exit DFU mode."
|
|
292
|
+
)
|
|
293
|
+
# on ODrive v3.x this passes
|
|
294
|
+
# on ODrive v4.x it throws a usb.core.USBError
|
|
295
|
+
except usb.core.USBError:
|
|
296
|
+
pass # expected
|
|
297
|
+
|
odrive/dfuse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .DfuDevice import DfuDevice, DfuState, DfuStatus
|