idftool 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (25) hide show
  1. idftool-0.3.0/CHANGELOG.md +94 -0
  2. {idftool-0.2.1 → idftool-0.3.0}/PKG-INFO +92 -14
  3. {idftool-0.2.1 → idftool-0.3.0}/README.md +90 -12
  4. {idftool-0.2.1 → idftool-0.3.0}/idftool/__main__.py +426 -34
  5. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/PKG-INFO +92 -14
  6. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/SOURCES.txt +3 -0
  7. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/requires.txt +1 -1
  8. idftool-0.3.0/idftool.egg-info/scm_file_list.json +19 -0
  9. idftool-0.3.0/idftool.egg-info/scm_version.json +8 -0
  10. {idftool-0.2.1 → idftool-0.3.0}/pyproject.toml +1 -1
  11. {idftool-0.2.1 → idftool-0.3.0}/.github/workflows/build.yml +0 -0
  12. {idftool-0.2.1 → idftool-0.3.0}/.github/workflows/publish.yml +0 -0
  13. {idftool-0.2.1 → idftool-0.3.0}/.gitignore +0 -0
  14. {idftool-0.2.1 → idftool-0.3.0}/CONTRIBUTING.md +0 -0
  15. {idftool-0.2.1 → idftool-0.3.0}/LICENSE.md +0 -0
  16. {idftool-0.2.1 → idftool-0.3.0}/Makefile +0 -0
  17. {idftool-0.2.1 → idftool-0.3.0}/idftool/__init__.py +0 -0
  18. {idftool-0.2.1 → idftool-0.3.0}/idftool/traceback.py +0 -0
  19. {idftool-0.2.1 → idftool-0.3.0}/idftool-onedir.spec +0 -0
  20. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/dependency_links.txt +0 -0
  21. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/entry_points.txt +0 -0
  22. {idftool-0.2.1 → idftool-0.3.0}/idftool.egg-info/top_level.txt +0 -0
  23. {idftool-0.2.1 → idftool-0.3.0}/idftool.py +0 -0
  24. {idftool-0.2.1 → idftool-0.3.0}/idftool.spec +0 -0
  25. {idftool-0.2.1 → idftool-0.3.0}/setup.cfg +0 -0
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
+ this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [v0.3.0] — 2026-07-15
8
+
9
+ ### Added
10
+ - Partition table file commands that make the table itself the subject
11
+ (a positional file), keeping the global `--partition-table-file`
12
+ option for its existing role of overriding the layout of other
13
+ commands:
14
+ - `convert-table <in> <out>` — convert a partition table between CSV
15
+ and binary, offline. Format is auto-detected on input and inferred
16
+ from the output extension (`.csv`/`.bin`) or `--format`.
17
+ - `print-table [file]` — pretty-print a partition table from a CSV or
18
+ binary file (offline), or from the device when no file is given.
19
+ - `dump-table [output]` — read the partition table from the device and
20
+ save it as CSV or binary (default filename
21
+ `{chip}-{mac}-{timestamp}-partition-table.csv`).
22
+ - `write-table <file>` — flash a partition table from a CSV or binary
23
+ file to the device. Requires an explicit file (so it can never read
24
+ the device's current table and write it back to itself), verifies
25
+ the table before flashing (override with `--force`), and warns that
26
+ only the map is replaced — existing partition data is not moved or
27
+ erased.
28
+
29
+ ### Changed
30
+ - `list` is now an alias of `print-table` (both print the device's
31
+ partition table identically). Its `--partition-type`,
32
+ `--partition-subtype`, and `--partition-name` flags — which were never
33
+ implemented — have been removed.
34
+
35
+ ### Fixed
36
+ - Working with empty or truncated input files now reports a clear error
37
+ instead of a confusing parser failure (e.g. "Partition table is
38
+ missing an end-of-table marker" for a zero-byte image). Empty images,
39
+ app binaries, and bundle ZIPs are rejected up front, as are images too
40
+ small to reach the partition table offset and corrupt (non-ZIP)
41
+ bundles.
42
+ - A parsed-but-empty partition table (all-`0xFF` binary region,
43
+ comment-only CSV, etc.) is now rejected across all parse paths rather
44
+ than silently yielding a table with no partitions.
45
+ - Loading a partition table CSV that contains bootloader rows (such as one
46
+ produced by `dump-table`) no longer requires re-specifying
47
+ `--primary-bootloader-offset`: the offset embedded in the CSV is
48
+ recovered from the file, so dumped tables round-trip through
49
+ `print-table`, `convert-table`, `write-table`, and `--partition-table-file`
50
+ without extra arguments. The flag still works as an explicit override.
51
+
52
+ ## [v0.2.2] — 2026-06-15
53
+
54
+ ### Added
55
+ - `print-image` — inspect a flash image file offline: prints the
56
+ partition table and, for each app partition that contains a valid
57
+ app, the project name, version, IDF version, compile time, ELF
58
+ SHA256, and target chip.
59
+ - `print-bundle` — same output for a partition bundle ZIP, driven by
60
+ the embedded `partition_table.csv` and the per-partition `.bin`
61
+ entries.
62
+
63
+ ### Changed
64
+ - Bumped `esp-idf-defs` to `~=0.1.3`. `print_partition_table` now takes
65
+ a `(offset, length) -> bytes` accessor instead of an `ESPLoader`
66
+ instance, so the same printing path serves device, image, and bundle
67
+ inspection.
68
+
69
+ ## [v0.2.1] — 2026-06-10
70
+
71
+ ### Changed
72
+ - Boot partition state now uses `OtaImageState` for clearer reporting.
73
+
74
+ ## [v0.2.0] — 2026-05-26
75
+
76
+ ### Added
77
+ - `create-nvs` and `write-nvs` commands for generating NVS partition
78
+ images from CSV.
79
+ - Rich-style boxed traceback formatter for friendlier error output.
80
+
81
+ ### Documentation
82
+ - README installation section and command index table.
83
+ - Recommend `pipx` over the standalone binary for installation.
84
+
85
+ ## [v0.1.0] — 2026-05-25
86
+
87
+ ### Added
88
+ - Initial release.
89
+
90
+ [v0.3.0]: https://github.com/nebkat/idftool/releases/tag/v0.3.0
91
+ [v0.2.2]: https://github.com/nebkat/idftool/releases/tag/v0.2.2
92
+ [v0.2.1]: https://github.com/nebkat/idftool/releases/tag/v0.2.1
93
+ [v0.2.0]: https://github.com/nebkat/idftool/releases/tag/v0.2.0
94
+ [v0.1.0]: https://github.com/nebkat/idftool/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idftool
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Utility for flashing, provisioning, and interacting with Espressif SOCs running ESP-IDF
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Source, https://github.com/nebkat/idftool
@@ -8,7 +8,7 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE.md
10
10
  Requires-Dist: esptool~=5.2.0
11
- Requires-Dist: esp-idf-defs~=0.1.0
11
+ Requires-Dist: esp-idf-defs~=0.1.3
12
12
  Requires-Dist: esp-idf-nvs-partition-gen~=0.2.0
13
13
  Dynamic: license-file
14
14
 
@@ -103,7 +103,7 @@ Setting boot partition to 'ota_1'...
103
103
  |---------|-------------|
104
104
  | **Discovery** | |
105
105
  | [`devices`](#devices) | List serial ports with hardware IDs |
106
- | [`list`](#list) | Print the partition table |
106
+ | [`list`](#print-table) | Print the partition table (alias of `print-table`) |
107
107
  | **Partition I/O** | |
108
108
  | [`read`](#read) | Read a partition (or slice) into a file |
109
109
  | [`write`](#write) | Write one or more files to named partitions |
@@ -120,10 +120,17 @@ Setting boot partition to 'ota_1'...
120
120
  | [`create-image`](#create-image) | Merge partition binaries into a single flash image |
121
121
  | [`dump-image`](#dump-image) | Dump the entire flash to an image file |
122
122
  | [`write-image`](#write-image-alias-reflash) | Write a full flash image to the device |
123
+ | [`print-image`](#print-image) | Print partition table and app info from a flash image file |
123
124
  | **Bundles** | |
124
125
  | [`create-bundle`](#create-bundle) | Pack partition images into a ZIP bundle |
125
126
  | [`dump-bundle`](#dump-bundle) | Pack every partition from the device into a ZIP |
126
127
  | [`write-bundle`](#write-bundle) | Flash every binary in a bundle ZIP |
128
+ | [`print-bundle`](#print-bundle) | Print partition table and app info from a bundle ZIP |
129
+ | **Partition table** | |
130
+ | [`convert-table`](#convert-table) | Convert a partition table file between CSV and binary |
131
+ | [`print-table`](#print-table) | Print a partition table from a CSV or binary file, or the device |
132
+ | [`dump-table`](#dump-table) | Read the partition table from the device into a file |
133
+ | [`write-table`](#write-table) | Flash a partition table from a CSV or binary file |
127
134
  | **NVS** | |
128
135
  | [`create-nvs`](#create-nvs) | Generate an NVS partition image from a CSV file |
129
136
  | [`write-nvs`](#write-nvs) | Generate an NVS image from CSV and flash it |
@@ -139,17 +146,6 @@ hardware IDs.
139
146
  idftool devices
140
147
  ```
141
148
 
142
- #### `list`
143
- Print the device's partition table. With a connected ESP, idftool also
144
- reads the application descriptor of every app partition (project name,
145
- version) and marks the currently-active OTA slot with a trailing `*`. The
146
- selected otadata copy is shown next to the otadata partition's subtype
147
- (`ota (A)`, `ota (B)`, or `ota (invalid)` when otadata is erased).
148
- ```text
149
- idftool list
150
- idftool --partition-table-file partitions.csv list # offline
151
- ```
152
-
153
149
  ### Partition I/O
154
150
 
155
151
  #### `read`
@@ -259,6 +255,15 @@ counterpart of `dump-image`.
259
255
  idftool write-image build/full-flash.img
260
256
  ```
261
257
 
258
+ #### `print-image`
259
+ Inspect a flash image file without touching a device: prints the
260
+ partition table embedded in the image and, for each app partition that
261
+ contains a valid app, the project name, version, IDF version, compile
262
+ time, ELF SHA256, and target chip.
263
+ ```text
264
+ idftool print-image build/full-flash.img
265
+ ```
266
+
262
267
  ### Bundles
263
268
 
264
269
  A bundle is a plain ZIP file containing one `*.bin` per partition (named
@@ -292,6 +297,79 @@ to match) instead of reading the table from flash.
292
297
  idftool write-bundle release.zip
293
298
  ```
294
299
 
300
+ #### `print-bundle`
301
+ Inspect a bundle ZIP without touching a device: prints the partition
302
+ table from the embedded `partition_table.csv` and, for each app
303
+ partition whose `.bin` is present in the bundle, the project name,
304
+ version, IDF version, compile time, ELF SHA256, and target chip.
305
+ ```text
306
+ idftool print-bundle release.zip
307
+ ```
308
+
309
+ ### Partition table
310
+
311
+ These commands work directly on a partition table as a file — the table
312
+ is the subject, passed as a positional argument. This is distinct from the
313
+ global `--partition-table-file` option, which overrides the layout *other*
314
+ commands use to address partitions by name. Input format (CSV or binary)
315
+ is auto-detected; output format is inferred from the file extension
316
+ (`.csv`/`.bin`) or set with `--format`.
317
+
318
+ When the input is a CSV that includes a bootloader row, pass
319
+ `--primary-bootloader-offset` (an offset or a chip name, e.g. `esp32s3`)
320
+ so the offset can be resolved.
321
+
322
+ #### `convert-table`
323
+ Convert a partition table between CSV and binary, offline. Handles both
324
+ directions; the binary output includes the MD5 checksum and padding, so it
325
+ is ready to flash or embed in an image.
326
+ ```text
327
+ idftool convert-table partitions.csv partitions.bin
328
+ idftool convert-table partitions.bin partitions.csv
329
+ idftool --primary-bootloader-offset esp32s3 convert-table partitions.csv partitions.bin
330
+ ```
331
+
332
+ #### `print-table`
333
+ Pretty-print a partition table. Given a CSV or binary file it runs offline;
334
+ with no file it reads the table from the device (or from
335
+ `--partition-table-file`). With a connected ESP, idftool also reads the
336
+ application descriptor of every app partition (project name, version) and
337
+ marks the currently-active OTA slot with a trailing `*`; the selected otadata
338
+ copy is shown next to the otadata partition's subtype (`ota (A)`, `ota (B)`,
339
+ or `ota (invalid)` when otadata is erased).
340
+
341
+ Aliased as `list`.
342
+ ```text
343
+ idftool print-table partitions.bin # from a file, offline
344
+ idftool print-table # from the device
345
+ idftool list # same thing
346
+ idftool --partition-table-file partitions.csv print-table
347
+ ```
348
+
349
+ #### `dump-table`
350
+ Read the partition table from the connected device and save it. Defaults to
351
+ CSV with an auto-generated filename; pass an output file or `--format` to
352
+ choose otherwise.
353
+ ```text
354
+ idftool dump-table # auto-named .csv
355
+ idftool dump-table backup.bin
356
+ idftool dump-table backup.csv --format csv
357
+ ```
358
+
359
+ #### `write-table`
360
+ Flash a partition table from a CSV or binary file to the device. The file is
361
+ required, so this never reads the device's current table and writes it back
362
+ to itself — use `dump-table` to pull the current table. The table is verified
363
+ before flashing (override with `--force`).
364
+
365
+ Only the partition map (at the partition table offset) is replaced; existing
366
+ partition **data** on flash is not moved, resized, or erased, so a table that
367
+ no longer matches the flash contents can make the device unbootable.
368
+ ```text
369
+ idftool write-table partitions.csv
370
+ idftool write-table partitions.bin --force
371
+ ```
372
+
295
373
  ### NVS
296
374
 
297
375
  idftool can generate a binary NVS (Non-Volatile Storage) partition image
@@ -89,7 +89,7 @@ Setting boot partition to 'ota_1'...
89
89
  |---------|-------------|
90
90
  | **Discovery** | |
91
91
  | [`devices`](#devices) | List serial ports with hardware IDs |
92
- | [`list`](#list) | Print the partition table |
92
+ | [`list`](#print-table) | Print the partition table (alias of `print-table`) |
93
93
  | **Partition I/O** | |
94
94
  | [`read`](#read) | Read a partition (or slice) into a file |
95
95
  | [`write`](#write) | Write one or more files to named partitions |
@@ -106,10 +106,17 @@ Setting boot partition to 'ota_1'...
106
106
  | [`create-image`](#create-image) | Merge partition binaries into a single flash image |
107
107
  | [`dump-image`](#dump-image) | Dump the entire flash to an image file |
108
108
  | [`write-image`](#write-image-alias-reflash) | Write a full flash image to the device |
109
+ | [`print-image`](#print-image) | Print partition table and app info from a flash image file |
109
110
  | **Bundles** | |
110
111
  | [`create-bundle`](#create-bundle) | Pack partition images into a ZIP bundle |
111
112
  | [`dump-bundle`](#dump-bundle) | Pack every partition from the device into a ZIP |
112
113
  | [`write-bundle`](#write-bundle) | Flash every binary in a bundle ZIP |
114
+ | [`print-bundle`](#print-bundle) | Print partition table and app info from a bundle ZIP |
115
+ | **Partition table** | |
116
+ | [`convert-table`](#convert-table) | Convert a partition table file between CSV and binary |
117
+ | [`print-table`](#print-table) | Print a partition table from a CSV or binary file, or the device |
118
+ | [`dump-table`](#dump-table) | Read the partition table from the device into a file |
119
+ | [`write-table`](#write-table) | Flash a partition table from a CSV or binary file |
113
120
  | **NVS** | |
114
121
  | [`create-nvs`](#create-nvs) | Generate an NVS partition image from a CSV file |
115
122
  | [`write-nvs`](#write-nvs) | Generate an NVS image from CSV and flash it |
@@ -125,17 +132,6 @@ hardware IDs.
125
132
  idftool devices
126
133
  ```
127
134
 
128
- #### `list`
129
- Print the device's partition table. With a connected ESP, idftool also
130
- reads the application descriptor of every app partition (project name,
131
- version) and marks the currently-active OTA slot with a trailing `*`. The
132
- selected otadata copy is shown next to the otadata partition's subtype
133
- (`ota (A)`, `ota (B)`, or `ota (invalid)` when otadata is erased).
134
- ```text
135
- idftool list
136
- idftool --partition-table-file partitions.csv list # offline
137
- ```
138
-
139
135
  ### Partition I/O
140
136
 
141
137
  #### `read`
@@ -245,6 +241,15 @@ counterpart of `dump-image`.
245
241
  idftool write-image build/full-flash.img
246
242
  ```
247
243
 
244
+ #### `print-image`
245
+ Inspect a flash image file without touching a device: prints the
246
+ partition table embedded in the image and, for each app partition that
247
+ contains a valid app, the project name, version, IDF version, compile
248
+ time, ELF SHA256, and target chip.
249
+ ```text
250
+ idftool print-image build/full-flash.img
251
+ ```
252
+
248
253
  ### Bundles
249
254
 
250
255
  A bundle is a plain ZIP file containing one `*.bin` per partition (named
@@ -278,6 +283,79 @@ to match) instead of reading the table from flash.
278
283
  idftool write-bundle release.zip
279
284
  ```
280
285
 
286
+ #### `print-bundle`
287
+ Inspect a bundle ZIP without touching a device: prints the partition
288
+ table from the embedded `partition_table.csv` and, for each app
289
+ partition whose `.bin` is present in the bundle, the project name,
290
+ version, IDF version, compile time, ELF SHA256, and target chip.
291
+ ```text
292
+ idftool print-bundle release.zip
293
+ ```
294
+
295
+ ### Partition table
296
+
297
+ These commands work directly on a partition table as a file — the table
298
+ is the subject, passed as a positional argument. This is distinct from the
299
+ global `--partition-table-file` option, which overrides the layout *other*
300
+ commands use to address partitions by name. Input format (CSV or binary)
301
+ is auto-detected; output format is inferred from the file extension
302
+ (`.csv`/`.bin`) or set with `--format`.
303
+
304
+ When the input is a CSV that includes a bootloader row, pass
305
+ `--primary-bootloader-offset` (an offset or a chip name, e.g. `esp32s3`)
306
+ so the offset can be resolved.
307
+
308
+ #### `convert-table`
309
+ Convert a partition table between CSV and binary, offline. Handles both
310
+ directions; the binary output includes the MD5 checksum and padding, so it
311
+ is ready to flash or embed in an image.
312
+ ```text
313
+ idftool convert-table partitions.csv partitions.bin
314
+ idftool convert-table partitions.bin partitions.csv
315
+ idftool --primary-bootloader-offset esp32s3 convert-table partitions.csv partitions.bin
316
+ ```
317
+
318
+ #### `print-table`
319
+ Pretty-print a partition table. Given a CSV or binary file it runs offline;
320
+ with no file it reads the table from the device (or from
321
+ `--partition-table-file`). With a connected ESP, idftool also reads the
322
+ application descriptor of every app partition (project name, version) and
323
+ marks the currently-active OTA slot with a trailing `*`; the selected otadata
324
+ copy is shown next to the otadata partition's subtype (`ota (A)`, `ota (B)`,
325
+ or `ota (invalid)` when otadata is erased).
326
+
327
+ Aliased as `list`.
328
+ ```text
329
+ idftool print-table partitions.bin # from a file, offline
330
+ idftool print-table # from the device
331
+ idftool list # same thing
332
+ idftool --partition-table-file partitions.csv print-table
333
+ ```
334
+
335
+ #### `dump-table`
336
+ Read the partition table from the connected device and save it. Defaults to
337
+ CSV with an auto-generated filename; pass an output file or `--format` to
338
+ choose otherwise.
339
+ ```text
340
+ idftool dump-table # auto-named .csv
341
+ idftool dump-table backup.bin
342
+ idftool dump-table backup.csv --format csv
343
+ ```
344
+
345
+ #### `write-table`
346
+ Flash a partition table from a CSV or binary file to the device. The file is
347
+ required, so this never reads the device's current table and writes it back
348
+ to itself — use `dump-table` to pull the current table. The table is verified
349
+ before flashing (override with `--force`).
350
+
351
+ Only the partition map (at the partition table offset) is replaced; existing
352
+ partition **data** on flash is not moved, resized, or erased, so a table that
353
+ no longer matches the flash contents can make the device unbootable.
354
+ ```text
355
+ idftool write-table partitions.csv
356
+ idftool write-table partitions.bin --force
357
+ ```
358
+
281
359
  ### NVS
282
360
 
283
361
  idftool can generate a binary NVS (Non-Volatile Storage) partition image
@@ -3,8 +3,8 @@ import os.path
3
3
  import re
4
4
  import sys
5
5
  import time
6
- from typing import Optional, Literal
7
- from zipfile import ZipFile
6
+ from typing import Callable, Optional, Literal
7
+ from zipfile import BadZipFile, ZipFile
8
8
 
9
9
  from esptool import ESPLoader, CHIP_DEFS, flash_size_bytes
10
10
  from serial import SerialException
@@ -17,7 +17,7 @@ from esp_idf_defs import ImageMetadata, ChipId
17
17
  from esp_idf_defs.otadata import OtaDataParameters, OtaDataSelectEntry, OtaImageState
18
18
  from esp_idf_defs.partitions import PARTITION_TABLE_SIZE, PARTITION_TABLE_OFFSET, PartitionTable, print_partition_table, \
19
19
  PartitionDefinition, BOOTLOADER_TYPE, SUBTYPES, PARTITION_TABLE_TYPE, APP_TYPE, NUM_PARTITION_SUBTYPE_APP_OTA, \
20
- DATA_TYPE
20
+ DATA_TYPE, get_encoding
21
21
  from esptool.cmds import detect_chip, read_flash, write_flash, erase_region, merge_bin, erase_flash, detect_flash_size
22
22
 
23
23
  PARTITION_SLICE_REGEX = re.compile(r"^(?P<partition>.+?)(?:\[(?:(?P<start>[-+]?(?:0x)?[0-9A-Fa-f]+)?(?::(?P<stop>[-+]?(?:0x)?[0-9A-Fa-f]+)?)?)?])?$")
@@ -239,6 +239,99 @@ def write_otadata(esp: ESPLoader, partition: PartitionDefinition, otadata: OtaDa
239
239
  esp.flash_block(data=otadata.otadata.to_bytes(), seq=0)
240
240
  esp.flash_finish()
241
241
 
242
+ def check_image_file(path: str, partition_table_offset: int) -> int:
243
+ """Validate an image file is non-empty and large enough to contain a partition table.
244
+
245
+ Returns the image size in bytes.
246
+ """
247
+ image_size = os.path.getsize(path)
248
+ if image_size == 0:
249
+ raise RuntimeError(f"Image file '{path}' is empty")
250
+ if image_size <= partition_table_offset:
251
+ raise RuntimeError(
252
+ f"Image file '{path}' ({image_size:#x} bytes) is smaller than the partition table "
253
+ f"offset {partition_table_offset:#x}; it does not appear to contain a partition table")
254
+ return image_size
255
+
256
+ def require_partitions(partition_table: PartitionTable, source: str) -> PartitionTable:
257
+ """Raise a clear error if a parsed partition table contains no partitions."""
258
+ if len(partition_table) == 0:
259
+ raise RuntimeError(f"Partition table from {source} is empty (no partitions defined)")
260
+ return partition_table
261
+
262
+ def load_partition_table_file(
263
+ path: str,
264
+ partition_table_offset: int,
265
+ primary_bootloader_offset: Optional[int],
266
+ recovery_bootloader_offset: Optional[int],
267
+ ) -> PartitionTable:
268
+ """Load a partition table from a CSV or binary file (format auto-detected)."""
269
+ if os.path.getsize(path) == 0:
270
+ raise RuntimeError(f"Partition table file '{path}' is empty")
271
+ with open(path, 'rb') as f:
272
+ data = f.read()
273
+
274
+ if data[:2] == PartitionDefinition.MAGIC_BYTES:
275
+ partition_table = PartitionTable.from_binary(data)
276
+ else:
277
+ csv_text = data.decode(get_encoding(data))
278
+ # A CSV with bootloader rows can't be parsed without the bootloader offsets. idftool writes
279
+ # those offsets into the CSV as literal bootloader rows, so recover them from the file itself
280
+ # when the user didn't pass them — this lets a dumped table round-trip without re-specifying
281
+ # --primary-bootloader-offset. (print-bundle does the same for bundle CSVs.)
282
+ if primary_bootloader_offset is None or recovery_bootloader_offset is None:
283
+ csv_primary, csv_recovery = _extract_csv_bootloader_offsets(csv_text)
284
+ if primary_bootloader_offset is None:
285
+ primary_bootloader_offset = csv_primary
286
+ if recovery_bootloader_offset is None:
287
+ recovery_bootloader_offset = csv_recovery
288
+ try:
289
+ partition_table = PartitionTable.from_csv(
290
+ csv_text,
291
+ partition_table_offset=partition_table_offset,
292
+ primary_bootloader_offset=primary_bootloader_offset,
293
+ recovery_bootloader_offset=recovery_bootloader_offset,
294
+ )
295
+ except RuntimeError as e:
296
+ msg = str(e)
297
+ if 'bootloader offset is not provided' in msg.lower():
298
+ which = 'recovery' if 'recovery' in msg.lower() else 'primary'
299
+ flag = ('--primary-bootloader-offset (an address or a chip name, e.g. esp32s3)'
300
+ if which == 'primary' else '--recovery-bootloader-offset')
301
+ line = re.search(r'line (\d+)', msg)
302
+ where = f" (line {line.group(1)})" if line else ""
303
+ raise RuntimeError(
304
+ f"The {which} bootloader entry in '{path}'{where} has no offset. "
305
+ f"Add the offset to the CSV, or pass {flag}."
306
+ ) from None
307
+ raise
308
+ return require_partitions(partition_table, f"file '{path}'")
309
+
310
+ def resolve_partition_table_format(output_file: Optional[str], explicit: Optional[str]) -> Literal['csv', 'bin']:
311
+ """Pick the output format from an explicit flag, else the output file extension (default csv)."""
312
+ if explicit:
313
+ return explicit
314
+ if output_file:
315
+ ext = os.path.splitext(output_file)[1].lower()
316
+ if ext == '.csv':
317
+ return 'csv'
318
+ if ext in ('.bin', '.img'):
319
+ return 'bin'
320
+ raise RuntimeError(
321
+ f"Cannot infer partition table format from '{output_file}'; "
322
+ f"pass --format csv|bin or use a .csv/.bin extension")
323
+ return 'csv'
324
+
325
+ def write_partition_table_file(partition_table: PartitionTable, output_file: str, output_format: Literal['csv', 'bin']):
326
+ """Serialise a partition table to CSV or binary and write it to output_file."""
327
+ if output_format == 'csv':
328
+ data = partition_table.to_csv().encode('utf-8')
329
+ else:
330
+ data = partition_table.to_binary()
331
+ with open(output_file, 'wb') as f:
332
+ f.write(data)
333
+ print(f"Wrote {output_format} partition table ({len(data):#x} bytes) to {output_file}")
334
+
242
335
  def validate_app_binary(esp: ESPLoader, app_binary: bytes) -> tuple[bytes, ImageMetadata]:
243
336
  try:
244
337
  image_metadata = ImageMetadata.from_bytes(app_binary, app_required=True)
@@ -256,6 +349,8 @@ def validate_app_binary(esp: ESPLoader, app_binary: bytes) -> tuple[bytes, Image
256
349
 
257
350
  def load_app_binary(esp: ESPLoader, app_binary_file: str, partition: PartitionDefinition) -> tuple[bytes, ImageMetadata]:
258
351
  app_binary_size = os.path.getsize(app_binary_file)
352
+ if app_binary_size == 0:
353
+ raise RuntimeError(f"Application binary '{app_binary_file}' is empty")
259
354
  if app_binary_size > partition.size:
260
355
  raise RuntimeError(
261
356
  f"Application binary size '{app_binary_size}' is greater than partition size {partition.size}")
@@ -404,7 +499,13 @@ def command_create_bundle(
404
499
  print(f"Adding partition table CSV to bundle")
405
500
 
406
501
  def check_write_bundle_has_partition_table(file: str) -> bool:
407
- with ZipFile(file, 'r') as zf:
502
+ if os.path.getsize(file) == 0:
503
+ raise RuntimeError(f"Bundle '{file}' is empty")
504
+ try:
505
+ bundle_zip = ZipFile(file, 'r')
506
+ except BadZipFile as e:
507
+ raise RuntimeError(f"Bundle '{file}' is not a valid ZIP archive") from e
508
+ with bundle_zip as zf:
408
509
  return any(m == 'partition_table.csv' for m in zf.namelist())
409
510
 
410
511
  def command_write_bundle(
@@ -569,27 +670,19 @@ def command_write_nvs(
569
670
  write_flash(esp=esp, addr_data=[(partition.offset, image)], flash_size='detect')
570
671
 
571
672
  def command_app_info(app_binary_file: str):
673
+ if os.path.getsize(app_binary_file) == 0:
674
+ raise RuntimeError(f"Application binary '{app_binary_file}' is empty")
572
675
  app_binary = open(app_binary_file, 'rb').read()
573
676
  try:
574
677
  image_metadata = ImageMetadata.from_bytes(app_binary, app_required=True)
575
678
  except ValueError as e:
576
679
  raise RuntimeError(f"Invalid application binary: {e}")
577
680
 
578
- desc = image_metadata.app_description
579
- header = image_metadata.header
580
-
581
- print(f"Project name: {desc.project_name}")
582
- print(f"Version: {desc.version}")
583
- print(f"IDF version: {desc.idf_version}")
584
- print(f"Secure version: {desc.secure_version}")
585
- if desc.compiled:
586
- print(f"Compiled: {desc.compiled}")
587
- print(f"ELF SHA256: {desc.elf_sha256.hex()}")
588
- max_rev = header.max_chip_rev_full if header.max_chip_rev_full != 0xFFFF else None
589
- rev_range = f"rev {header.min_chip_rev_full} to {max_rev}" if max_rev else f"rev {header.min_chip_rev_full}+"
590
- print(f"Chip: {header.chip_id.name} ({rev_range})")
681
+ print_app_info(image_metadata.app_description, image_metadata.header)
591
682
 
592
683
  def command_write_image(esp: ESPLoader, bootloader_entry: PartitionDefinition, image_file_path: str):
684
+ if os.path.getsize(image_file_path) == 0:
685
+ raise RuntimeError(f"Image file '{image_file_path}' is empty")
593
686
  image_file = open(image_file_path, 'rb')
594
687
  image_file.seek(bootloader_entry.offset)
595
688
  bootloader_binary = image_file.read(bootloader_entry.size)
@@ -619,6 +712,214 @@ def command_write_image(esp: ESPLoader, bootloader_entry: PartitionDefinition, i
619
712
  flash_size='detect',
620
713
  )
621
714
 
715
+ def print_app_info(desc, header, indent: str = ""):
716
+ print(f"{indent}Project name: {desc.project_name}")
717
+ print(f"{indent}Version: {desc.version}")
718
+ print(f"{indent}IDF version: {desc.idf_version}")
719
+ print(f"{indent}Secure version: {desc.secure_version}")
720
+ if desc.compiled:
721
+ print(f"{indent}Compiled: {desc.compiled}")
722
+ print(f"{indent}ELF SHA256: {desc.elf_sha256.hex()}")
723
+ max_rev = header.max_chip_rev_full if header.max_chip_rev_full != 0xFFFF else None
724
+ rev_range = f"rev {header.min_chip_rev_full} to {max_rev}" if max_rev else f"rev {header.min_chip_rev_full}+"
725
+ print(f"{indent}Chip: {header.chip_id.name} ({rev_range})")
726
+
727
+ def print_partition_table_and_apps(
728
+ partition_table: PartitionTable,
729
+ read: Callable[[int, int], bytes],
730
+ ):
731
+ print_partition_table(partition_table, read)
732
+
733
+ for part in partition_table:
734
+ if part.type != APP_TYPE:
735
+ continue
736
+ app_binary = read(part.offset, part.size)
737
+ try:
738
+ image_metadata = ImageMetadata.from_bytes(app_binary, app_required=True)
739
+ except (RuntimeError, ValueError):
740
+ continue
741
+ print()
742
+ print(f"Partition '{part.name}' (offset={part.offset:#x}):")
743
+ print_app_info(image_metadata.app_description, image_metadata.header, indent=" ")
744
+
745
+ def command_print_image(image_file_path: str, partition_table_offset: int, partition_table_size: int):
746
+ image_size = check_image_file(image_file_path, partition_table_offset)
747
+ with open(image_file_path, 'rb') as f:
748
+ f.seek(partition_table_offset)
749
+ partition_table_binary = f.read(partition_table_size)
750
+ try:
751
+ partition_table = PartitionTable.from_binary(partition_table_binary)
752
+ except (RuntimeError, ValueError) as e:
753
+ raise RuntimeError(f"Could not parse partition table at offset {partition_table_offset:#x}") from e
754
+ require_partitions(partition_table, f"image at offset {partition_table_offset:#x}")
755
+
756
+ def read(offset: int, length: int) -> bytes:
757
+ if offset >= image_size:
758
+ return b"\xff" * length
759
+ f.seek(offset)
760
+ data = f.read(length)
761
+ if len(data) < length:
762
+ data += b"\xff" * (length - len(data))
763
+ return data
764
+
765
+ print(f"Image: {image_file_path} ({image_size:#x} bytes)")
766
+ print()
767
+ print_partition_table_and_apps(partition_table, read)
768
+
769
+ def _extract_csv_bootloader_offsets(csv_text: str) -> tuple[Optional[int], Optional[int]]:
770
+ primary = recovery = None
771
+ for line in csv_text.splitlines():
772
+ line = line.strip()
773
+ if not line or line.startswith('#'):
774
+ continue
775
+ parts = [p.strip() for p in line.split(',')]
776
+ if len(parts) < 4 or parts[1] != 'bootloader':
777
+ continue
778
+ try:
779
+ offset = int(parts[3], 0)
780
+ except ValueError:
781
+ continue
782
+ if parts[2] == 'primary' and primary is None:
783
+ primary = offset
784
+ elif parts[2] == 'recovery' and recovery is None:
785
+ recovery = offset
786
+ return primary, recovery
787
+
788
+ def command_print_bundle(
789
+ bundle_file_path: str,
790
+ partition_table_offset: int,
791
+ primary_bootloader_offset: Optional[int],
792
+ recovery_bootloader_offset: Optional[int],
793
+ ):
794
+ bundle_size = os.path.getsize(bundle_file_path)
795
+ if bundle_size == 0:
796
+ raise RuntimeError(f"Bundle '{bundle_file_path}' is empty")
797
+ try:
798
+ bundle_zip = ZipFile(bundle_file_path, 'r')
799
+ except BadZipFile as e:
800
+ raise RuntimeError(f"Bundle '{bundle_file_path}' is not a valid ZIP archive") from e
801
+ with bundle_zip as zf:
802
+ names = zf.namelist()
803
+ if 'partition_table.csv' not in names:
804
+ raise RuntimeError(f"Bundle {bundle_file_path} does not contain a partition_table.csv")
805
+ csv_text = zf.read('partition_table.csv').decode('utf-8')
806
+ if not csv_text.strip():
807
+ raise RuntimeError(f"Bundle '{bundle_file_path}' contains an empty partition_table.csv")
808
+
809
+ # idftool-generated bundles encode bootloader offsets in the CSV; PartitionTable.from_csv
810
+ # ignores the literal for bootloader rows and requires an explicit offset, so fall back to
811
+ # what the CSV says when the user didn't pass one.
812
+ if primary_bootloader_offset is None or recovery_bootloader_offset is None:
813
+ csv_primary, csv_recovery = _extract_csv_bootloader_offsets(csv_text)
814
+ if primary_bootloader_offset is None:
815
+ primary_bootloader_offset = csv_primary
816
+ if recovery_bootloader_offset is None:
817
+ recovery_bootloader_offset = csv_recovery
818
+
819
+ partition_table = PartitionTable.from_csv(
820
+ csv_text,
821
+ partition_table_offset=partition_table_offset,
822
+ primary_bootloader_offset=primary_bootloader_offset,
823
+ recovery_bootloader_offset=recovery_bootloader_offset,
824
+ )
825
+ require_partitions(partition_table, f"bundle '{bundle_file_path}'")
826
+
827
+ partition_data = {
828
+ name[:-4]: zf.read(name)
829
+ for name in names if name.endswith('.bin')
830
+ }
831
+
832
+ def read(offset: int, length: int) -> bytes:
833
+ for part in partition_table:
834
+ if part.offset <= offset < part.offset + part.size:
835
+ local = offset - part.offset
836
+ data = partition_data.get(part.name, b"")
837
+ chunk = data[local:local + length]
838
+ if len(chunk) < length:
839
+ chunk += b"\xff" * (length - len(chunk))
840
+ return chunk
841
+ return b"\xff" * length
842
+
843
+ included = sorted(partition_data.keys())
844
+ print(f"Bundle: {bundle_file_path} ({bundle_size:#x} bytes)")
845
+ print(f"Partitions included: {', '.join(included) if included else '(none)'}")
846
+ print()
847
+ print_partition_table_and_apps(partition_table, read)
848
+
849
+ def command_print_table(
850
+ table_file_path: str,
851
+ partition_table_offset: int,
852
+ primary_bootloader_offset: Optional[int],
853
+ recovery_bootloader_offset: Optional[int],
854
+ ):
855
+ partition_table = load_partition_table_file(
856
+ table_file_path, partition_table_offset, primary_bootloader_offset, recovery_bootloader_offset)
857
+ print(f"Partition table: {table_file_path}")
858
+ print()
859
+ print_partition_table(partition_table)
860
+
861
+ def command_convert_table(
862
+ input_file: str,
863
+ output_file: str,
864
+ output_format: Optional[str],
865
+ partition_table_offset: int,
866
+ primary_bootloader_offset: Optional[int],
867
+ recovery_bootloader_offset: Optional[int],
868
+ ):
869
+ partition_table = load_partition_table_file(
870
+ input_file, partition_table_offset, primary_bootloader_offset, recovery_bootloader_offset)
871
+ output_format = resolve_partition_table_format(output_file, output_format)
872
+ write_partition_table_file(partition_table, output_file, output_format)
873
+
874
+ def command_dump_table(
875
+ esp: ESPLoader,
876
+ partition_table: PartitionTable,
877
+ output_file: Optional[str],
878
+ output_format: Optional[str],
879
+ ):
880
+ if not output_file:
881
+ chip = esp.CHIP_NAME.lower().replace(' ', '-')
882
+ try:
883
+ mac = esp.read_mac("BASE_MAC")
884
+ serial = ''.join(f"{b:02x}" for b in mac)
885
+ except Exception:
886
+ serial = "unknown"
887
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
888
+ extension = 'bin' if output_format == 'bin' else 'csv'
889
+ output_file = f"{chip}-{serial}-{timestamp}-partition-table.{extension}"
890
+ output_format = resolve_partition_table_format(output_file, output_format)
891
+ write_partition_table_file(partition_table, output_file, output_format)
892
+
893
+ def command_write_table(
894
+ esp: ESPLoader,
895
+ table_file_path: str,
896
+ partition_table_offset: int,
897
+ primary_bootloader_offset: Optional[int],
898
+ recovery_bootloader_offset: Optional[int],
899
+ force: bool = False,
900
+ ):
901
+ partition_table = load_partition_table_file(
902
+ table_file_path, partition_table_offset, primary_bootloader_offset, recovery_bootloader_offset)
903
+
904
+ try:
905
+ partition_table.verify(partition_table_offset=partition_table_offset)
906
+ except RuntimeError as e:
907
+ if not force:
908
+ raise RuntimeError(
909
+ f"Partition table failed verification: {e}. Re-run with --force to flash it anyway.") from e
910
+ print(f"Warning: partition table failed verification: {e} (continuing due to --force)")
911
+
912
+ partition_table.verify_size_fits(flash_size_bytes(detect_flash_size(esp)))
913
+
914
+ binary = partition_table.to_binary()
915
+ print_partition_table(partition_table)
916
+ print()
917
+ print(f"Writing partition table ({len(binary):#x} bytes) to offset {partition_table_offset:#x}...")
918
+ print("Note: this replaces only the partition map; existing partition data on flash is not moved, "
919
+ "resized, or erased. A table that no longer matches the flash contents can make the device unbootable.")
920
+ write_flash(esp=esp, addr_data=[(partition_table_offset, binary)], flash_size='detect')
921
+ print("Partition table written")
922
+
622
923
  def command_dump_image(esp: ESPLoader, output_file: Optional[str]):
623
924
  flash_size = flash_size_bytes(detect_flash_size(esp))
624
925
 
@@ -690,30 +991,87 @@ def main(args):
690
991
  command_create_nvs(args.csv_file, args.size, args.output)
691
992
  return
692
993
 
994
+ # print-image inspects a local image file and needs neither device nor external partition table
995
+ if args.command == 'print-image':
996
+ command_print_image(
997
+ image_file_path=args.image_file,
998
+ partition_table_offset=args.partition_table_offset,
999
+ partition_table_size=args.partition_table_size,
1000
+ )
1001
+ return
1002
+
1003
+ # print-bundle inspects a local bundle ZIP and needs neither device nor external partition table
1004
+ if args.command == 'print-bundle':
1005
+ command_print_bundle(
1006
+ bundle_file_path=args.bundle_file,
1007
+ partition_table_offset=args.partition_table_offset,
1008
+ primary_bootloader_offset=args.primary_bootloader_offset,
1009
+ recovery_bootloader_offset=args.recovery_bootloader_offset,
1010
+ )
1011
+ return
1012
+
1013
+ # print-table and convert-table operate purely on a local partition table file. print-table
1014
+ # with no file falls through to the shared loader, printing the device's table (or the one
1015
+ # given via --partition-table-file).
1016
+ if args.command == 'print-table' and args.table_file:
1017
+ command_print_table(
1018
+ table_file_path=args.table_file,
1019
+ partition_table_offset=args.partition_table_offset,
1020
+ primary_bootloader_offset=args.primary_bootloader_offset,
1021
+ recovery_bootloader_offset=args.recovery_bootloader_offset,
1022
+ )
1023
+ return
1024
+
1025
+ if args.command == 'convert-table':
1026
+ command_convert_table(
1027
+ input_file=args.input_file,
1028
+ output_file=args.output_file,
1029
+ output_format=args.format,
1030
+ partition_table_offset=args.partition_table_offset,
1031
+ primary_bootloader_offset=args.primary_bootloader_offset,
1032
+ recovery_bootloader_offset=args.recovery_bootloader_offset,
1033
+ )
1034
+ return
1035
+
693
1036
  # Connect to ESP device if required
694
- requires_esp = args.command not in ['list', 'create-image', 'create-bundle', 'create-nvs'] or not args.partition_table_file
1037
+ requires_esp = args.command not in ['create-image', 'create-bundle', 'create-nvs', 'print-table'] or not args.partition_table_file
695
1038
  esp = get_esp(port=args.port, baud=args.baud) if requires_esp else None
696
- if not args.primary_bootloader_offset and esp:
1039
+ if args.primary_bootloader_offset is None and esp:
697
1040
  args.primary_bootloader_offset = esp.BOOTLOADER_FLASH_OFFSET
698
1041
 
1042
+ # write-table flashes a partition table from its own positional file, bypassing the shared
1043
+ # loader so it never reads the device's current table and writes it straight back to itself.
1044
+ if args.command == 'write-table':
1045
+ command_write_table(
1046
+ esp=esp,
1047
+ table_file_path=args.table_file,
1048
+ partition_table_offset=args.partition_table_offset,
1049
+ primary_bootloader_offset=args.primary_bootloader_offset,
1050
+ recovery_bootloader_offset=args.recovery_bootloader_offset,
1051
+ force=args.force,
1052
+ )
1053
+ if esp and not args.no_reset:
1054
+ esp.hard_reset()
1055
+ return
1056
+
699
1057
  if args.command == 'dump-image':
700
1058
  command_dump_image(esp=esp, output_file=args.output_file)
701
1059
  return
702
1060
 
703
1061
  # Load partition table
704
1062
  if args.command == 'write-image':
1063
+ check_image_file(args.image_file, args.partition_table_offset)
705
1064
  with open(args.image_file, 'rb') as f:
706
1065
  f.seek(args.partition_table_offset)
707
1066
  partition_table_binary = f.read(args.partition_table_size)
708
1067
  partition_table = PartitionTable.from_binary(partition_table_binary)
709
1068
  elif args.partition_table_file:
710
- with open(args.partition_table_file, 'rb') as f:
711
- partition_table = PartitionTable.from_file(
712
- f,
713
- partition_table_offset=args.partition_table_offset,
714
- primary_bootloader_offset=args.primary_bootloader_offset,
715
- recovery_bootloader_offset=args.recovery_bootloader_offset
716
- )[0]
1069
+ partition_table = load_partition_table_file(
1070
+ args.partition_table_file,
1071
+ partition_table_offset=args.partition_table_offset,
1072
+ primary_bootloader_offset=args.primary_bootloader_offset,
1073
+ recovery_bootloader_offset=args.recovery_bootloader_offset,
1074
+ )
717
1075
  elif args.command == 'write-bundle' and check_write_bundle_has_partition_table(args.input_file):
718
1076
  with ZipFile(args.input_file, 'r') as tar:
719
1077
  partition_table_file = tar.read('partition_table.csv')
@@ -732,6 +1090,8 @@ def main(args):
732
1090
  except RuntimeError as e:
733
1091
  raise RuntimeError("Partition table could not be loaded") from e
734
1092
 
1093
+ require_partitions(partition_table, "the loaded source")
1094
+
735
1095
  # Read otadata so the printed table can mark the active app partition
736
1096
  otadata_params: OtaDataParameters | None = None
737
1097
  if esp:
@@ -741,7 +1101,7 @@ def main(args):
741
1101
  pass
742
1102
 
743
1103
  # Print partition table
744
- print_partition_table(partition_table, esp, otadata=otadata_params)
1104
+ print_partition_table(partition_table, esp.read_flash if esp else None, otadata=otadata_params)
745
1105
 
746
1106
  if esp:
747
1107
  flash_size_str = detect_flash_size(esp)
@@ -766,7 +1126,8 @@ def main(args):
766
1126
  ) if args.primary_bootloader_offset is not None else None
767
1127
  )
768
1128
 
769
- if args.command == 'list':
1129
+ if args.command == 'print-table':
1130
+ # The shared path above already loaded and printed the table.
770
1131
  pass
771
1132
  elif args.command == 'read':
772
1133
  command_read(
@@ -824,6 +1185,13 @@ def main(args):
824
1185
  )
825
1186
  elif args.command == 'dump-bundle':
826
1187
  command_dump_bundle(esp=esp, partition_table=partition_table, output_file=args.output_file)
1188
+ elif args.command == 'dump-table':
1189
+ command_dump_table(
1190
+ esp=esp,
1191
+ partition_table=partition_table,
1192
+ output_file=args.output_file,
1193
+ output_format=args.format,
1194
+ )
827
1195
  elif args.command == 'write-bundle':
828
1196
  command_write_bundle(
829
1197
  esp=esp,
@@ -881,12 +1249,6 @@ def _main():
881
1249
  # Devices subcommand
882
1250
  devices_parser = subparsers.add_parser('devices', help='Device list')
883
1251
 
884
- # List subcommand
885
- list_parser = subparsers.add_parser('list', help='List partitions')
886
- list_parser.add_argument('--partition-type', help='Filter by partition type')
887
- list_parser.add_argument('--partition-subtype', help='Filter by partition subtype')
888
- list_parser.add_argument('--partition-name', help='Filter by partition name')
889
-
890
1252
  # Read subcommand
891
1253
  read_parser = subparsers.add_parser('read', help='Read partition')
892
1254
  read_parser.add_argument('partition', help='Name of the partition to read')
@@ -932,6 +1294,10 @@ def _main():
932
1294
  write_image_parser = subparsers.add_parser('write-image', aliases=['reflash'], help='Write a full flash image to the device')
933
1295
  write_image_parser.add_argument('image_file', help='Input file containing flash image')
934
1296
 
1297
+ # Print Image subcommand
1298
+ print_image_parser = subparsers.add_parser('print-image', help='Print partition table and app info from a flash image file')
1299
+ print_image_parser.add_argument('image_file', help='Input file containing flash image')
1300
+
935
1301
  # Create bundle subcommand
936
1302
  create_bundle_parser = subparsers.add_parser('create-bundle', help='Create a ZIP bundle of partition binaries')
937
1303
  create_bundle_parser.add_argument('-o', '--output', help='Output ZIP filename', dest='output_file', required=True)
@@ -951,6 +1317,30 @@ def _main():
951
1317
  write_bundle_parser = subparsers.add_parser('write-bundle', help='Write a ZIP bundle of partition binaries')
952
1318
  write_bundle_parser.add_argument('input_file', help='Input ZIP filename')
953
1319
 
1320
+ # Print Bundle subcommand
1321
+ print_bundle_parser = subparsers.add_parser('print-bundle', help='Print partition table and app info from a partition bundle ZIP')
1322
+ print_bundle_parser.add_argument('bundle_file', help='Input ZIP bundle file')
1323
+
1324
+ # Print Table subcommand
1325
+ print_table_parser = subparsers.add_parser('print-table', aliases=['list'], help='Print a partition table from a CSV or binary file, or from the device')
1326
+ print_table_parser.add_argument('table_file', nargs='?', help='Partition table CSV or binary file (default: read from the device, or --partition-table-file)')
1327
+
1328
+ # Convert Table subcommand
1329
+ convert_table_parser = subparsers.add_parser('convert-table', help='Convert a partition table file between CSV and binary')
1330
+ convert_table_parser.add_argument('input_file', help='Input partition table CSV or binary file')
1331
+ convert_table_parser.add_argument('output_file', help='Output partition table file (.csv or .bin)')
1332
+ convert_table_parser.add_argument('-f', '--format', choices=['csv', 'bin'], help='Output format (default: inferred from output extension)')
1333
+
1334
+ # Dump Table subcommand
1335
+ dump_table_parser = subparsers.add_parser('dump-table', help='Dump the partition table from the device to a file')
1336
+ dump_table_parser.add_argument('output_file', nargs='?', help='Output file (default: {chip}-{mac}-{timestamp}-partition-table.csv)')
1337
+ dump_table_parser.add_argument('-f', '--format', choices=['csv', 'bin'], help='Output format (default: inferred from output extension, else csv)')
1338
+
1339
+ # Write Table subcommand
1340
+ write_table_parser = subparsers.add_parser('write-table', help='Write a partition table from a CSV or binary file to the device')
1341
+ write_table_parser.add_argument('table_file', help='Partition table CSV or binary file to flash')
1342
+ write_table_parser.add_argument('--force', action='store_true', help='Flash even if the partition table fails verification')
1343
+
954
1344
  # Create NVS subcommand
955
1345
  create_nvs_parser = subparsers.add_parser('create-nvs', help='Generate NVS partition image from CSV')
956
1346
  create_nvs_parser.add_argument('csv_file', help='Input NVS CSV file')
@@ -988,6 +1378,8 @@ def _main():
988
1378
  args = parser.parse_args()
989
1379
  if args.command == 'reflash':
990
1380
  args.command = 'write-image'
1381
+ if args.command == 'list':
1382
+ args.command = 'print-table'
991
1383
  if args.command == 'enter-bootloader' and not args.port:
992
1384
  parser.error("enter-bootloader requires -p/--port")
993
1385
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idftool
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Utility for flashing, provisioning, and interacting with Espressif SOCs running ESP-IDF
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Source, https://github.com/nebkat/idftool
@@ -8,7 +8,7 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE.md
10
10
  Requires-Dist: esptool~=5.2.0
11
- Requires-Dist: esp-idf-defs~=0.1.0
11
+ Requires-Dist: esp-idf-defs~=0.1.3
12
12
  Requires-Dist: esp-idf-nvs-partition-gen~=0.2.0
13
13
  Dynamic: license-file
14
14
 
@@ -103,7 +103,7 @@ Setting boot partition to 'ota_1'...
103
103
  |---------|-------------|
104
104
  | **Discovery** | |
105
105
  | [`devices`](#devices) | List serial ports with hardware IDs |
106
- | [`list`](#list) | Print the partition table |
106
+ | [`list`](#print-table) | Print the partition table (alias of `print-table`) |
107
107
  | **Partition I/O** | |
108
108
  | [`read`](#read) | Read a partition (or slice) into a file |
109
109
  | [`write`](#write) | Write one or more files to named partitions |
@@ -120,10 +120,17 @@ Setting boot partition to 'ota_1'...
120
120
  | [`create-image`](#create-image) | Merge partition binaries into a single flash image |
121
121
  | [`dump-image`](#dump-image) | Dump the entire flash to an image file |
122
122
  | [`write-image`](#write-image-alias-reflash) | Write a full flash image to the device |
123
+ | [`print-image`](#print-image) | Print partition table and app info from a flash image file |
123
124
  | **Bundles** | |
124
125
  | [`create-bundle`](#create-bundle) | Pack partition images into a ZIP bundle |
125
126
  | [`dump-bundle`](#dump-bundle) | Pack every partition from the device into a ZIP |
126
127
  | [`write-bundle`](#write-bundle) | Flash every binary in a bundle ZIP |
128
+ | [`print-bundle`](#print-bundle) | Print partition table and app info from a bundle ZIP |
129
+ | **Partition table** | |
130
+ | [`convert-table`](#convert-table) | Convert a partition table file between CSV and binary |
131
+ | [`print-table`](#print-table) | Print a partition table from a CSV or binary file, or the device |
132
+ | [`dump-table`](#dump-table) | Read the partition table from the device into a file |
133
+ | [`write-table`](#write-table) | Flash a partition table from a CSV or binary file |
127
134
  | **NVS** | |
128
135
  | [`create-nvs`](#create-nvs) | Generate an NVS partition image from a CSV file |
129
136
  | [`write-nvs`](#write-nvs) | Generate an NVS image from CSV and flash it |
@@ -139,17 +146,6 @@ hardware IDs.
139
146
  idftool devices
140
147
  ```
141
148
 
142
- #### `list`
143
- Print the device's partition table. With a connected ESP, idftool also
144
- reads the application descriptor of every app partition (project name,
145
- version) and marks the currently-active OTA slot with a trailing `*`. The
146
- selected otadata copy is shown next to the otadata partition's subtype
147
- (`ota (A)`, `ota (B)`, or `ota (invalid)` when otadata is erased).
148
- ```text
149
- idftool list
150
- idftool --partition-table-file partitions.csv list # offline
151
- ```
152
-
153
149
  ### Partition I/O
154
150
 
155
151
  #### `read`
@@ -259,6 +255,15 @@ counterpart of `dump-image`.
259
255
  idftool write-image build/full-flash.img
260
256
  ```
261
257
 
258
+ #### `print-image`
259
+ Inspect a flash image file without touching a device: prints the
260
+ partition table embedded in the image and, for each app partition that
261
+ contains a valid app, the project name, version, IDF version, compile
262
+ time, ELF SHA256, and target chip.
263
+ ```text
264
+ idftool print-image build/full-flash.img
265
+ ```
266
+
262
267
  ### Bundles
263
268
 
264
269
  A bundle is a plain ZIP file containing one `*.bin` per partition (named
@@ -292,6 +297,79 @@ to match) instead of reading the table from flash.
292
297
  idftool write-bundle release.zip
293
298
  ```
294
299
 
300
+ #### `print-bundle`
301
+ Inspect a bundle ZIP without touching a device: prints the partition
302
+ table from the embedded `partition_table.csv` and, for each app
303
+ partition whose `.bin` is present in the bundle, the project name,
304
+ version, IDF version, compile time, ELF SHA256, and target chip.
305
+ ```text
306
+ idftool print-bundle release.zip
307
+ ```
308
+
309
+ ### Partition table
310
+
311
+ These commands work directly on a partition table as a file — the table
312
+ is the subject, passed as a positional argument. This is distinct from the
313
+ global `--partition-table-file` option, which overrides the layout *other*
314
+ commands use to address partitions by name. Input format (CSV or binary)
315
+ is auto-detected; output format is inferred from the file extension
316
+ (`.csv`/`.bin`) or set with `--format`.
317
+
318
+ When the input is a CSV that includes a bootloader row, pass
319
+ `--primary-bootloader-offset` (an offset or a chip name, e.g. `esp32s3`)
320
+ so the offset can be resolved.
321
+
322
+ #### `convert-table`
323
+ Convert a partition table between CSV and binary, offline. Handles both
324
+ directions; the binary output includes the MD5 checksum and padding, so it
325
+ is ready to flash or embed in an image.
326
+ ```text
327
+ idftool convert-table partitions.csv partitions.bin
328
+ idftool convert-table partitions.bin partitions.csv
329
+ idftool --primary-bootloader-offset esp32s3 convert-table partitions.csv partitions.bin
330
+ ```
331
+
332
+ #### `print-table`
333
+ Pretty-print a partition table. Given a CSV or binary file it runs offline;
334
+ with no file it reads the table from the device (or from
335
+ `--partition-table-file`). With a connected ESP, idftool also reads the
336
+ application descriptor of every app partition (project name, version) and
337
+ marks the currently-active OTA slot with a trailing `*`; the selected otadata
338
+ copy is shown next to the otadata partition's subtype (`ota (A)`, `ota (B)`,
339
+ or `ota (invalid)` when otadata is erased).
340
+
341
+ Aliased as `list`.
342
+ ```text
343
+ idftool print-table partitions.bin # from a file, offline
344
+ idftool print-table # from the device
345
+ idftool list # same thing
346
+ idftool --partition-table-file partitions.csv print-table
347
+ ```
348
+
349
+ #### `dump-table`
350
+ Read the partition table from the connected device and save it. Defaults to
351
+ CSV with an auto-generated filename; pass an output file or `--format` to
352
+ choose otherwise.
353
+ ```text
354
+ idftool dump-table # auto-named .csv
355
+ idftool dump-table backup.bin
356
+ idftool dump-table backup.csv --format csv
357
+ ```
358
+
359
+ #### `write-table`
360
+ Flash a partition table from a CSV or binary file to the device. The file is
361
+ required, so this never reads the device's current table and writes it back
362
+ to itself — use `dump-table` to pull the current table. The table is verified
363
+ before flashing (override with `--force`).
364
+
365
+ Only the partition map (at the partition table offset) is replaced; existing
366
+ partition **data** on flash is not moved, resized, or erased, so a table that
367
+ no longer matches the flash contents can make the device unbootable.
368
+ ```text
369
+ idftool write-table partitions.csv
370
+ idftool write-table partitions.bin --force
371
+ ```
372
+
295
373
  ### NVS
296
374
 
297
375
  idftool can generate a binary NVS (Non-Volatile Storage) partition image
@@ -1,4 +1,5 @@
1
1
  .gitignore
2
+ CHANGELOG.md
2
3
  CONTRIBUTING.md
3
4
  LICENSE.md
4
5
  Makefile
@@ -17,4 +18,6 @@ idftool.egg-info/SOURCES.txt
17
18
  idftool.egg-info/dependency_links.txt
18
19
  idftool.egg-info/entry_points.txt
19
20
  idftool.egg-info/requires.txt
21
+ idftool.egg-info/scm_file_list.json
22
+ idftool.egg-info/scm_version.json
20
23
  idftool.egg-info/top_level.txt
@@ -1,3 +1,3 @@
1
1
  esptool~=5.2.0
2
- esp-idf-defs~=0.1.0
2
+ esp-idf-defs~=0.1.3
3
3
  esp-idf-nvs-partition-gen~=0.2.0
@@ -0,0 +1,19 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "Makefile",
5
+ "idftool.spec",
6
+ "pyproject.toml",
7
+ "CHANGELOG.md",
8
+ "CONTRIBUTING.md",
9
+ "LICENSE.md",
10
+ "idftool-onedir.spec",
11
+ ".gitignore",
12
+ "idftool.py",
13
+ "idftool/__init__.py",
14
+ "idftool/traceback.py",
15
+ "idftool/__main__.py",
16
+ ".github/workflows/build.yml",
17
+ ".github/workflows/publish.yml"
18
+ ]
19
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.0",
3
+ "distance": 0,
4
+ "node": "gbf3a2f9bff2743367428ab9e8273189243b01ba5",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-15"
8
+ }
@@ -11,7 +11,7 @@
11
11
  dynamic = ["version"]
12
12
  dependencies = [
13
13
  "esptool~=5.2.0",
14
- "esp-idf-defs~=0.1.0",
14
+ "esp-idf-defs~=0.1.3",
15
15
  "esp-idf-nvs-partition-gen~=0.2.0",
16
16
  ]
17
17
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes