mpytool 2.4.0__tar.gz → 2.6.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 (51) hide show
  1. {mpytool-2.4.0 → mpytool-2.6.0}/PKG-INFO +133 -14
  2. {mpytool-2.4.0 → mpytool-2.6.0}/README.md +132 -13
  3. {mpytool-2.4.0 → mpytool-2.6.0}/README_API.md +164 -6
  4. {mpytool-2.4.0 → mpytool-2.6.0}/completions/_mpytool +27 -6
  5. {mpytool-2.4.0 → mpytool-2.6.0}/completions/mpytool.bash +23 -7
  6. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/__init__.py +3 -1
  7. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/_version.py +3 -3
  8. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/conn.py +34 -5
  9. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/conn_serial.py +16 -3
  10. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/conn_socket.py +1 -0
  11. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/mpy.py +418 -5
  12. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/mpy_comm.py +27 -0
  13. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/mpytool.py +313 -22
  14. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/terminal_unix.py +24 -14
  15. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/utils.py +4 -1
  16. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/PKG-INFO +133 -14
  17. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/SOURCES.txt +3 -0
  18. mpytool-2.6.0/mpytool.egg-info/scm_file_list.json +44 -0
  19. mpytool-2.6.0/mpytool.egg-info/scm_version.json +8 -0
  20. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_cli.py +25 -0
  21. mpytool-2.6.0/tests/test_conn_serial.py +38 -0
  22. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_mpy.py +395 -1
  23. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_mpytool.py +236 -5
  24. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_utils.py +1 -1
  25. {mpytool-2.4.0 → mpytool-2.6.0}/.github/workflows/integration.yml +0 -0
  26. {mpytool-2.4.0 → mpytool-2.6.0}/.github/workflows/publish.yml +0 -0
  27. {mpytool-2.4.0 → mpytool-2.6.0}/.github/workflows/tests.yml +0 -0
  28. {mpytool-2.4.0 → mpytool-2.6.0}/.gitignore +0 -0
  29. {mpytool-2.4.0 → mpytool-2.6.0}/LICENSE +0 -0
  30. {mpytool-2.4.0 → mpytool-2.6.0}/README_bench.md +0 -0
  31. {mpytool-2.4.0 → mpytool-2.6.0}/README_mpremote.md +0 -0
  32. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/cmd_cp.py +0 -0
  33. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/logger.py +0 -0
  34. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/mount.py +0 -0
  35. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/mpy_cross.py +0 -0
  36. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/speedtest.py +0 -0
  37. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/terminal.py +0 -0
  38. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool/terminal_win.py +0 -0
  39. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/dependency_links.txt +0 -0
  40. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/entry_points.txt +0 -0
  41. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/requires.txt +0 -0
  42. {mpytool-2.4.0 → mpytool-2.6.0}/mpytool.egg-info/top_level.txt +0 -0
  43. {mpytool-2.4.0 → mpytool-2.6.0}/pyproject.toml +0 -0
  44. {mpytool-2.4.0 → mpytool-2.6.0}/setup.cfg +0 -0
  45. {mpytool-2.4.0 → mpytool-2.6.0}/tests/__init__.py +0 -0
  46. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_cmd_cp.py +0 -0
  47. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_conn_socket.py +0 -0
  48. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_errors.py +0 -0
  49. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_helpers.py +0 -0
  50. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_integration.py +0 -0
  51. {mpytool-2.4.0 → mpytool-2.6.0}/tests/test_mount.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.4.0
