idftool 0.1.0__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.
idftool/__main__.py ADDED
@@ -0,0 +1,920 @@
1
+ import argparse
2
+ import os.path
3
+ import re
4
+ import sys
5
+ import time
6
+ from typing import Optional, Literal
7
+ from zipfile import ZipFile
8
+
9
+ from esptool import ESPLoader, CHIP_DEFS, flash_size_bytes
10
+ from serial import SerialException
11
+ from serial.tools import list_ports
12
+ from serial.tools.list_ports_common import ListPortInfo
13
+
14
+ from esp_idf_defs import ImageMetadata, ChipId
15
+ from esp_idf_defs.otadata import OtaDataParameters, OtaDataSelectEntry
16
+ from esp_idf_defs.partitions import PARTITION_TABLE_SIZE, PARTITION_TABLE_OFFSET, PartitionTable, print_partition_table, \
17
+ PartitionDefinition, BOOTLOADER_TYPE, SUBTYPES, PARTITION_TABLE_TYPE, APP_TYPE, NUM_PARTITION_SUBTYPE_APP_OTA, \
18
+ DATA_TYPE
19
+ from esptool.cmds import detect_chip, read_flash, write_flash, erase_region, merge_bin, erase_flash, detect_flash_size
20
+
21
+ PARTITION_SLICE_REGEX = re.compile(r"^(?P<partition>.+?)(?:\[(?:(?P<start>[-+]?(?:0x)?[0-9A-Fa-f]+)?(?::(?P<stop>[-+]?(?:0x)?[0-9A-Fa-f]+)?)?)?])?$")
22
+ PARTITION_OFFSET_REGEX = re.compile(r"^(?P<partition>.+?)(?:\[(?:(?P<start>[-+]?(?:0x)?[0-9A-Fa-f]+)?)?])?$")
23
+
24
+ # TODO: Use esptool version when merged
25
+ def get_port_list() -> list[str]:
26
+ """Get the list of serial ports names with optional filters.
27
+
28
+ For backwards compatibility, this function returns a list of port names.
29
+ """
30
+ return [port.device for port in _get_port_list()]
31
+
32
+
33
+ def _get_port_list() -> list[ListPortInfo]:
34
+ ports = []
35
+ for port in list_ports.comports():
36
+ if sys.platform == "darwin" and port.device.endswith(
37
+ ("Bluetooth-Incoming-Port", "wlan-debug", "cu.debug-console")
38
+ ):
39
+ continue
40
+ ports.append(port)
41
+
42
+ # Constants for sorting optimization
43
+ ESPRESSIF_VID = 0x303A
44
+ LINUX_DEVICE_PATTERNS = ("ttyUSB", "ttyACM")
45
+ MACOS_DEVICE_PATTERNS = ("usbserial", "usbmodem")
46
+
47
+ def _port_sort_key_linux(port_info: ListPortInfo) -> tuple[int, str]:
48
+ if port_info.vid == ESPRESSIF_VID:
49
+ return (3, port_info.device)
50
+
51
+ if any(pattern in port_info.device for pattern in LINUX_DEVICE_PATTERNS):
52
+ return (2, port_info.device)
53
+
54
+ return (1, port_info.device)
55
+
56
+ def _port_sort_key_macos(port_info: ListPortInfo) -> tuple[int, str]:
57
+ if port_info.vid == ESPRESSIF_VID:
58
+ return (3, port_info.device)
59
+
60
+ if any(pattern in port_info.device for pattern in MACOS_DEVICE_PATTERNS):
61
+ return (2, port_info.device)
62
+
63
+ return (1, port_info.device)
64
+
65
+ def _port_sort_key_windows(port_info: ListPortInfo) -> tuple[int, str]:
66
+ if port_info.vid == ESPRESSIF_VID:
67
+ return (2, port_info.device)
68
+
69
+ return (1, port_info.device)
70
+
71
+ if sys.platform == "win32":
72
+ key_func = _port_sort_key_windows
73
+ elif sys.platform == "darwin":
74
+ key_func = _port_sort_key_macos
75
+ else:
76
+ key_func = _port_sort_key_linux
77
+
78
+ sorted_port_info = sorted(ports, key=key_func)
79
+ return sorted_port_info
80
+
81
+ def parse_bootloader_offset(x):
82
+ try:
83
+ return int(x, 0)
84
+ except ValueError:
85
+ if CHIP_DEFS[x] is None:
86
+ raise argparse.ArgumentTypeError(f"Invalid bootloader offset or chip name: {x}")
87
+ return CHIP_DEFS[x].BOOTLOADER_FLASH_OFFSET
88
+
89
+ def auto_int(x):
90
+ return int(x, 0)
91
+
92
+ def get_esp(port: str | None, baud: int) -> ESPLoader:
93
+ esp: ESPLoader | None = None
94
+ if port:
95
+ esp = detect_chip(port, baud=baud)
96
+ else:
97
+ ports = get_port_list()
98
+ for port in ports:
99
+ try:
100
+ print(f"Serial port {port} (baud={baud})")
101
+ esp = detect_chip(port, baud=baud)
102
+ break
103
+ except RuntimeError:
104
+ pass
105
+ if not esp:
106
+ raise RuntimeError("No ESP found")
107
+ return esp.run_stub()
108
+
109
+ def get_partition(
110
+ partition_table: PartitionTable,
111
+ partition_table_entry: PartitionDefinition,
112
+ bootloader_entry: Optional[PartitionDefinition],
113
+ label: str
114
+ ) -> PartitionDefinition:
115
+ try:
116
+ try:
117
+ address = int(label, 0)
118
+ return next(
119
+ (p for p in partition_table if p.offset == address),
120
+ )
121
+ except ValueError:
122
+ pass
123
+ return partition_table[label]
124
+ except ValueError:
125
+ if label == partition_table_entry.name:
126
+ return partition_table_entry
127
+ elif bootloader_entry and label == bootloader_entry.name:
128
+ return bootloader_entry
129
+ else:
130
+ raise
131
+
132
+ def get_partition_slice(
133
+ partition_table: PartitionTable,
134
+ partition_table_entry: PartitionDefinition,
135
+ bootloader_entry: Optional[PartitionDefinition],
136
+ s: str
137
+ ) -> tuple[PartitionDefinition, int, int]:
138
+ match = PARTITION_SLICE_REGEX.fullmatch(s)
139
+ if not match:
140
+ raise ValueError(f"Invalid partition slice format: {s}")
141
+
142
+ partition_name = match.group("partition")
143
+ start_str = match.group("start")
144
+ stop_str = match.group("stop")
145
+
146
+ partition = get_partition(partition_table, partition_table_entry, bootloader_entry, partition_name)
147
+
148
+ start = int(start_str, 0) if start_str else 0
149
+ if start < 0:
150
+ start += partition.size
151
+
152
+ if stop_str is None:
153
+ stop = partition.size
154
+ else:
155
+ stop = int(stop_str, 0)
156
+ if stop_str[0] == '+':
157
+ stop = start + stop
158
+ if stop < 0:
159
+ stop += partition.size
160
+
161
+ if start < 0 or stop < 0 or start > partition.size or stop > partition.size or start > stop:
162
+ raise ValueError(f"Invalid slice range [{start:#x}:{stop:#x}] for partition {partition_name} of size {partition.size:#x}")
163
+
164
+ return partition, partition.offset + start, stop - start
165
+
166
+ def get_partition_address(
167
+ partition_table: PartitionTable,
168
+ partition_table_entry: PartitionDefinition,
169
+ bootloader_entry: Optional[PartitionDefinition],
170
+ s: str
171
+ ) -> tuple[PartitionDefinition, int]:
172
+ match = PARTITION_OFFSET_REGEX.fullmatch(s)
173
+ if not match:
174
+ raise ValueError(f"Invalid partition offset format: {s}")
175
+
176
+ partition_name = match.group("partition")
177
+ start_str = match.group("start")
178
+
179
+ partition = get_partition(partition_table, partition_table_entry, bootloader_entry, partition_name)
180
+
181
+ start = int(start_str, 0) if start_str else 0
182
+ if start < 0:
183
+ start += partition.size
184
+
185
+ if start < 0 or start > partition.size:
186
+ raise ValueError(f"Invalid offset [{start:#x}] for partition {partition_name} of size {partition.size:#x}")
187
+
188
+ return partition, partition.offset + start
189
+
190
+ def hexdump(data: bytes, start = 0, width = 16):
191
+ def to_printable_ascii(byte):
192
+ return chr(byte) if 32 <= byte <= 126 else "."
193
+
194
+ # If start is not aligned, print an empty row with padding
195
+ misalignment = start % width
196
+ start -= misalignment
197
+
198
+ offset = 0
199
+ while offset < len(data):
200
+ chunk = data[offset : offset + width - misalignment]
201
+ hex_values = "-- " * misalignment + " ".join(f"{byte:02x}" for byte in chunk) + " " + "-- " * (width - len(chunk) - misalignment)
202
+ ascii_values = "-" * misalignment + "".join(to_printable_ascii(byte) for byte in chunk) + "-" * (width - len(chunk) - misalignment)
203
+ print(f"0x{start + offset:08x} {hex_values} |{ascii_values}|")
204
+ offset += width - misalignment
205
+ misalignment = 0
206
+
207
+ def read_otadata(esp: ESPLoader, partition_table: PartitionTable) -> tuple[PartitionDefinition, OtaDataParameters]:
208
+ ota_app_count = len(list(
209
+ part for part in partition_table if
210
+ part.type == APP_TYPE and SUBTYPES[APP_TYPE]['ota_0'] <= part.subtype <=
211
+ SUBTYPES[APP_TYPE]['ota_15'] + NUM_PARTITION_SUBTYPE_APP_OTA)
212
+ )
213
+ if ota_app_count == 0:
214
+ raise ValueError("No OTA partitions found") # TODO Better error type
215
+
216
+ otadata_partition = next(
217
+ (p for p in partition_table if p.type == DATA_TYPE and p.subtype == SUBTYPES[DATA_TYPE]['ota']),
218
+ None
219
+ )
220
+ if not otadata_partition:
221
+ raise ValueError("No otadata partition found") # TODO Better error type
222
+
223
+ otadata_a_bytes = esp.read_flash(otadata_partition.offset, OtaDataSelectEntry.SIZE)
224
+ otadata_b_bytes = esp.read_flash(otadata_partition.offset + esp.FLASH_SECTOR_SIZE, OtaDataSelectEntry.SIZE)
225
+ otadata_a = OtaDataSelectEntry.from_bytes(otadata_a_bytes)
226
+ otadata_b = OtaDataSelectEntry.from_bytes(otadata_b_bytes)
227
+
228
+ otadata, a_or_b = OtaDataSelectEntry.select(otadata_a, otadata_b)
229
+
230
+ return otadata_partition, OtaDataParameters(otadata, a_or_b, ota_app_count)
231
+
232
+ def write_otadata(esp: ESPLoader, partition: PartitionDefinition, otadata: OtaDataParameters):
233
+ esp.flash_begin(
234
+ size=OtaDataSelectEntry.SIZE,
235
+ offset=partition.offset + (0 if otadata.a_or_b == 'a' else esp.FLASH_SECTOR_SIZE)
236
+ )
237
+ esp.flash_block(data=otadata.otadata.to_bytes(), seq=0)
238
+ esp.flash_finish()
239
+
240
+ def validate_app_binary(esp: ESPLoader, app_binary: bytes) -> tuple[bytes, ImageMetadata]:
241
+ try:
242
+ image_metadata = ImageMetadata.from_bytes(app_binary, app_required=True)
243
+ except ValueError as e:
244
+ raise RuntimeError(f"Invalid application binary: {e}")
245
+
246
+ if image_metadata.header.chip_id.value != esp.IMAGE_CHIP_ID:
247
+ raise RuntimeError(
248
+ f"Chip ID mismatch: "
249
+ f"attempting to flash {image_metadata.header.chip_id.name} image "
250
+ f"to {ChipId(esp.IMAGE_CHIP_ID).name} device"
251
+ )
252
+
253
+ return app_binary, image_metadata
254
+
255
+ def load_app_binary(esp: ESPLoader, app_binary_file: str, partition: PartitionDefinition) -> tuple[bytes, ImageMetadata]:
256
+ app_binary_size = os.path.getsize(app_binary_file)
257
+ if app_binary_size > partition.size:
258
+ raise RuntimeError(
259
+ f"Application binary size '{app_binary_size}' is greater than partition size {partition.size}")
260
+
261
+ app_binary = open(app_binary_file, 'rb').read()
262
+ return validate_app_binary(esp, app_binary)
263
+
264
+ def command_read(
265
+ esp: ESPLoader,
266
+ partition_table: PartitionTable,
267
+ partition_table_entry: PartitionDefinition,
268
+ bootloader_entry: Optional[PartitionDefinition],
269
+ label: str,
270
+ output_file: str
271
+ ):
272
+ partition, address, length = get_partition_slice(partition_table, partition_table_entry, bootloader_entry, label)
273
+ print(f"Reading partition {partition.name} (offset={address:#x}, size={length:#x})")
274
+ read_flash(esp, address=address, size=length, output=output_file)
275
+
276
+ def command_write(
277
+ esp: ESPLoader,
278
+ partition_table: PartitionTable,
279
+ partition_table_entry: PartitionDefinition,
280
+ bootloader_entry: Optional[PartitionDefinition],
281
+ files: list[str]
282
+ ):
283
+ if len(files) % 2 != 0:
284
+ raise ValueError("Files list must contain pairs of partition name and input file")
285
+
286
+ addr_data = []
287
+ for partition_name, input_file in list(zip(files[::2], files[1::2])):
288
+ partition, address = get_partition_address(partition_table, partition_table_entry, bootloader_entry, partition_name)
289
+ input_file_size = os.path.getsize(input_file)
290
+ if input_file_size > partition.size:
291
+ raise ValueError(f"Input file {input_file} size {input_file_size:#x} exceeds partition {partition.name} size {partition.size:#x}")
292
+ addr_data.append((address, input_file))
293
+ print(f"Writing file {input_file} (size={input_file_size:#x}) to partition {partition.name} (offset={address:#x}, size={partition.size:#x})")
294
+
295
+ write_flash(
296
+ esp=esp,
297
+ addr_data=addr_data,
298
+ flash_size='detect'
299
+ )
300
+
301
+ def command_erase(
302
+ esp: ESPLoader,
303
+ partition_table: PartitionTable,
304
+ partition_table_entry: PartitionDefinition,
305
+ bootloader_entry: Optional[PartitionDefinition],
306
+ label: str
307
+ ):
308
+ partition, address, length = get_partition_slice(partition_table, partition_table_entry, bootloader_entry, label)
309
+
310
+ print(f"Erasing partition {partition.name} (offset={address:#x}, size={length:#x})")
311
+ erase_region(esp, address=address, size=length)
312
+
313
+ def command_view(
314
+ esp: ESPLoader,
315
+ partition_table: PartitionTable,
316
+ partition_table_entry: PartitionDefinition,
317
+ bootloader_entry: Optional[PartitionDefinition],
318
+ label: str,
319
+ output: Literal['str', 'hex'],
320
+ width: int
321
+ ):
322
+ partition, address, length = get_partition_slice(partition_table, partition_table_entry, bootloader_entry, label)
323
+
324
+ print(f"Viewing partition {partition.name} (offset={address:#x}, size={length:#x})")
325
+ buffer = esp.read_flash(offset=address, length=length)
326
+ if output == 'str':
327
+ print(buffer.decode('utf-8', errors='replace'), flush=True)
328
+ elif output == 'hex':
329
+ hexdump(buffer, address - partition.offset, width)
330
+ else:
331
+ raise ValueError(f"Invalid output type {output}")
332
+
333
+ def command_create_image(
334
+ partition_table: PartitionTable,
335
+ partition_table_entry: PartitionDefinition,
336
+ bootloader_entry: Optional[PartitionDefinition],
337
+ files: list[str],
338
+ flash_partition_table: bool,
339
+ output_format: Literal['raw', 'uf2', 'hex'],
340
+ output_file: str
341
+ ):
342
+ if len(files) % 2 != 0:
343
+ raise ValueError("Files list must contain pairs of partition name and input file")
344
+
345
+ addr_data = []
346
+ for partition_name, input_file in list(zip(files[::2], files[1::2])):
347
+ partition, address = get_partition_address(partition_table, partition_table_entry, bootloader_entry, partition_name)
348
+ input_file_size = os.path.getsize(input_file)
349
+ if input_file_size > partition.size:
350
+ raise ValueError(
351
+ f"Input file {input_file} size {input_file_size:#x} exceeds partition {partition.name} size {partition.size:#x}")
352
+ addr_data.append((address, input_file))
353
+ print(f"Merging file {input_file} (size={input_file_size:#x}) to partition {partition.name} (offset={address:#x}, size={partition.size:#x})")
354
+
355
+ # Add partition table if requested
356
+ if flash_partition_table:
357
+ partition_table_binary = partition_table.to_binary()
358
+ addr_data.append((partition_table_entry.offset, partition_table_binary))
359
+
360
+ merge_bin(
361
+ addr_data=addr_data,
362
+ flash_freq="keep",
363
+ flash_mode="keep",
364
+ flash_size="keep",
365
+ chip='esp32', # Does not matter since we are keeping all flash parameters
366
+ output=output_file,
367
+ output_format=output_format,
368
+ partition_table=partition_table
369
+ )
370
+
371
+ def command_create_bundle(
372
+ partition_table: PartitionTable,
373
+ partition_table_entry: PartitionDefinition,
374
+ bootloader_entry: Optional[PartitionDefinition],
375
+ files: list[str],
376
+ flash_partition_table: bool,
377
+ output_file: str
378
+ ):
379
+ if len(files) % 2 != 0:
380
+ raise ValueError("Files list must contain pairs of partition name and input file")
381
+
382
+ with ZipFile(output_file, 'w') as zf:
383
+ # Add specified partition files
384
+ for partition_name, input_file in list(zip(files[::2], files[1::2])):
385
+ partition = get_partition(partition_table, partition_table_entry, bootloader_entry, partition_name)
386
+ input_file_size = os.path.getsize(input_file)
387
+ if input_file_size > partition.size:
388
+ raise ValueError(
389
+ f"Input file {input_file} size {input_file_size:#x} exceeds partition {partition.name} size {partition.size:#x}")
390
+ zf.write(input_file, arcname=f"{partition.name}.bin")
391
+ print(f"Adding file {input_file} (size={input_file_size:#x}) to bundle as partition {partition.name} (offset={partition.offset:#x}, size={partition.size:#x})")
392
+
393
+ # Add empty files for erased partitions
394
+ # for partition_name in args.erase:
395
+ # partition = get_partition(partition_table, partition_table_entry, bootloader_entry, partition_name)
396
+ # zf.writestr(f"{partition.name}.bin", bytes(partition.size))
397
+ # print(f"Adding empty file to bundle for erased partition {partition.name} (offset={partition.offset:#x}, size={partition.size:#x})")
398
+
399
+ # Add partition table if requested
400
+ if flash_partition_table:
401
+ zf.writestr("partition_table.csv", partition_table.to_csv())
402
+ print(f"Adding partition table CSV to bundle")
403
+
404
+ def check_write_bundle_has_partition_table(file: str) -> bool:
405
+ with ZipFile(file, 'r') as zf:
406
+ return any(m == 'partition_table.csv' for m in zf.namelist())
407
+
408
+ def command_write_bundle(
409
+ esp: ESPLoader,
410
+ partition_table: PartitionTable,
411
+ partition_table_entry: PartitionDefinition,
412
+ bootloader_entry: Optional[PartitionDefinition],
413
+ input_file: str
414
+ ):
415
+ with ZipFile(input_file, 'r') as zf:
416
+ # Flash files
417
+ addr_data = []
418
+ for member in filter(lambda m: m.endswith('.bin'), zf.namelist()):
419
+ partition_name = member[:-4] # Remove .bin extension
420
+ partition = get_partition(partition_table, partition_table_entry, bootloader_entry, partition_name)
421
+ data = zf.read(member)
422
+ if len(data) > partition.size:
423
+ raise ValueError(f"Input file {member.name} size {len(data):#x} exceeds partition {partition.name} size {partition.size:#x}")
424
+
425
+ if partition.type == APP_TYPE:
426
+ validate_app_binary(esp, data)
427
+
428
+ print(f"Writing partition {partition.name} (offset={partition.offset:#x}, size={partition.size:#x}) from bundle")
429
+ addr_data.append((partition.offset, data))
430
+
431
+ # Flash partition table if present
432
+ if any(m == 'partition_table.csv' for m in zf.namelist()):
433
+ partition = next(
434
+ (e for e in partition_table if e.type == PARTITION_TABLE_TYPE and e.subtype == SUBTYPES[e.type]['primary'])
435
+ )
436
+ print(f"Writing partition table (offset={partition.offset:#x}, size={partition.size:#x}) from bundle")
437
+ addr_data.append((partition.offset, partition_table.to_binary()))
438
+
439
+ # Perform write
440
+ write_flash(
441
+ esp=esp,
442
+ addr_data=addr_data,
443
+ flash_size='detect',
444
+ )
445
+
446
+ def command_get_boot(esp: ESPLoader, partition_table: PartitionTable):
447
+ _, otadata = read_otadata(esp, partition_table)
448
+
449
+ if otadata.slot is None:
450
+ print("OTA slot not set")
451
+ else:
452
+ print(f"OTA slot 'ota_{otadata.slot}' (seq={otadata.otadata.seq}, state={otadata.otadata.ota_state})")
453
+
454
+ def command_set_boot(esp: ESPLoader, partition_table: PartitionTable, label: str):
455
+ otadata_partition, otadata = read_otadata(esp, partition_table)
456
+
457
+ partition = partition_table[label]
458
+ if partition.type != APP_TYPE:
459
+ raise ValueError(f"Partition {label} is not an app partition")
460
+ if partition.subtype < SUBTYPES[APP_TYPE]['ota_0'] or partition.subtype > SUBTYPES[APP_TYPE]['ota_15']:
461
+ raise ValueError(f"Partition {label} is not an OTA partition")
462
+ ota_slot = partition.subtype & 0x0F
463
+
464
+ print(f"Setting boot partition to '{partition.name}'...")
465
+ otadata = otadata.incremented_and_swapped(ota_slot)
466
+ write_otadata(esp, otadata_partition, otadata)
467
+
468
+ def command_clear_boot(esp: ESPLoader, partition_table: PartitionTable):
469
+ otadata_partition = next(
470
+ (p for p in partition_table if p.type == DATA_TYPE and p.subtype == SUBTYPES[DATA_TYPE]['ota']),
471
+ None
472
+ )
473
+ if not otadata_partition:
474
+ raise ValueError("No otadata partition found") # TODO Better error type
475
+
476
+ print("Clearing boot partition...")
477
+ esp.erase_region(offset=otadata_partition.offset, size=otadata_partition.size)
478
+
479
+ def command_ota(esp: ESPLoader, partition_table: PartitionTable, app_binary_file: str):
480
+ otadata_partition, otadata = read_otadata(esp, partition_table)
481
+
482
+ partition: PartitionDefinition
483
+ next_slot = otadata.next_slot
484
+ partition = next(
485
+ (p for p in partition_table if p.type == APP_TYPE and p.subtype == SUBTYPES[APP_TYPE]['ota_0'] + next_slot),
486
+ None
487
+ )
488
+ if not partition:
489
+ raise ValueError(f"Partition ota_{next_slot} not found")
490
+
491
+ app_binary, image_metadata = load_app_binary(esp, app_binary_file, partition)
492
+
493
+ print(f"Writing '{image_metadata.app_description.title}' to partition '{partition.name}'...")
494
+ write_flash(esp=esp, addr_data=[(partition.offset, app_binary_file)])
495
+
496
+ print(f"Setting boot partition to 'ota_{next_slot}'...")
497
+ otadata = otadata.incremented_and_swapped(next_slot)
498
+ write_otadata(esp, otadata_partition, otadata)
499
+
500
+ def command_factory(esp: ESPLoader, partition_table: PartitionTable, app_binary_file: str):
501
+ partition = next(
502
+ (part for part in partition_table if part.type == APP_TYPE and part.subtype == SUBTYPES[APP_TYPE]['factory']),
503
+ None
504
+ )
505
+ if not partition:
506
+ partition = next(
507
+ (part for part in partition_table if part.type == APP_TYPE and part.subtype == SUBTYPES[APP_TYPE]['ota_0']),
508
+ None
509
+ )
510
+ if not partition:
511
+ raise ValueError("No factory or OTA partition found")
512
+
513
+ app_binary, image_metadata = load_app_binary(esp, app_binary_file, partition)
514
+
515
+ print(f"Writing '{image_metadata.app_description.title}' to partition '{partition.name}'...")
516
+ write_flash(esp=esp, addr_data=[(partition.offset, app_binary)])
517
+
518
+ otadata_partition = next(
519
+ (p for p in partition_table if p.type == DATA_TYPE and p.subtype == SUBTYPES[DATA_TYPE]['ota']),
520
+ None
521
+ )
522
+ if otadata_partition:
523
+ print("Erasing 'otadata' partition...")
524
+ esp.erase_region(offset=otadata_partition.offset, size=otadata_partition.size)
525
+
526
+ def command_app_info(app_binary_file: str):
527
+ app_binary = open(app_binary_file, 'rb').read()
528
+ try:
529
+ image_metadata = ImageMetadata.from_bytes(app_binary, app_required=True)
530
+ except ValueError as e:
531
+ raise RuntimeError(f"Invalid application binary: {e}")
532
+
533
+ desc = image_metadata.app_description
534
+ header = image_metadata.header
535
+
536
+ print(f"Project name: {desc.project_name}")
537
+ print(f"Version: {desc.version}")
538
+ print(f"IDF version: {desc.idf_version}")
539
+ print(f"Secure version: {desc.secure_version}")
540
+ if desc.compiled:
541
+ print(f"Compiled: {desc.compiled}")
542
+ print(f"ELF SHA256: {desc.elf_sha256.hex()}")
543
+ max_rev = header.max_chip_rev_full if header.max_chip_rev_full != 0xFFFF else None
544
+ rev_range = f"rev {header.min_chip_rev_full} to {max_rev}" if max_rev else f"rev {header.min_chip_rev_full}+"
545
+ print(f"Chip: {header.chip_id.name} ({rev_range})")
546
+
547
+ def command_write_image(esp: ESPLoader, bootloader_entry: PartitionDefinition, image_file_path: str):
548
+ image_file = open(image_file_path, 'rb')
549
+ image_file.seek(bootloader_entry.offset)
550
+ bootloader_binary = image_file.read(bootloader_entry.size)
551
+ try:
552
+ bootloader_image_metadata = ImageMetadata.from_bytes(bootloader_binary)
553
+ except (RuntimeError, ValueError) as e:
554
+ raise RuntimeError("Invalid bootloader in image, check the input file (chip type may be incorrect)")\
555
+ from e
556
+
557
+ if bootloader_image_metadata.header.chip_id.value != esp.IMAGE_CHIP_ID:
558
+ raise RuntimeError(
559
+ f"Chip ID mismatch: "
560
+ f"attempting to flash {bootloader_image_metadata.header.chip_id.name} image "
561
+ f"to {ChipId(esp.IMAGE_CHIP_ID).name} device"
562
+ )
563
+
564
+ image_data: bytes
565
+ with open(image_file_path, 'rb') as image_file:
566
+ image_file.seek(esp.BOOTLOADER_FLASH_OFFSET)
567
+ image_data = image_file.read()
568
+
569
+ print(f"Writing image '{image_file_path}'...")
570
+ erase_flash(esp)
571
+ write_flash(
572
+ esp=esp,
573
+ addr_data=[(esp.BOOTLOADER_FLASH_OFFSET, image_data)],
574
+ flash_size='detect',
575
+ )
576
+
577
+ def command_dump_image(esp: ESPLoader, output_file: Optional[str]):
578
+ flash_size = flash_size_bytes(detect_flash_size(esp))
579
+
580
+ if not output_file:
581
+ chip = esp.CHIP_NAME.lower().replace(' ', '-')
582
+ try:
583
+ mac = esp.read_mac("BASE_MAC")
584
+ serial = ''.join(f"{b:02x}" for b in mac)
585
+ except Exception:
586
+ serial = "unknown"
587
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
588
+ output_file = f"{chip}-{serial}-{timestamp}.img"
589
+
590
+ print(f"Dumping {flash_size:#x} bytes of flash to {output_file}...")
591
+ read_flash(esp, address=0, size=flash_size, output=output_file)
592
+
593
+ def command_dump_bundle(esp: ESPLoader, partition_table: PartitionTable, output_file: Optional[str]):
594
+ if not output_file:
595
+ chip = esp.CHIP_NAME.lower().replace(' ', '-')
596
+ try:
597
+ mac = esp.read_mac("BASE_MAC")
598
+ serial = ''.join(f"{b:02x}" for b in mac)
599
+ except Exception:
600
+ serial = "unknown"
601
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
602
+ output_file = f"{chip}-{serial}-{timestamp}.zip"
603
+
604
+ with ZipFile(output_file, 'w') as zf:
605
+ for partition in partition_table:
606
+ print(f"Reading partition {partition.name} (offset={partition.offset:#x}, size={partition.size:#x})")
607
+ data = esp.read_flash(partition.offset, partition.size)
608
+ zf.writestr(f"{partition.name}.bin", data)
609
+ zf.writestr("partition_table.csv", partition_table.to_csv())
610
+ print(f"Adding partition table CSV to bundle")
611
+
612
+ print(f"Bundle written to {output_file}")
613
+
614
+ def command_enter_bootloader(port: str, baud: int, poll_interval: float = 0.05):
615
+ """Fast-poll a serial port path and enter the ROM bootloader as soon as it appears.
616
+
617
+ Skips run_stub and does not reset the chip on exit, so the device is left
618
+ parked in the ROM bootloader (download mode) for the next tool to grab.
619
+ """
620
+ print(f"Waiting for {port}...", file=sys.stderr)
621
+ while True:
622
+ while not os.path.exists(port):
623
+ time.sleep(poll_interval)
624
+ try:
625
+ esp = detect_chip(port, baud=baud)
626
+ break
627
+ except Exception as e:
628
+ print(f"Bootloader entry failed: {type(e).__name__}: {e}. Retrying...", file=sys.stderr)
629
+ time.sleep(poll_interval)
630
+ print(f"In download mode: {esp.CHIP_NAME} ({port})")
631
+
632
+ def main(args):
633
+ if args.command == 'devices':
634
+ devices = _get_port_list()
635
+ for d in devices:
636
+ print(f"{d.device} || {d.description} || {d.hwid}")
637
+ return
638
+
639
+ if args.command == 'enter-bootloader':
640
+ command_enter_bootloader(port=args.port, baud=args.baud)
641
+ return
642
+
643
+ # Connect to ESP device if required
644
+ requires_esp = args.command not in ['list', 'create-image', 'create-bundle'] or not args.partition_table_file
645
+ esp = get_esp(port=args.port, baud=args.baud) if requires_esp else None
646
+ if not args.primary_bootloader_offset and esp:
647
+ args.primary_bootloader_offset = esp.BOOTLOADER_FLASH_OFFSET
648
+
649
+ if args.command == 'dump-image':
650
+ command_dump_image(esp=esp, output_file=args.output_file)
651
+ return
652
+
653
+ # Load partition table
654
+ if args.command == 'write-image':
655
+ with open(args.image_file, 'rb') as f:
656
+ f.seek(args.partition_table_offset)
657
+ partition_table_binary = f.read(args.partition_table_size)
658
+ partition_table = PartitionTable.from_binary(partition_table_binary)
659
+ elif args.partition_table_file:
660
+ with open(args.partition_table_file, 'rb') as f:
661
+ partition_table = PartitionTable.from_file(
662
+ f,
663
+ partition_table_offset=args.partition_table_offset,
664
+ primary_bootloader_offset=args.primary_bootloader_offset,
665
+ recovery_bootloader_offset=args.recovery_bootloader_offset
666
+ )[0]
667
+ elif args.command == 'write-bundle' and check_write_bundle_has_partition_table(args.input_file):
668
+ with ZipFile(args.input_file, 'r') as tar:
669
+ partition_table_file = tar.read('partition_table.csv')
670
+ if not partition_table_file:
671
+ raise RuntimeError("Partition table could not be loaded from bundle")
672
+ partition_table = PartitionTable.from_csv(
673
+ partition_table_file.decode('utf-8'),
674
+ partition_table_offset=args.partition_table_offset,
675
+ primary_bootloader_offset=args.primary_bootloader_offset,
676
+ recovery_bootloader_offset=args.recovery_bootloader_offset
677
+ )
678
+ else:
679
+ try:
680
+ partition_table_binary = esp.read_flash(offset=args.partition_table_offset, length=args.partition_table_size)
681
+ partition_table = PartitionTable.from_binary(partition_table_binary)
682
+ except RuntimeError as e:
683
+ raise RuntimeError("Partition table could not be loaded") from e
684
+
685
+ # Read otadata so the printed table can mark the active app partition
686
+ otadata_params: OtaDataParameters | None = None
687
+ if esp:
688
+ try:
689
+ _, otadata_params = read_otadata(esp, partition_table)
690
+ except ValueError:
691
+ pass
692
+
693
+ # Print partition table
694
+ print_partition_table(partition_table, esp, otadata=otadata_params)
695
+
696
+ if esp:
697
+ flash_size_str = detect_flash_size(esp)
698
+ flash_size = flash_size_bytes(flash_size_str)
699
+ partition_table.verify_size_fits(flash_size)
700
+
701
+ # Add virtual partition table entry if not present
702
+ partition_table_entry = next(
703
+ (e for e in partition_table if e.type == PARTITION_TABLE_TYPE and e.subtype == SUBTYPES[e.type]['primary']),
704
+ PartitionDefinition.default_partition_table(
705
+ offset=args.partition_table_offset,
706
+ size=args.partition_table_size
707
+ )
708
+ )
709
+
710
+ # Add virtual bootloader partition if not present and bootloader offsets are provided
711
+ bootloader_entry = next(
712
+ (e for e in partition_table if e.type == BOOTLOADER_TYPE and e.subtype == SUBTYPES[e.type]['primary']),
713
+ PartitionDefinition.default_bootloader(
714
+ offset=args.primary_bootloader_offset,
715
+ size=args.partition_table_offset - args.primary_bootloader_offset
716
+ ) if args.primary_bootloader_offset is not None else None
717
+ )
718
+
719
+ if args.command == 'list':
720
+ pass
721
+ elif args.command == 'read':
722
+ command_read(
723
+ esp=esp,
724
+ partition_table=partition_table,
725
+ partition_table_entry=partition_table_entry,
726
+ bootloader_entry=bootloader_entry,
727
+ label=args.partition,
728
+ output_file=args.output_file
729
+ )
730
+ elif args.command == 'write':
731
+ command_write(
732
+ esp=esp,
733
+ partition_table=partition_table,
734
+ partition_table_entry=partition_table_entry,
735
+ bootloader_entry=bootloader_entry,
736
+ files=args.files
737
+ )
738
+ elif args.command == 'erase':
739
+ command_erase(
740
+ esp=esp,
741
+ partition_table=partition_table,
742
+ partition_table_entry=partition_table_entry,
743
+ bootloader_entry=bootloader_entry,
744
+ label=args.partition
745
+ )
746
+ elif args.command == 'view':
747
+ command_view(
748
+ esp=esp,
749
+ partition_table=partition_table,
750
+ partition_table_entry=partition_table_entry,
751
+ bootloader_entry=bootloader_entry,
752
+ label=args.partition,
753
+ output=args.output,
754
+ width=args.width
755
+ )
756
+ elif args.command == 'create-image':
757
+ command_create_image(
758
+ partition_table=partition_table,
759
+ partition_table_entry=partition_table_entry,
760
+ bootloader_entry=bootloader_entry,
761
+ files=args.files,
762
+ flash_partition_table=args.flash_partition_table,
763
+ output_format=args.format,
764
+ output_file=args.output_file
765
+ )
766
+ elif args.command == 'create-bundle':
767
+ command_create_bundle(
768
+ partition_table=partition_table,
769
+ partition_table_entry=partition_table_entry,
770
+ bootloader_entry=bootloader_entry,
771
+ files=args.files,
772
+ flash_partition_table=args.flash_partition_table,
773
+ output_file=args.output_file
774
+ )
775
+ elif args.command == 'dump-bundle':
776
+ command_dump_bundle(esp=esp, partition_table=partition_table, output_file=args.output_file)
777
+ elif args.command == 'write-bundle':
778
+ command_write_bundle(
779
+ esp=esp,
780
+ partition_table=partition_table,
781
+ partition_table_entry=partition_table_entry,
782
+ bootloader_entry=bootloader_entry,
783
+ input_file=args.input_file
784
+ )
785
+ elif args.command == 'get-boot':
786
+ command_get_boot(esp=esp, partition_table=partition_table)
787
+ elif args.command == 'set-boot':
788
+ command_set_boot(esp=esp, partition_table=partition_table, label=args.partition)
789
+ elif args.command == 'clear-boot':
790
+ command_clear_boot(esp=esp, partition_table=partition_table)
791
+ elif args.command == 'ota':
792
+ command_ota(esp=esp, partition_table=partition_table, app_binary_file=args.app_binary_file)
793
+ elif args.command == 'factory':
794
+ command_factory(esp=esp, partition_table=partition_table, app_binary_file=args.app_binary_file)
795
+ elif args.command == 'write-image':
796
+ command_write_image(esp=esp, bootloader_entry=bootloader_entry, image_file_path=args.image_file)
797
+ else:
798
+ print(f"Unknown command: {args.command}", file=sys.stderr)
799
+
800
+ if esp and not args.no_reset: esp.hard_reset()
801
+
802
+ def _main():
803
+ parser = argparse.ArgumentParser()
804
+
805
+ parser.add_argument('-p', '--port', help='Serial port device')
806
+ parser.add_argument('-b', '--baud', type=int, help='Serial port baud rate', default=ESPLoader.ESP_ROM_BAUD)
807
+ parser.add_argument('--no-reset', action='store_true', help='Do not reset the chip after operations')
808
+
809
+ parser.add_argument('--partition-table-file', help='Path to partition table CSV or binary file to use instead of reading from device')
810
+ parser.add_argument('--partition-table-offset', type=auto_int, help='Partition table offset (where to read partition table from flash, or where to place when loading partition table from CSV)', default=PARTITION_TABLE_OFFSET)
811
+ parser.add_argument('--partition-table-size', type=auto_int, help='Partition table size', default=PARTITION_TABLE_SIZE)
812
+ parser.add_argument('--primary-bootloader-offset', type=parse_bootloader_offset, help='Primary bootloader offset or chip type e.g. esp32s3 (used when loading partition table from CSV)')
813
+ parser.add_argument('--recovery-bootloader-offset', type=auto_int, help='Recovery bootloader offset (used when loading partition table from CSV)')
814
+
815
+ # Create subparsers
816
+ subparsers = parser.add_subparsers(dest='command', help='Available commands', required=True)
817
+
818
+ # Devices subcommand
819
+ devices_parser = subparsers.add_parser('devices', help='Device list')
820
+
821
+ # List subcommand
822
+ list_parser = subparsers.add_parser('list', help='List partitions')
823
+ list_parser.add_argument('--partition-type', help='Filter by partition type')
824
+ list_parser.add_argument('--partition-subtype', help='Filter by partition subtype')
825
+ list_parser.add_argument('--partition-name', help='Filter by partition name')
826
+
827
+ # Read subcommand
828
+ read_parser = subparsers.add_parser('read', help='Read partition')
829
+ read_parser.add_argument('partition', help='Name of the partition to read')
830
+ read_parser.add_argument('output_file', help='Output file to save the partition data')
831
+
832
+ # Write subcommand
833
+ write_parser = subparsers.add_parser('write', help='Write partitions')
834
+ write_parser.add_argument(
835
+ 'files',
836
+ nargs='+',
837
+ metavar=('PARTITION', 'FILENAME'),
838
+ help='Partition and filename pairs'
839
+ )
840
+
841
+ # Erase subcommand
842
+ erase_parser = subparsers.add_parser('erase', help='Erase partition')
843
+ erase_parser.add_argument('partition', help='Name of the partition to erase')
844
+
845
+ # View subcommand
846
+ view_parser = subparsers.add_parser('view', help='View partition')
847
+ view_parser.add_argument('partition', help='Name of the partition to view')
848
+ view_parser.add_argument('-w', '--width', type=int, help='Width of the hexdump', default=16)
849
+ view_parser.add_argument('--hex', action='store_const', const='hex', dest='output', help='Output as hex dump', default='hex')
850
+ view_parser.add_argument('-s', '--string', action='store_const', const='str', dest='output', help='Output as string')
851
+
852
+ # Create Image subcommand
853
+ create_image_parser = subparsers.add_parser('create-image', help='Merge partition binaries into a single flash image')
854
+ create_image_parser.add_argument('-o', '--output', help='Output filename', dest='output_file', required=True)
855
+ create_image_parser.add_argument('-f', '--format', help='Output format', choices=['raw', 'uf2', 'hex'], default='raw')
856
+ create_image_parser.add_argument('--flash-partition-table', action='store_true', help='Include partition table in the image')
857
+ create_image_parser.add_argument(
858
+ 'files',
859
+ nargs='+',
860
+ metavar='PARTITION FILENAME',
861
+ help='Partition and filename pairs'
862
+ )
863
+
864
+ # Dump Image subcommand
865
+ dump_image_parser = subparsers.add_parser('dump-image', help='Dump the entire flash to an image file')
866
+ dump_image_parser.add_argument('output_file', nargs='?', help='Output filename (default: {chip}-{mac}-{timestamp}.img)')
867
+
868
+ # Write Image subcommand (alias: reflash)
869
+ write_image_parser = subparsers.add_parser('write-image', aliases=['reflash'], help='Write a full flash image to the device')
870
+ write_image_parser.add_argument('image_file', help='Input file containing flash image')
871
+
872
+ # Create bundle subcommand
873
+ create_bundle_parser = subparsers.add_parser('create-bundle', help='Create a ZIP bundle of partition binaries')
874
+ create_bundle_parser.add_argument('-o', '--output', help='Output ZIP filename', dest='output_file', required=True)
875
+ create_bundle_parser.add_argument('--flash-partition-table', action='store_true', help='Include partition table in the bundle')
876
+ create_bundle_parser.add_argument(
877
+ 'files',
878
+ nargs='+',
879
+ metavar='PARTITION FILENAME',
880
+ help='Partition and filename pairs'
881
+ )
882
+
883
+ # Dump Bundle subcommand
884
+ dump_bundle_parser = subparsers.add_parser('dump-bundle', help='Dump all partitions to a ZIP bundle')
885
+ dump_bundle_parser.add_argument('output_file', nargs='?', help='Output ZIP filename (default: {chip}-{mac}-{timestamp}.zip)')
886
+
887
+ # Write bundle subcommand
888
+ write_bundle_parser = subparsers.add_parser('write-bundle', help='Write a ZIP bundle of partition binaries')
889
+ write_bundle_parser.add_argument('input_file', help='Input ZIP filename')
890
+
891
+ # Factory subcommand
892
+ factory_parser = subparsers.add_parser('factory', help='Perform factory flash and clear boot partition')
893
+ factory_parser.add_argument('app_binary_file', help='Input file containing app binary')
894
+
895
+ # Ota subcommand
896
+ ota_parser = subparsers.add_parser('ota', help='Perform OTA')
897
+ ota_parser.add_argument('app_binary_file', help='Input file containing app binary')
898
+
899
+ # Get Boot subcommand
900
+ get_boot_parser = subparsers.add_parser('get-boot', help='Get boot partition')
901
+
902
+ # Set Boot subcommand
903
+ set_boot_parser = subparsers.add_parser('set-boot', help='Set boot partition')
904
+ set_boot_parser.add_argument('partition', help='Name of the partition to set as boot')
905
+
906
+ # Clear Boot subcommand
907
+ clear_boot_parser = subparsers.add_parser('clear-boot', help='Clear boot partition')
908
+
909
+ # Enter Bootloader subcommand
910
+ enter_bootloader_parser = subparsers.add_parser('enter-bootloader', help='Fast-poll the serial port and enter the ROM bootloader as soon as the device appears, then exit without resetting')
911
+
912
+ args = parser.parse_args()
913
+ if args.command == 'reflash':
914
+ args.command = 'write-image'
915
+ if args.command == 'enter-bootloader' and not args.port:
916
+ parser.error("enter-bootloader requires -p/--port")
917
+ main(args)
918
+
919
+ if __name__ == "__main__":
920
+ _main()