3
+ Version: 2.6.0
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ It is an alternative to the official [mpremote](https://docs.micropython.org/en/
33
33
  bootloader entry
34
34
  - **Wipe device** - erase the whole filesystem and machine-reset
35
35
  for a clean slate (handy at the start of automated tests)
36
+ - **Deploy projects** - `sync` uploads a whole project to the device
37
+ using a `.mpyproject` config file (deploy map, excludes, `.mpy`
38
+ compilation)
36
39
  - **General-purpose serial terminal** - `repl` and `monitor` work
37
40
  with any serial device
38
41
  - **Mount local directory** - VFS mount (read-only or read-write)
@@ -168,6 +171,47 @@ Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version dif
168
171
  from device, automatically uses `-b` flag to target the correct bytecode version.
169
172
  If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
170
173
 
174
+ ### Deploy project (sync)
175
+
176
+ `sync` uploads a whole project to the device based on a `.mpyproject`
177
+ configuration file. It is the command-line counterpart of the deploy feature in
178
+ the [Sublime Text plugin](https://github.com/cortexm/mpytool-sublime) and shares
179
+ the same `.mpyproject` format. Unlike the plugin it is non-interactive: when more
180
+ than one device is connected, pass `-p` to pick the port.
181
+
182
+ ```
183
+ $ mpytool sync # deploy project in CWD (.mpyproject searched upward)
184
+ $ mpytool sync ~/projects/blink # deploy a project in another directory
185
+ $ mpytool -p /dev/ttyACM0 sync # pick port explicitly (multiple devices)
186
+ $ mpytool sync -- reset -- monitor # deploy, then reset and watch output
187
+ ```
188
+
189
+ The `.mpyproject` file (JSON, trailing commas tolerated) describes what to upload:
190
+
191
+ ```json
192
+ {
193
+ "name": "blink",
194
+ "compile": true,
195
+ "deploy": {
196
+ "": ["./"],
197
+ "/lib/": ["../shared/utils.py"],
198
+ "/main.py": "src/main_dev.py"
199
+ },
200
+ "exclude": ["*.bak", "tests"]
201
+ }
202
+ ```
203
+
204
+ - `deploy` maps a device destination to its source(s), relative to the project
205
+ root. A key of `""` or one ending in `/` is a **directory** (value is a list of
206
+ sources); any other key is a **rename** (value is a single source file).
207
+ Defaults to `{"": ["./"]}` (whole project to root) when omitted.
208
+ - `compile: true` compiles `.py` to `.mpy` (same as `cp -m`).
209
+ - `exclude` adds ignore patterns on top of the defaults (`__pycache__`, `*.pyc`,
210
+ dotfiles). Combined with global `-e` patterns.
211
+
212
+ The connection fields (`port`, `address`) used by the Sublime plugin are ignored
213
+ by the CLI — use the global `-p` / `-a` options instead.
214
+
171
215
  ### Move/rename on device
172
216
  ```
173
217
  $ mpytool mv :/old.py :/new.py # rename file
@@ -342,6 +386,35 @@ RUN: script.py (128 bytes)
342
386
  Done!
343
387
  ```
344
388
 
389
+ The script can also come from **STDIN** using the `-` convention (or simply by
390
+ piping when no file is given), which is handy for generated code or one-off
391
+ snippets:
392
+ ```
393
+ $ cat script.py | mpytool run - # explicit '-' = read STDIN
394
+ $ mpytool run - <<< 'print("hi")' # here-string (single line)
395
+ $ my_codegen | mpytool run # piped, no file argument
396
+ $ cat boot.py | mpytool run - -- reset -- monitor # run from STDIN, then chain
397
+ ```
398
+
399
+ For **multi-line** code, feed a here-document straight into `run -` (no `cat`,
400
+ no pipe needed - the here-doc *is* the standard input):
401
+ ```
402
+ $ mpytool run - << 'EOF'
403
+ import machine, time
404
+ led = machine.Pin('LED', machine.Pin.OUT)
405
+ for _ in range(3):
406
+ led.toggle()
407
+ time.sleep(0.2)
408
+ print('done')
409
+ EOF
410
+ ```
411
+ Quote the delimiter (`'EOF'`) so the shell does not expand `$`, backticks or
412
+ `{}` inside the Python code. The closing `EOF` must be on a line of its own.
413
+
414
+ Because STDIN can be read only once, at most one command per invocation may
415
+ consume it: combining a STDIN `run` with another STDIN `run` or with `repl`
416
+ (which needs the terminal) is rejected up front with a clear error.
417
+
345
418
  ### Edit file on device
346
419
  ```
347
420
  $ mpytool edit :boot.py # edit file (uses $VISUAL or $EDITOR)
@@ -484,10 +557,25 @@ Version: 3.4.0; MicroPython v1.27.0 on 2025-12-09
484
557
  Impl: micropython
485
558
  Machine: Raspberry Pi Pico with RP2040
486
559
  Serial: e660123456789abc
487
- Memory: 36.4 KB / 240 KB (15.15%)
560
+ GC heap: 36.4 KB / 240 KB (15.15%)
488
561
  Flash: 120 KB / 1.38 MB (8.52%)
489
562
  ```
490
563
 
564
+ On ESP32 the physical memory regions are listed too (from the ESP-IDF
565
+ allocator), which stays stable regardless of what the GC heap is doing:
566
+
567
+ ```
568
+ SRAM: 412 KB / 640 KB (64.38%)
569
+ PSRAM: 12.0 MB / 32.0 MB (37.50%)
570
+ GC heap: 209 KB / 20.0 MB (1.02%)
571
+ ```
572
+
573
+ `GC heap` is the heap MicroPython uses for Python objects - **not** the amount
574
+ of RAM in the chip. On builds with PSRAM it grows and shrinks on demand, so its
575
+ total changes over time: right after a reset it can claim nearly all free PSRAM,
576
+ and it shrinks once drivers allocate PSRAM outside the GC heap (e.g. camera
577
+ framebuffers). Use the `SRAM` / `PSRAM` lines to judge actual memory pressure.
578
+
491
579
  On devices with WiFi or Ethernet, MAC addresses are also shown:
492
580
  ```
493
581
  MAC WiFi: aa:bb:cc:dd:ee:01
@@ -504,15 +592,18 @@ $ mpytool flash erase # quick erase (reset filesystem)
504
592
  $ mpytool flash erase --full # full erase
505
593
 
506
594
  # ESP32 - partitions (by label)
507
- $ mpytool flash # list all partitions with filesystem info
508
- Label Type Subtype Address Size Block Actual FS Flags
509
- ------------------------------------------------------------------------------------------
510
- factory app factory 0x10000 1.94M running
511
- nvs data nvs 0x9000 24.0K
512
- vfs data littlefs 0x200000 2.00M 4 KB littlefs2
513
-
514
- Boot partition: factory
515
- Next OTA: (none)
595
+ $ mpytool flash # list all partitions, sorted by address
596
+ Label Type Subtype Address Size Block Content Flags
597
+ --------------------------------------------------------------------------------------------
598
+ nvs data nvs 0x9000 16.0K
599
+ otadata data ota 0xd000 8.00K
600
+ phy_init data phy 0xf000 4.00K
601
+ ota_0 app ota_0 0x10000 2.50M app ESP32-P4 running, ota:valid
602
+ ota_1 app ota_1 0x290000 2.50M app ESP32-P4 ota:valid
603
+ vfs data fat 0x510000 10.9M 4.00K littlefs2
604
+
605
+ Boot partition: ota_0
606
+ Next OTA: ota_1
516
607
 
517
608
  $ mpytool flash read vfs backup.bin # backup partition to file
518
609
  $ mpytool flash write nvs nvs_backup.bin # restore partition from file
@@ -520,11 +611,39 @@ $ mpytool flash erase vfs # quick erase partition
520
611
  $ mpytool flash erase vfs --full # full erase partition
521
612
  ```
522
613
 
614
+ `Content` shows what a partition actually holds - the detected filesystem for
615
+ data partitions, the image type for app partitions (`app <CHIP>`, or
616
+ `BOOTLOADER!` / `INVALID!` / `empty`). The `ota:` flags come from the `otadata`
617
+ partition and describe the **rollback bookkeeping** of a slot, not the image
618
+ stored in it: a slot can read `ota:valid` while holding an unbootable image.
619
+
523
620
  ### OTA firmware update (ESP32)
621
+
622
+ OTA needs an **app-only** image (`micropython.bin`), not the combined
623
+ `firmware.bin` (bootloader + partition table + app) that `esptool` writes.
624
+ mpytool checks the image header before uploading, so a wrong file is rejected
625
+ immediately instead of failing with `ESP_ERR_OTA_VALIDATE_FAILED` after the
626
+ whole transfer. It also refuses an image built for a different chip.
627
+
628
+ ```
629
+ $ mpytool flash ota micropython.bin # write to next OTA partition
630
+ $ mpytool flash ota micropython.bin -- reset --machine # write and reboot
631
+ $ mpytool flash ota micropython.bin -- reset --machine -t 30 # ... with 30s timeout
632
+ $ mpytool flash ota micropython.bin --force # write despite failed validation
633
+
634
+ $ mpytool flash ota confirm # mark running app valid (cancel rollback)
635
+ $ mpytool flash ota boot # boot the other slot (manual rollback)
636
+ $ mpytool flash ota boot ota_1 # boot a specific app partition
637
+ ```
638
+
639
+ If the firmware enables rollback, a freshly booted OTA image stays in
640
+ `pending-verify` and reverts on the next reset until it is confirmed - either by
641
+ the app itself (`esp32.Partition(esp32.Partition.RUNNING).mark_app_valid_cancel_rollback()`)
642
+ or from the host:
643
+
524
644
  ```
525
- $ mpytool flash ota firmware.app-bin # flash to next OTA partition
526
- $ mpytool flash ota firmware.app-bin -- reset --machine # flash and reboot
527
- $ mpytool flash ota firmware.app-bin -- reset --machine -t 30 # flash and reboot with 30s timeout
645
+ $ mpytool flash ota micropython.bin -- reset --machine
646
+ $ mpytool flash ota confirm
528
647
  ```
529
648
 
530
649
  ### Multiple commands separated by `--`
@@ -18,6 +18,9 @@ It is an alternative to the official [mpremote](https://docs.micropython.org/en/
18
18
  bootloader entry
19
19
  - **Wipe device** - erase the whole filesystem and machine-reset
20
20
  for a clean slate (handy at the start of automated tests)
21
+ - **Deploy projects** - `sync` uploads a whole project to the device
22
+ using a `.mpyproject` config file (deploy map, excludes, `.mpy`
23
+ compilation)
21
24
  - **General-purpose serial terminal** - `repl` and `monitor` work
22
25
  with any serial device
23
26
  - **Mount local directory** - VFS mount (read-only or read-write)
@@ -153,6 +156,47 @@ Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version dif
153
156
  from device, automatically uses `-b` flag to target the correct bytecode version.
154
157
  If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
155
158
 
159
+ ### Deploy project (sync)
160
+
161
+ `sync` uploads a whole project to the device based on a `.mpyproject`
162
+ configuration file. It is the command-line counterpart of the deploy feature in
163
+ the [Sublime Text plugin](https://github.com/cortexm/mpytool-sublime) and shares
164
+ the same `.mpyproject` format. Unlike the plugin it is non-interactive: when more
165
+ than one device is connected, pass `-p` to pick the port.
166
+
167
+ ```
168
+ $ mpytool sync # deploy project in CWD (.mpyproject searched upward)
169
+ $ mpytool sync ~/projects/blink # deploy a project in another directory
170
+ $ mpytool -p /dev/ttyACM0 sync # pick port explicitly (multiple devices)
171
+ $ mpytool sync -- reset -- monitor # deploy, then reset and watch output
172
+ ```
173
+
174
+ The `.mpyproject` file (JSON, trailing commas tolerated) describes what to upload:
175
+
176
+ ```json
177
+ {
178
+ "name": "blink",
179
+ "compile": true,
180
+ "deploy": {
181
+ "": ["./"],
182
+ "/lib/": ["../shared/utils.py"],
183
+ "/main.py": "src/main_dev.py"
184
+ },
185
+ "exclude": ["*.bak", "tests"]
186
+ }
187
+ ```
188
+
189
+ - `deploy` maps a device destination to its source(s), relative to the project
190
+ root. A key of `""` or one ending in `/` is a **directory** (value is a list of
191
+ sources); any other key is a **rename** (value is a single source file).
192
+ Defaults to `{"": ["./"]}` (whole project to root) when omitted.
193
+ - `compile: true` compiles `.py` to `.mpy` (same as `cp -m`).
194
+ - `exclude` adds ignore patterns on top of the defaults (`__pycache__`, `*.pyc`,
195
+ dotfiles). Combined with global `-e` patterns.
196
+
197
+ The connection fields (`port`, `address`) used by the Sublime plugin are ignored
198
+ by the CLI — use the global `-p` / `-a` options instead.
199
+
156
200
  ### Move/rename on device
157
201
  ```
158
202
  $ mpytool mv :/old.py :/new.py # rename file
@@ -327,6 +371,35 @@ RUN: script.py (128 bytes)
327
371
  Done!
328
372
  ```
329
373
 
374
+ The script can also come from **STDIN** using the `-` convention (or simply by
375
+ piping when no file is given), which is handy for generated code or one-off
376
+ snippets:
377
+ ```
378
+ $ cat script.py | mpytool run - # explicit '-' = read STDIN
379
+ $ mpytool run - <<< 'print("hi")' # here-string (single line)
380
+ $ my_codegen | mpytool run # piped, no file argument
381
+ $ cat boot.py | mpytool run - -- reset -- monitor # run from STDIN, then chain
382
+ ```
383
+
384
+ For **multi-line** code, feed a here-document straight into `run -` (no `cat`,
385
+ no pipe needed - the here-doc *is* the standard input):
386
+ ```
387
+ $ mpytool run - << 'EOF'
388
+ import machine, time
389
+ led = machine.Pin('LED', machine.Pin.OUT)
390
+ for _ in range(3):
391
+ led.toggle()
392
+ time.sleep(0.2)
393
+ print('done')
394
+ EOF
395
+ ```
396
+ Quote the delimiter (`'EOF'`) so the shell does not expand `$`, backticks or
397
+ `{}` inside the Python code. The closing `EOF` must be on a line of its own.
398
+
399
+ Because STDIN can be read only once, at most one command per invocation may
400
+ consume it: combining a STDIN `run` with another STDIN `run` or with `repl`
401
+ (which needs the terminal) is rejected up front with a clear error.
402
+
330
403
  ### Edit file on device
331
404
  ```
332
405
  $ mpytool edit :boot.py # edit file (uses $VISUAL or $EDITOR)
@@ -469,10 +542,25 @@ Version: 3.4.0; MicroPython v1.27.0 on 2025-12-09
469
542
  Impl: micropython
470
543
  Machine: Raspberry Pi Pico with RP2040
471
544
  Serial: e660123456789abc
472
- Memory: 36.4 KB / 240 KB (15.15%)
545
+ GC heap: 36.4 KB / 240 KB (15.15%)
473
546
  Flash: 120 KB / 1.38 MB (8.52%)
474
547
  ```
475
548
 
549
+ On ESP32 the physical memory regions are listed too (from the ESP-IDF
550
+ allocator), which stays stable regardless of what the GC heap is doing:
551
+
552
+ ```
553
+ SRAM: 412 KB / 640 KB (64.38%)
554
+ PSRAM: 12.0 MB / 32.0 MB (37.50%)
555
+ GC heap: 209 KB / 20.0 MB (1.02%)
556
+ ```
557
+
558
+ `GC heap` is the heap MicroPython uses for Python objects - **not** the amount
559
+ of RAM in the chip. On builds with PSRAM it grows and shrinks on demand, so its
560
+ total changes over time: right after a reset it can claim nearly all free PSRAM,
561
+ and it shrinks once drivers allocate PSRAM outside the GC heap (e.g. camera
562
+ framebuffers). Use the `SRAM` / `PSRAM` lines to judge actual memory pressure.
563
+
476
564
  On devices with WiFi or Ethernet, MAC addresses are also shown:
477
565
  ```
478
566
  MAC WiFi: aa:bb:cc:dd:ee:01
@@ -489,15 +577,18 @@ $ mpytool flash erase # quick erase (reset filesystem)
489
577
  $ mpytool flash erase --full # full erase
490
578
 
491
579
  # ESP32 - partitions (by label)
492
- $ mpytool flash # list all partitions with filesystem info
493
- Label Type Subtype Address Size Block Actual FS Flags
494
- ------------------------------------------------------------------------------------------
495
- factory app factory 0x10000 1.94M running
496
- nvs data nvs 0x9000 24.0K
497
- vfs data littlefs 0x200000 2.00M 4 KB littlefs2
498
-
499
- Boot partition: factory
500
- Next OTA: (none)
580
+ $ mpytool flash # list all partitions, sorted by address
581
+ Label Type Subtype Address Size Block Content Flags
582
+ --------------------------------------------------------------------------------------------
583
+ nvs data nvs 0x9000 16.0K
584
+ otadata data ota 0xd000 8.00K
585
+ phy_init data phy 0xf000 4.00K
586
+ ota_0 app ota_0 0x10000 2.50M app ESP32-P4 running, ota:valid
587
+ ota_1 app ota_1 0x290000 2.50M app ESP32-P4 ota:valid
588
+ vfs data fat 0x510000 10.9M 4.00K littlefs2
589
+
590
+ Boot partition: ota_0
591
+ Next OTA: ota_1
501
592
 
502
593
  $ mpytool flash read vfs backup.bin # backup partition to file
503
594
  $ mpytool flash write nvs nvs_backup.bin # restore partition from file
@@ -505,11 +596,39 @@ $ mpytool flash erase vfs # quick erase partition
505
596
  $ mpytool flash erase vfs --full # full erase partition
506
597
  ```
507
598
 
599
+ `Content` shows what a partition actually holds - the detected filesystem for
600
+ data partitions, the image type for app partitions (`app <CHIP>`, or
601
+ `BOOTLOADER!` / `INVALID!` / `empty`). The `ota:` flags come from the `otadata`
602
+ partition and describe the **rollback bookkeeping** of a slot, not the image
603
+ stored in it: a slot can read `ota:valid` while holding an unbootable image.
604
+
508
605
  ### OTA firmware update (ESP32)
606
+
607
+ OTA needs an **app-only** image (`micropython.bin`), not the combined
608
+ `firmware.bin` (bootloader + partition table + app) that `esptool` writes.
609
+ mpytool checks the image header before uploading, so a wrong file is rejected
610
+ immediately instead of failing with `ESP_ERR_OTA_VALIDATE_FAILED` after the
611
+ whole transfer. It also refuses an image built for a different chip.
612
+
613
+ ```
614
+ $ mpytool flash ota micropython.bin # write to next OTA partition
615
+ $ mpytool flash ota micropython.bin -- reset --machine # write and reboot
616
+ $ mpytool flash ota micropython.bin -- reset --machine -t 30 # ... with 30s timeout
617
+ $ mpytool flash ota micropython.bin --force # write despite failed validation
618
+
619
+ $ mpytool flash ota confirm # mark running app valid (cancel rollback)
620
+ $ mpytool flash ota boot # boot the other slot (manual rollback)
621
+ $ mpytool flash ota boot ota_1 # boot a specific app partition
622
+ ```
623
+
624
+ If the firmware enables rollback, a freshly booted OTA image stays in
625
+ `pending-verify` and reverts on the next reset until it is confirmed - either by
626
+ the app itself (`esp32.Partition(esp32.Partition.RUNNING).mark_app_valid_cancel_rollback()`)
627
+ or from the host:
628
+
509
629
  ```
510
- $ mpytool flash ota firmware.app-bin # flash to next OTA partition
511
- $ mpytool flash ota firmware.app-bin -- reset --machine # flash and reboot
512
- $ mpytool flash ota firmware.app-bin -- reset --machine -t 30 # flash and reboot with 30s timeout
630
+ $ mpytool flash ota micropython.bin -- reset --machine
631
+ $ mpytool flash ota confirm
513
632
  ```
514
633
 
515
634
  ### Multiple commands separated by `--`
@@ -681,14 +681,45 @@ mpy.unique_id()
681
681
 
682
682
  #### memory()
683
683
 
684
- Get memory usage.
684
+ Get MicroPython **GC heap** usage - the heap available to Python objects, not
685
+ physical RAM. On ESP32 builds with PSRAM the GC heap grows and shrinks on
686
+ demand, so `total` changes over time: right after a reset it can claim nearly
687
+ all free PSRAM, and it shrinks again once drivers allocate PSRAM outside the GC
688
+ heap (e.g. camera framebuffers via `heap_caps_malloc`). Use
689
+ [`esp_ram()`](#esp_ram) for the physical memory regions.
685
690
 
686
691
  ```python
687
692
  mpy.memory()
688
693
  ```
689
694
 
690
695
  **Returns:**
691
- - `dict`: `{'alloc': bytes_used, 'free': bytes_free, 'total': total_bytes}`
696
+ - `dict`: `{'alloc': bytes_used, 'free': bytes_free, 'total': gc_heap_bytes}`
697
+
698
+ #### esp_ram()
699
+
700
+ Get physical RAM usage per region (ESP32 only), read via
701
+ `esp32.idf_heap_info()`. Unlike `memory()` this is stable, since it reflects the
702
+ actual silicon rather than the floating GC heap.
703
+
704
+ ```python
705
+ mpy.esp_ram()
706
+ ```
707
+
708
+ **Returns:**
709
+ - `dict` with `'sram'` and, when PSRAM is fitted, `'psram'`; each is
710
+ `{'total': bytes, 'free': bytes, 'used': bytes}`. Empty dict on non-ESP32
711
+ devices or builds without `esp32.idf_heap_info`.
712
+
713
+ **Note:** totals cover memory registered with the ESP-IDF allocator, so they
714
+ exclude static `.data`/`.bss` and code in IRAM - they are the heap-available
715
+ portion of each region, not the raw chip capacity.
716
+
717
+ **Example:**
718
+ ```python
719
+ >>> mpy.esp_ram()
720
+ {'sram': {'total': 640000, 'free': 140000, 'used': 500000},
721
+ 'psram': {'total': 33554432, 'free': 20971520, 'used': 12582912}}
722
+ ```
692
723
 
693
724
  ### Mount
694
725
 
@@ -953,9 +984,14 @@ Get partition table information (ESP32 only).
953
984
  mpy.partitions()
954
985
  ```
955
986
 
987
+ Partitions are sorted by flash offset.
988
+
956
989
  **Returns:**
957
990
  - `dict` with keys:
958
- - `'partitions'`: List of partition dicts with keys: `label`, `type`, `type_name`, `subtype`, `subtype_name`, `offset`, `size`, `encrypted`, `running`, `filesystem`, `fs_block_size`
991
+ - `'partitions'`: List of partition dicts with keys: `label`, `type`, `type_name`, `subtype`, `subtype_name`, `offset`, `size`, `encrypted`, `running`, `filesystem`, `fs_block_size`, plus for OTA app partitions:
992
+ - `'ota_state'`: rollback state recorded in `otadata` - `'new'`, `'pending-verify'`, `'valid'`, `'invalid'`, `'aborted'` or `'undefined'`. This is bookkeeping for the *slot*; it says nothing about the image currently stored there.
993
+ - `'ota_boot'`: `True` for the slot the bootloader will start
994
+ - `'image'`: what the partition actually holds, as returned by [`parse_esp_image()`](#parse_esp_imagedata)
959
995
  - `'boot'`: Boot partition label or None
960
996
  - `'next_ota'`: Next OTA partition label or None
961
997
  - `'next_ota_size'`: Next OTA partition size or None
@@ -1068,24 +1104,146 @@ mpy.flash_erase(label=None, full=False, progress_callback=None)
1068
1104
  >>> mpy.flash_erase(label='nvs', full=True)
1069
1105
  ```
1070
1106
 
1071
- #### ota_write(data, progress_callback=None, compress=None)
1107
+ #### ota_write(data, progress_callback=None, compress=None, validate=True, force=False)
1072
1108
 
1073
1109
  Write firmware to next OTA partition and set it as boot partition.
1074
1110
 
1111
+ The image must be an **app-only** build (`micropython.bin`), not the combined
1112
+ `firmware.bin` that also contains the bootloader and partition table.
1113
+
1075
1114
  ```python
1076
- mpy.ota_write(data, progress_callback=None, compress=None)
1115
+ mpy.ota_write(data, progress_callback=None, compress=None,
1116
+ validate=True, force=False)
1077
1117
  ```
1078
1118
 
1079
1119
  **Parameters:**
1080
1120
  - `data` (bytes): Firmware content (.app-bin file)
1081
1121
  - `progress_callback` (callable, optional): Callback `(transferred, total, wire_bytes)`
1082
1122
  - `compress` (bool, optional): Enable/disable compression
1123
+ - `validate` (bool): Check the image header first; set `False` only if `check_ota_image()` was already called on this data
1124
+ - `force` (bool): With `validate`, warn instead of refusing a bad image
1083
1125
 
1084
1126
  **Returns:**
1085
1127
  - `dict`: `{'target': label, 'offset': partition_offset, 'size': fw_size, 'wire_bytes': bytes_sent, 'compressed': bool}`
1086
1128
 
1087
1129
  **Raises:**
1088
- - `MpyError`: If OTA not available or firmware too large
1130
+ - `MpyError`: If OTA not available, firmware too large, or the image fails validation
1131
+
1132
+ #### ota_confirm()
1133
+
1134
+ Mark the running app valid, cancelling a pending OTA rollback (wraps
1135
+ `esp32.Partition.mark_app_valid_cancel_rollback()`). After a fresh OTA boot the
1136
+ image is in `pending-verify` and reverts on the next reset until confirmed.
1137
+
1138
+ ```python
1139
+ mpy.ota_confirm()
1140
+ ```
1141
+
1142
+ **Returns:**
1143
+ - `str`: Label of the running partition
1144
+
1145
+ **Raises:**
1146
+ - `MpyError`: On non-ESP32 devices, without OTA partitions, or if rollback is unsupported
1147
+
1148
+ #### ota_set_boot(label=None)
1149
+
1150
+ Switch the boot partition for the next reset.
1151
+
1152
+ ```python
1153
+ mpy.ota_set_boot(label=None)
1154
+ ```
1155
+
1156
+ **Parameters:**
1157
+ - `label` (str, optional): Target app partition (e.g. `'ota_1'`). If `None`, the passive slot from `get_next_update()` is used - a manual rollback.
1158
+
1159
+ **Returns:**
1160
+ - `str`: Label of the partition set as boot
1161
+
1162
+ **Raises:**
1163
+ - `MpyError`: On non-ESP32 devices, no OTA layout, or unknown label
1164
+
1165
+ **Example:**
1166
+ ```python
1167
+ >>> mpy.ota_set_boot() # boot the other image
1168
+ 'ota_1'
1169
+ >>> mpy.machine_reset()
1170
+ ```
1171
+
1172
+ #### check_ota_image(data, force=False)
1173
+
1174
+ Validate firmware before an OTA upload. Rejects bootloader/combined images,
1175
+ empty or corrupt data, and images built for a different chip - turning a long
1176
+ upload followed by `ESP_ERR_OTA_VALIDATE_FAILED` into an immediate error.
1177
+
1178
+ ```python
1179
+ mpy.check_ota_image(data, force=False)
1180
+ ```
1181
+
1182
+ **Parameters:**
1183
+ - `data` (bytes): Firmware image
1184
+ - `force` (bool): Report problems as warnings and continue instead of raising
1185
+
1186
+ **Raises:**
1187
+ - `MpyError`: If the data is not an application image for this chip (unless `force`)
1188
+
1189
+ #### running_chip_id()
1190
+
1191
+ Chip id of the currently running application, read from its image header. The
1192
+ bootloader validates `chip_id` against the real silicon before booting, so a
1193
+ running image proves its id matches the actual chip - and the header layout is
1194
+ stable across IDF/MicroPython versions.
1195
+
1196
+ ```python
1197
+ mpy.running_chip_id()
1198
+ ```
1199
+
1200
+ The value is cached; `reset_state()` clears it.
1201
+
1202
+ **Returns:**
1203
+ - `int`: Chip id (see `mpytool.mpy.ESP_CHIP_IDS`), or `None` if undetermined
1204
+
1205
+ ### Module Functions
1206
+
1207
+ #### esp_chip_name(chip_id)
1208
+
1209
+ Display name for an `esp_chip_id_t` value.
1210
+
1211
+ ```python
1212
+ from mpytool import esp_chip_name
1213
+ ```
1214
+
1215
+ **Parameters:**
1216
+ - `chip_id` (int or None): Chip id, e.g. from `running_chip_id()`
1217
+
1218
+ **Returns:**
1219
+ - `str`: Name such as `'ESP32-P4'`, or `'0x0099'` for a chip not in `ESP_CHIP_IDS` (the table is display-only, so newer chips still work); `None` if `chip_id` is `None`
1220
+
1221
+ #### parse_esp_image(data)
1222
+
1223
+ Parse the header of an ESP32 flash image. Distinguishes an application image
1224
+ (what OTA requires) from a bootloader or combined flash image.
1225
+
1226
+ ```python
1227
+ from mpytool import parse_esp_image
1228
+ ```
1229
+
1230
+ **Parameters:**
1231
+ - `data` (bytes): At least the first ~176 bytes of the image
1232
+
1233
+ **Returns:**
1234
+ - `dict` with `'kind'` (`'app'`, `'bootloader'`, `'empty'` or `'invalid'`), plus:
1235
+ - `'chip_id'`, `'chip_name'`, `'segments'` (app/bootloader)
1236
+ - `'idf_ver'`, `'date'`, `'time'`, `'version'`, `'project'` (app only; MicroPython leaves version/project empty)
1237
+ - `'app_offset'` - where the app starts inside a combined image (bootloader only)
1238
+
1239
+ **Example:**
1240
+ ```python
1241
+ >>> parse_esp_image(open('micropython.bin', 'rb').read())
1242
+ {'segments': 8, 'chip_id': 18, 'chip_name': 'ESP32-P4', 'kind': 'app', ...}
1243
+ >>> parse_esp_image(open('firmware.bin', 'rb').read())
1244
+ {'segments': 3, 'chip_id': 18, 'chip_name': 'ESP32-P4', 'kind': 'bootloader',
1245
+ 'app_offset': 57344}
1246
+ ```
1089
1247
 
1090
1248
  ## MpyComm Class
1091
1249
 
@@ -269,16 +269,24 @@ _mpytool() {
269
269
  fi
270
270
  fi
271
271
  ;;
272
+ sync)
273
+ # Optional local project directory, -- anytime
274
+ if [[ $pos -eq 2 ]]; then
275
+ _files -/
276
+ fi
277
+ compadd -- '--'
278
+ ;;
272
279
  exec)
273
280
  # 1 code string, -- after it
274
281
  [[ $nargs -ge 1 ]] && compadd -- '--'
275
282
  ;;
276
283
  run)
277
- # 1 local .py file, -- after it
284
+ # optional local .py file or '-' for STDIN, -- after it
278
285
  if [[ $pos -eq 2 ]]; then
279
286
  _files -g '*.py'
287
+ compadd -- '-'
280
288
  fi
281
- [[ $nargs -ge 1 ]] && compadd -- '--'
289
+ compadd -- '--'
282
290
  ;;
283
291
  edit)
284
292
  # --editor CMD + 1 remote file, -- after file
@@ -323,10 +331,23 @@ _mpytool() {
323
331
  )
324
332
  _describe -t subcmds 'flash subcommand' subcmds
325
333
  elif [[ "$flash_subcmd" == "ota" ]]; then
326
- # ota: 1 firmware file, -- after it
334
+ # ota: <firmware.bin> | confirm | boot [label]
335
+ local ota_action="${words[cmd_start+2]}"
327
336
  if [[ $pos -eq 3 ]]; then
337
+ local -a otacmds=(
338
+ 'confirm:mark running app valid (cancel rollback)'
339
+ 'boot:switch boot partition'
340
+ )
341
+ _describe -t otacmds 'ota action' otacmds
328
342
  _files -g "*.bin"
343
+ compadd -- '--force'
344
+ elif [[ "$ota_action" == "boot" && $pos -eq 4 ]]; then
345
+ # partition label (device-specific, no completion)
346
+ compadd -- '--'
347
+ elif [[ "$ota_action" == "confirm" ]]; then
348
+ compadd -- '--'
329
349
  else
350
+ compadd -- '--force'
330
351
  compadd -- '--'
331
352
  fi
332
353
  elif [[ $pos -eq 3 && "$flash_subcmd" == "erase" ]]; then
@@ -344,9 +365,9 @@ _mpytool() {
344
365
  # -- after complete subcommand
345
366
  if [[ "$flash_subcmd" == "erase" && $pos -ge 3 ]]; then
346
367
  : # already handled above
347
- elif [[ "$flash_subcmd" == "ota" && $pos -ge 4 ]]; then
348
- compadd -- '--'
349
- elif [[ "$flash_subcmd" != "erase" && "$flash_subcmd" != "ota" && $pos -ge 5 ]]; then
368
+ elif [[ "$flash_subcmd" == "ota" ]]; then
369
+ : # already handled above
370
+ elif [[ "$flash_subcmd" != "erase" && $pos -ge 5 ]]; then
350
371
  compadd -- '--'
351
372
  fi
352
373
  ;;