idftool 1.2.0__tar.gz → 1.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 (123) hide show
  1. {idftool-1.2.0 → idftool-1.3.0}/CHANGELOG.md +11 -0
  2. {idftool-1.2.0/idftool.egg-info → idftool-1.3.0}/PKG-INFO +1 -1
  3. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/provisioning.md +6 -0
  4. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/bundles.md +1 -1
  5. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/nvs.md +6 -0
  6. {idftool-1.2.0 → idftool-1.3.0}/idftool/bundle.py +4 -3
  7. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/bundles.py +1 -1
  8. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/nvs.py +114 -19
  9. {idftool-1.2.0 → idftool-1.3.0}/idftool/flash.py +43 -31
  10. {idftool-1.2.0 → idftool-1.3.0/idftool.egg-info}/PKG-INFO +1 -1
  11. idftool-1.3.0/idftool.egg-info/scm_version.json +8 -0
  12. {idftool-1.2.0 → idftool-1.3.0}/test/test_flash.py +24 -10
  13. {idftool-1.2.0 → idftool-1.3.0}/test/test_offline.py +51 -1
  14. {idftool-1.2.0 → idftool-1.3.0}/test/test_virtual.py +15 -0
  15. idftool-1.2.0/idftool.egg-info/scm_version.json +0 -8
  16. {idftool-1.2.0 → idftool-1.3.0}/.github/workflows/build.yml +0 -0
  17. {idftool-1.2.0 → idftool-1.3.0}/.github/workflows/ci.yml +0 -0
  18. {idftool-1.2.0 → idftool-1.3.0}/.github/workflows/docs.yml +0 -0
  19. {idftool-1.2.0 → idftool-1.3.0}/.github/workflows/publish.yml +0 -0
  20. {idftool-1.2.0 → idftool-1.3.0}/.gitignore +0 -0
  21. {idftool-1.2.0 → idftool-1.3.0}/CONTRIBUTING.md +0 -0
  22. {idftool-1.2.0 → idftool-1.3.0}/LICENSE.md +0 -0
  23. {idftool-1.2.0 → idftool-1.3.0}/Makefile +0 -0
  24. {idftool-1.2.0 → idftool-1.3.0}/README.md +0 -0
  25. {idftool-1.2.0 → idftool-1.3.0}/docs/changelog.md +0 -0
  26. {idftool-1.2.0 → idftool-1.3.0}/docs/contributing.md +0 -0
  27. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/backups-and-releases.md +0 -0
  28. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/choosing-a-device.md +0 -0
  29. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/flashing-firmware.md +0 -0
  30. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/getting-started.md +0 -0
  31. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/monitoring.md +0 -0
  32. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/partition-addressing.md +0 -0
  33. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/partitions-or-files.md +0 -0
  34. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/plugins.md +0 -0
  35. {idftool-1.2.0 → idftool-1.3.0}/docs/guide/scripting.md +0 -0
  36. {idftool-1.2.0 → idftool-1.3.0}/docs/index.md +0 -0
  37. {idftool-1.2.0 → idftool-1.3.0}/docs/installation.md +0 -0
  38. {idftool-1.2.0 → idftool-1.3.0}/docs/library.md +0 -0
  39. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/discovery.md +0 -0
  40. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/espefuse.md +0 -0
  41. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/esptool.md +0 -0
  42. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/filesystems.md +0 -0
  43. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/firmware.md +0 -0
  44. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/global-options.md +0 -0
  45. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/idf-py.md +0 -0
  46. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/images.md +0 -0
  47. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/index.md +0 -0
  48. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/misc.md +0 -0
  49. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/monitor.md +0 -0
  50. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/partition-io.md +0 -0
  51. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/partition-table.md +0 -0
  52. {idftool-1.2.0 → idftool-1.3.0}/docs/reference/write-options.md +0 -0
  53. {idftool-1.2.0 → idftool-1.3.0}/docs/stylesheets/extra.css +0 -0
  54. {idftool-1.2.0 → idftool-1.3.0}/examples/identify-plugin/idftool_chip_description.py +0 -0
  55. {idftool-1.2.0 → idftool-1.3.0}/examples/identify-plugin/pyproject.toml +0 -0
  56. {idftool-1.2.0 → idftool-1.3.0}/idftool/__init__.py +0 -0
  57. {idftool-1.2.0 → idftool-1.3.0}/idftool/__main__.py +0 -0
  58. {idftool-1.2.0 → idftool-1.3.0}/idftool/apps.py +0 -0
  59. {idftool-1.2.0 → idftool-1.3.0}/idftool/cli.py +0 -0
  60. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/__init__.py +0 -0
  61. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/apps.py +0 -0
  62. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/firmware.py +0 -0
  63. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/fs.py +0 -0
  64. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/images.py +0 -0
  65. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/misc.py +0 -0
  66. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/partition_io.py +0 -0
  67. {idftool-1.2.0 → idftool-1.3.0}/idftool/commands/table.py +0 -0
  68. {idftool-1.2.0 → idftool-1.3.0}/idftool/display.py +0 -0
  69. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/__init__.py +0 -0
  70. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/common.py +0 -0
  71. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/fatfs.py +0 -0
  72. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/littlefs.py +0 -0
  73. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/spiffs.py +0 -0
  74. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/spiffsgen.py +0 -0
  75. {idftool-1.2.0 → idftool-1.3.0}/idftool/fs/wl.py +0 -0
  76. {idftool-1.2.0 → idftool-1.3.0}/idftool/nvs/__init__.py +0 -0
  77. {idftool-1.2.0 → idftool-1.3.0}/idftool/nvs/common.py +0 -0
  78. {idftool-1.2.0 → idftool-1.3.0}/idftool/nvs/crypto.py +0 -0
  79. {idftool-1.2.0 → idftool-1.3.0}/idftool/nvs/edit.py +0 -0
  80. {idftool-1.2.0 → idftool-1.3.0}/idftool/nvs/parser.py +0 -0
  81. {idftool-1.2.0 → idftool-1.3.0}/idftool/params.py +0 -0
  82. {idftool-1.2.0 → idftool-1.3.0}/idftool/partitions.py +0 -0
  83. {idftool-1.2.0 → idftool-1.3.0}/idftool/plugins.py +0 -0
  84. {idftool-1.2.0 → idftool-1.3.0}/idftool/ports.py +0 -0
  85. {idftool-1.2.0 → idftool-1.3.0}/idftool/state.py +0 -0
  86. {idftool-1.2.0 → idftool-1.3.0}/idftool/traceback.py +0 -0
  87. {idftool-1.2.0 → idftool-1.3.0}/idftool-onedir.spec +0 -0
  88. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/SOURCES.txt +0 -0
  89. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/dependency_links.txt +0 -0
  90. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/entry_points.txt +0 -0
  91. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/requires.txt +0 -0
  92. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/scm_file_list.json +0 -0
  93. {idftool-1.2.0 → idftool-1.3.0}/idftool.egg-info/top_level.txt +0 -0
  94. {idftool-1.2.0 → idftool-1.3.0}/idftool.py +0 -0
  95. {idftool-1.2.0 → idftool-1.3.0}/idftool.spec +0 -0
  96. {idftool-1.2.0 → idftool-1.3.0}/mkdocs.yml +0 -0
  97. {idftool-1.2.0 → idftool-1.3.0}/pyproject.toml +0 -0
  98. {idftool-1.2.0 → idftool-1.3.0}/setup.cfg +0 -0
  99. {idftool-1.2.0 → idftool-1.3.0}/test/README.md +0 -0
  100. {idftool-1.2.0 → idftool-1.3.0}/test/conftest.py +0 -0
  101. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/README.md +0 -0
  102. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/app-v1.bin +0 -0
  103. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/app-v2.bin +0 -0
  104. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/bootloader.bin +0 -0
  105. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/flash-image.bin +0 -0
  106. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/nvs.csv +0 -0
  107. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/partition-table.bin +0 -0
  108. {idftool-1.2.0 → idftool-1.3.0}/test/fixtures/esp32s3/partitions.csv +0 -0
  109. {idftool-1.2.0 → idftool-1.3.0}/test/project/.gitignore +0 -0
  110. {idftool-1.2.0 → idftool-1.3.0}/test/project/CMakeLists.txt +0 -0
  111. {idftool-1.2.0 → idftool-1.3.0}/test/project/README.md +0 -0
  112. {idftool-1.2.0 → idftool-1.3.0}/test/project/build_fixtures.sh +0 -0
  113. {idftool-1.2.0 → idftool-1.3.0}/test/project/main/CMakeLists.txt +0 -0
  114. {idftool-1.2.0 → idftool-1.3.0}/test/project/main/idf_component.yml +0 -0
  115. {idftool-1.2.0 → idftool-1.3.0}/test/project/main/main.c +0 -0
  116. {idftool-1.2.0 → idftool-1.3.0}/test/project/nvs.csv +0 -0
  117. {idftool-1.2.0 → idftool-1.3.0}/test/project/partitions.csv +0 -0
  118. {idftool-1.2.0 → idftool-1.3.0}/test/project/sdkconfig.defaults +0 -0
  119. {idftool-1.2.0 → idftool-1.3.0}/test/samples/bootloader-template.csv +0 -0
  120. {idftool-1.2.0 → idftool-1.3.0}/test/samples/nvs.csv +0 -0
  121. {idftool-1.2.0 → idftool-1.3.0}/test/samples/partitions.csv +0 -0
  122. {idftool-1.2.0 → idftool-1.3.0}/test/test_device.py +0 -0
  123. {idftool-1.2.0 → idftool-1.3.0}/test/test_nvs.py +0 -0
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [v1.3.0] — 2026-10-02
8
+
9
+ ### Added
10
+ - `set-nvs --csv` sets every key in a CSV without touching the rest of the
11
+ partition. Bundle `set-nvs` ops take a CSV as `file`.
12
+
13
+ ### Changed
14
+ - The progress bar for sector-by-sector writes matches esptool's: sizes in
15
+ kB/MB and elapsed time.
16
+
7
17
  ## [v1.2.0] — 2026-10-01
8
18
 
9
19
  ### Added
@@ -400,6 +410,7 @@ this project adheres to [Semantic Versioning](https://semver.org/).
400
410
  ### Added
401
411
  - Initial release.
402
412
 
413
+ [v1.3.0]: https://github.com/nebkat/idftool/releases/tag/v1.3.0
403
414
  [v1.2.0]: https://github.com/nebkat/idftool/releases/tag/v1.2.0
404
415
  [v1.1.0]: https://github.com/nebkat/idftool/releases/tag/v1.1.0
405
416
  [v1.0.0]: https://github.com/nebkat/idftool/releases/tag/v1.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idftool
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: The ultimate CLI tool for interacting with Espressif devices
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Source, https://github.com/nebkat/idftool
@@ -40,6 +40,12 @@ Add a key that doesn't exist yet by giving its type:
40
40
  idftool set-nvs nvs config:serial:string=SN-0042
41
41
  ```
42
42
 
43
+ Set several keys from a CSV, keeping everything else:
44
+
45
+ ```bash
46
+ idftool set-nvs nvs --csv config.csv
47
+ ```
48
+
43
49
  ### Read values back
44
50
 
45
51
  ```console
@@ -51,7 +51,7 @@ order after the files:
51
51
  | `erase` | `partition` | Erase a partition. |
52
52
  | `write-fs` | `partition`, `file` | Write a filesystem image. |
53
53
  | `edit-fs` | `partition`, `put`, `delete` | Put files (path → file in the bundle) in the filesystem, and delete paths. |
54
- | `set-nvs` | `partition`, `set`, `delete` | Set keys (`ns:key` → `type:value`, or a bare value for a key that exists) and delete them. Without `partition`, the first NVS partition. |
54
+ | `set-nvs` | `partition`, `file`, `set`, `delete` | Set the keys in `file` (an NVS CSV), set keys (`ns:key` → `type:value`, or a bare value for a key that exists) and delete them. Without `partition`, the first NVS partition. |
55
55
  | `set-boot` | `partition` | Boot that OTA slot next. |
56
56
  | `clear-boot` | | Clear otadata so the factory app boots. |
57
57
 
@@ -137,6 +137,7 @@ idftool set-nvs nvs storage:serial:string=SN-0001 -d storage:old_key
137
137
  idftool set-nvs nvs -n storage :cert:blob=@device.der
138
138
  idftool set-nvs -f nvs.bin storage:device_id=42
139
139
  idftool set-nvs nvs storage:device_id=42 --dry-run
140
+ idftool set-nvs nvs --csv keys.csv
140
141
  ```
141
142
 
142
143
  `namespace:key=value`
@@ -157,6 +158,11 @@ idftool set-nvs nvs storage:device_id=42 --dry-run
157
158
  `-d namespace:key`
158
159
  : Delete a key.
159
160
 
161
+ `-c FILE`, `--csv FILE`
162
+ : Set every key in a CSV, in the format `create-nvs` takes and `read-nvs`
163
+ writes. Keys not in the CSV are left alone. Repeatable; CSV rows apply
164
+ first, then specs, then deletes.
165
+
160
166
  Changes are **appended** the way firmware writes them: a new entry goes into
161
167
  free space and the old one is marked erased. Everything else stays
162
168
  byte-for-byte identical, and on a device only the 4 KiB pages that changed are
@@ -116,8 +116,8 @@ class Op:
116
116
  f"\"delete\" a list")
117
117
  if op == 'edit-fs' and not (put or delete):
118
118
  raise BundleError(f"{where} (edit-fs): needs \"put\" and/or \"delete\"")
119
- if op == 'set-nvs' and not (set_ or delete):
120
- raise BundleError(f"{where} (set-nvs): needs \"set\" and/or \"delete\"")
119
+ if op == 'set-nvs' and not (set_ or delete or data.get('file')):
120
+ raise BundleError(f"{where} (set-nvs): needs \"file\", \"set\" and/or \"delete\"")
121
121
  return cls(index, op, data.get('partition'), data.get('file'),
122
122
  {str(k): str(v) for k, v in put.items()}, [str(d) for d in delete],
123
123
  dict(set_))
@@ -138,7 +138,8 @@ class Op:
138
138
  f"delete {', '.join(self.delete)}" if self.delete else '']
139
139
  return f"Update files in '{self.partition}': {'; '.join(w for w in what if w)}"
140
140
  if self.op == 'set-nvs':
141
- what = [f"{k} = {v}" for k, v in self.set.items()] + [f"delete {d}" for d in self.delete]
141
+ what = ([f"keys from {self.file}"] if self.file else []) + \
142
+ [f"{k} = {v}" for k, v in self.set.items()] + [f"delete {d}" for d in self.delete]
142
143
  where = f" ('{self.partition}')" if self.partition else ''
143
144
  return f"Update NVS{where}: {', '.join(what)}"
144
145
  if self.op == 'set-boot':
@@ -235,7 +235,7 @@ def write_bundle(state, input_file, hmac_key=None, **options):
235
235
  f"'{op.partition}' is not an OTA app partition")
236
236
  elif op.op == 'set-nvs':
237
237
  try:
238
- nvs_edits[op.index] = manifest_edits(op.set, op.delete, read_file)
238
+ nvs_edits[op.index] = manifest_edits(op.set, op.delete, read_file, op.file)
239
239
  except (click.UsageError, RuntimeError) as e:
240
240
  message = e.format_message() if isinstance(e, click.UsageError) else str(e)
241
241
  raise BundleError(f"manifest.json: op {op.index} (set-nvs): {message}") from e
@@ -20,8 +20,8 @@ from idftool.partitions import get_partition
20
20
 
21
21
  # Keep the pass-through write options in a panel of their own.
22
22
  option_group('write-nvs', '--hmac-key')
23
- option_group('set-nvs', '--file', '--delete', '--namespace', '--output', '--rewrite', '--hmac-key',
24
- '--dry-run')
23
+ option_group('set-nvs', '--file', '--csv', '--delete', '--namespace', '--output', '--rewrite',
24
+ '--hmac-key', '--dry-run')
25
25
 
26
26
 
27
27
  def hmac_key_option(f):
@@ -338,25 +338,114 @@ def _parse_delete(spec, default_namespace):
338
338
  return Edit(namespace, key)
339
339
 
340
340
 
341
+ def _fill_blob(encoding, value, key):
342
+ """The bytes of a ``blob_fill(N;0xPP)`` or ``blob_sz_fill(N;0xPP)`` value."""
343
+ import re
344
+ import struct
345
+
346
+ match = re.fullmatch(r'(blob_fill|blob_sz_fill)\((\d+);(0x[0-9a-fA-F]{2})\)', encoding)
347
+ if not match:
348
+ raise click.UsageError(f"'{key}': invalid encoding '{encoding}'")
349
+ kind, length, pad = match.group(1), int(match.group(2)), int(match.group(3), 16)
350
+ data = value.encode('utf-8')
351
+ if len(data) > length:
352
+ raise click.UsageError(f"'{key}': value is longer than {length} bytes")
353
+ prefix = struct.pack('<I', len(data)) if kind == 'blob_sz_fill' else b''
354
+ return prefix + data + bytes([pad]) * (length - len(data))
355
+
356
+
357
+ def _csv_value(key, row_type, encoding, value, read_file):
358
+ """``(type, value)`` of one ``nvs_partition_gen`` CSV row."""
359
+ import base64
360
+ import binascii
361
+ from idftool.nvs import PRIMITIVES
362
+
363
+ if row_type == 'file':
364
+ raw = read_file(value)
365
+ if encoding == 'binary':
366
+ return 'blob', raw
367
+ value = raw.decode('utf-8')
368
+ if encoding == 'string':
369
+ return 'string', value
370
+ elif row_type != 'data':
371
+ raise click.UsageError(f"'{key}': unknown type '{row_type}' (expected namespace, data "
372
+ f"or file)")
373
+
374
+ try:
375
+ if encoding in PRIMITIVES:
376
+ return encoding, int(value.strip(), 0)
377
+ if encoding == 'string':
378
+ return 'string', value
379
+ if encoding == 'hex2bin':
380
+ return 'blob', bytes.fromhex(value.strip())
381
+ if encoding == 'base64':
382
+ return 'blob', base64.b64decode(value)
383
+ if encoding == 'binary':
384
+ return 'blob', value.encode('utf-8')
385
+ except (ValueError, binascii.Error) as e:
386
+ raise click.UsageError(f"'{key}': '{value}' is not valid {encoding}: {e}")
387
+ if encoding.startswith(('blob_fill', 'blob_sz_fill')):
388
+ return 'blob', _fill_blob(encoding, value, key)
389
+ raise click.UsageError(f"'{key}': unknown encoding '{encoding}'")
390
+
391
+
392
+ def csv_edits(text, source, read_file=_read_host_file):
393
+ """Edits setting every key in an ``nvs_partition_gen`` CSV, the format ``create-nvs``
394
+ takes and ``read-nvs`` writes. ``file`` rows are read through `read_file`."""
395
+ import csv
396
+ from idftool.nvs.edit import Edit
397
+
398
+ rows = csv.DictReader(line for line in text.splitlines() if not line.startswith('#'))
399
+ if not rows.fieldnames or not {'key', 'type', 'encoding', 'value'} <= set(rows.fieldnames):
400
+ raise click.UsageError(f"{source} needs a 'key,type,encoding,value' header")
401
+ edits, namespace = [], None
402
+ for row in rows:
403
+ key, row_type = row['key'], (row['type'] or '').strip()
404
+ if row_type == 'namespace':
405
+ namespace = key
406
+ continue
407
+ if namespace is None:
408
+ raise click.UsageError(f"{source}: '{key}' comes before any namespace row")
409
+ try:
410
+ type_name, value = _csv_value(key, row_type, (row['encoding'] or '').strip(),
411
+ row['value'] or '', read_file)
412
+ except click.UsageError as e:
413
+ raise click.UsageError(f"{source}: {e.format_message()}")
414
+ edits.append(Edit(namespace, key, type_name, value))
415
+ return edits
416
+
417
+
418
+ def _read_csv(path):
419
+ try:
420
+ text = open(path, encoding='utf-8').read()
421
+ except OSError as e:
422
+ raise click.UsageError(f"Cannot read CSV file '{path}': {e}")
423
+ return csv_edits(text, f"'{path}'")
424
+
425
+
341
426
  def _resolve_untyped(image, edits, read_file=_read_host_file):
342
- """Fill in the type of any ``--set`` that didn't give one, from the entry it replaces."""
427
+ """Fill in the type of any ``--set`` that didn't give one, from the entry it replaces —
428
+ an earlier edit of the same key, or else the image's."""
343
429
  from idftool.nvs.edit import Edit
344
430
 
345
- resolved = []
431
+ resolved, types = [], {}
346
432
  for edit in edits:
347
433
  if edit.is_delete or edit.type is not None:
348
434
  resolved.append(edit)
435
+ types[edit.qualified] = edit.type
349
436
  continue
350
437
  existing = image.get(edit.namespace, edit.key)
351
- if existing is None:
438
+ type_name = types.get(edit.qualified, existing and existing.type)
439
+ if type_name is None:
352
440
  from idftool.nvs import PRIMITIVES, NvsError
353
441
  known = ', '.join(list(PRIMITIVES) + ['string', 'blob'])
354
442
  raise NvsError(
355
443
  f"'{edit.namespace}:{edit.key}' is not in the image, so its type cannot be "
356
444
  f"inferred — write it as {edit.namespace}:{edit.key}:<type>={edit.value} "
357
445
  f"(types: {known})")
358
- resolved.append(Edit(edit.namespace, edit.key, existing.type,
359
- _parse_value(existing.type, edit.value, read_file)))
446
+ types[edit.qualified] = type_name
447
+ resolved.append(Edit(edit.namespace, edit.key, type_name,
448
+ _parse_value(type_name, edit.value, read_file)))
360
449
  return resolved
361
450
 
362
451
 
@@ -403,13 +492,14 @@ def _write_pages(esp, part, result, dirty, options, hmac_key=None):
403
492
  **write_flash_options(options, skip_flashed=True, diff=True))
404
493
 
405
494
 
406
- def manifest_edits(set_values, deletes, read_file):
407
- """Edits from a bundle manifest's ``set-nvs`` op: ``ns:key`` → ``type:value`` (or a bare
408
- value for a key that exists), and ``ns:key`` deletes."""
495
+ def manifest_edits(set_values, deletes, read_file, csv_file=None):
496
+ """Edits from a bundle manifest's ``set-nvs`` op: the keys of `csv_file`, then ``ns:key`` →
497
+ ``type:value`` (or a bare value for a key that exists), then ``ns:key`` deletes."""
409
498
  from idftool.nvs import PRIMITIVES
410
499
 
411
500
  known = list(PRIMITIVES) + ['string', 'blob']
412
- edits = []
501
+ edits = (csv_edits(read_file(csv_file).decode('utf-8'), f"'{csv_file}'", read_file)
502
+ if csv_file else [])
413
503
  for qualified, value in set_values.items():
414
504
  value = str(value)
415
505
  prefix, colon, rest = value.partition(':')
@@ -462,22 +552,23 @@ def _split_target(args, image_file, what):
462
552
 
463
553
 
464
554
  def set_nvs(state, args, image_file, deletes, namespace, output_file, do_rewrite, dry_run,
465
- hmac_key=None, **options):
555
+ hmac_key=None, csv_files=(), **options):
466
556
  """Set or delete keys in an NVS partition or image. Keyword arguments go to esptool's
467
557
  ``write_flash`` (see :data:`idftool.flash.WRITE_FLASH_OPTIONS`)."""
468
558
  import idftool.nvs as nvs
469
559
  from idftool.nvs.edit import apply
470
560
 
471
561
  partition, specs = _split_target(args, image_file, "SPEC")
472
- if not specs and not deletes:
562
+ if not specs and not deletes and not csv_files:
473
563
  if partition and '=' in partition:
474
564
  # The one positional given is a spec, so what's missing is the target it applies to.
475
565
  raise click.UsageError(
476
566
  f"'{partition}' looks like a SPEC, not a partition — name the partition first "
477
567
  f"(idftool set-nvs nvs {partition}) or use -f/--file")
478
- raise click.UsageError(f"Nothing to do — pass at least one SPEC or --delete.\n{SPEC_HELP}")
568
+ raise click.UsageError(f"Nothing to do — pass at least one SPEC, --csv or --delete.\n{SPEC_HELP}")
479
569
 
480
- edits = ([_parse_set(spec, namespace) for spec in specs] +
570
+ edits = ([edit for path in csv_files for edit in _read_csv(path)] +
571
+ [_parse_set(spec, namespace) for spec in specs] +
481
572
  [_parse_delete(spec, namespace) for spec in deletes])
482
573
 
483
574
  data, part, source = _load_image(state, partition, image_file, 'set-nvs', hmac_key)
@@ -518,6 +609,8 @@ def set_nvs(state, args, image_file, deletes, namespace, output_file, do_rewrite
518
609
  @click.argument('args', nargs=-1, metavar='[PARTITION] SPEC...')
519
610
  @click.option('-f', '--file', 'image_file', default=None,
520
611
  help='Edit this NVS image file instead of a partition on the device')
612
+ @click.option('-c', '--csv', 'csv_files', multiple=True, metavar='CSV',
613
+ help='Set every key in this CSV (the create-nvs format); repeatable')
521
614
  @click.option('-d', '--delete', 'deletes', multiple=True, metavar='KEY',
522
615
  help='Delete a key: namespace:key')
523
616
  @click.option('-n', '--namespace', default=None,
@@ -530,20 +623,22 @@ def set_nvs(state, args, image_file, deletes, namespace, output_file, do_rewrite
530
623
  @hmac_key_option
531
624
  @flash_options
532
625
  @pass_state
533
- def cmd_set_nvs(state, args, image_file, deletes, namespace, output_file, do_rewrite, dry_run,
534
- hmac_key, **options):
626
+ def cmd_set_nvs(state, args, image_file, csv_files, deletes, namespace, output_file, do_rewrite,
627
+ dry_run, hmac_key, **options):
535
628
  """Set or delete keys in an NVS partition on the device, or in an image file with --file.
536
629
 
537
630
  A SPEC is `namespace:key=value`, or `namespace:key:type=value` to give the type of a key
538
631
  that isn't there yet — for one that is, the type is taken from the entry being replaced.
539
- A value of `@FILE` is read from that file.
632
+ A value of `@FILE` is read from that file. `--csv` sets every key in a CSV file of the
633
+ kind `create-nvs` takes and `read-nvs` writes, leaving the rest of the partition alone;
634
+ CSV rows apply first, then SPECs, then deletes.
540
635
 
541
636
  Changes are appended the way the firmware would write them, so everything else in the
542
637
  partition is left byte-for-byte alone and a device write only touches the pages that
543
638
  changed. If there is no room left to append, the image is compacted instead.
544
639
  """
545
640
  return set_nvs(state, args, image_file, deletes, namespace, output_file, do_rewrite, dry_run,
546
- hmac_key, **options)
641
+ hmac_key, csv_files, **options)
547
642
 
548
643
 
549
644
  def _parse_get(spec, default_namespace):
@@ -313,30 +313,51 @@ def plan_sector_writes(esp, address, data, on_scanned=None, sector_size=DIFF_SEC
313
313
 
314
314
 
315
315
  class Progress:
316
- """One progress bar for a whole operation, drawn by esptool's own logger.
316
+ """One progress bar for a whole operation: esptool's own (``log.progress``).
317
317
 
318
318
  The differential path scans and writes in the same pass, so its progress is two numbers
319
- that advance independently — bytes compared, and bytes written. Both go on one bar, which
320
- tracks the scan (whose total is known from the start) and reports the writes alongside.
321
-
322
- Rendering goes through ``log.progress_bar`` rather than being written out here, so this
323
- inherits esptool's decisions rather than second-guessing them: whether the terminal takes
324
- ANSI escapes, the Windows fallback, and the verbosity setting, which is what ``silent``
325
- has to reach to be worth anything. It also means the bar looks like every other bar the
326
- user sees from a flashing tool.
319
+ that advance independently — bytes compared, and bytes written. The bar tracks the scan
320
+ (whose total is known from the start); what has been written goes in its description.
321
+
322
+ Going through esptool's logger means the bar looks like every other bar esptool draws —
323
+ same layout, sizes and elapsed time — and obeys its decisions on ANSI, Unicode and
324
+ verbosity.
327
325
  """
328
326
 
329
- def __init__(self, prefix, total, enabled=True):
330
- self.prefix, self.total, self.enabled = prefix, total, enabled and total > 0
327
+ class _StoppedShort(Exception):
328
+ """Thrown into ``log.progress`` so a scan that stopped short isn't drawn as done."""
329
+
330
+ def __init__(self, description, total, enabled=True):
331
+ self.description, self.total, self.enabled = description, total, enabled and total > 0
331
332
  self.scanned = self.written = 0
332
333
  self._shown = None
334
+ self._drawn = 0
335
+ self._context = self._task = None
336
+
337
+ def __enter__(self):
338
+ self._context = log.progress(total=self.total, description=self.description,
339
+ unit='B', disable=not self.enabled)
340
+ self._task = self._context.__enter__()
341
+ return self
342
+
343
+ def __exit__(self, *exc):
344
+ if self.scanned < self.total:
345
+ # An abandoned or failed scan never reaches 100%: end the line where it stopped.
346
+ if self._shown is not None:
347
+ log.print("")
348
+ if exc[0] is None:
349
+ exc = (self._StoppedShort, self._StoppedShort(), None)
350
+ self._context.__exit__(*exc)
333
351
 
334
352
  @staticmethod
335
353
  def size(count):
336
- for unit, scale in (('M', 1 << 20), ('K', 1 << 10)):
337
- if count >= scale:
338
- return f'{count / scale:.1f}{unit}'.replace('.0', '')
339
- return f'{count}B'
354
+ """A byte count as esptool's bar shows it, e.g. ``1.20MB``."""
355
+ if count < 1024:
356
+ return f'{count}B'
357
+ for unit in ('kB', 'MB'):
358
+ count /= 1024
359
+ if count < 1024 or unit == 'MB':
360
+ return f'{count:.2f}{unit}'
340
361
 
341
362
  @staticmethod
342
363
  def rate(count, seconds):
@@ -349,26 +370,20 @@ class Progress:
349
370
  256 lines of scroll on one that cannot overwrite.
350
371
  """
351
372
  if scanned is not None:
352
- self.scanned = scanned
373
+ self.scanned = min(scanned, self.total)
353
374
  if written is not None:
354
375
  self.written = written
355
376
  if not self.enabled:
356
377
  return
357
- percent = 100 * min(self.scanned, self.total) // self.total
378
+ percent = 100 * self.scanned // self.total
358
379
  if percent == self._shown:
359
380
  return
360
381
  self._shown = percent
361
- suffix = f' compared {self.size(self.scanned)}/{self.size(self.total)}'
382
+ description = self.description
362
383
  if self.written:
363
- suffix += f', written {self.size(self.written)}'
364
- log.progress_bar(min(self.scanned, self.total), self.total,
365
- prefix=f'{self.prefix} ', suffix=suffix)
366
-
367
- def finish(self):
368
- """End the bar's line if it stopped short — an abandoned scan never reaches 100%."""
369
- if self._shown is not None and self._shown != 100:
370
- log.print("")
371
- self._shown = None
384
+ description += f' (written {self.size(self.written)})'
385
+ self._task.update(advance=self.scanned - self._drawn, description=description)
386
+ self._drawn = self.scanned
372
387
 
373
388
 
374
389
  def _say(prefix, args, file=None):
@@ -490,12 +505,11 @@ def _write_changed_sectors(esp, address, prepared, described, kwargs):
490
505
  fall back to comparing the region as a whole.
491
506
  """
492
507
  print(f"Comparing {described} against flash at {address:#010x}...")
493
- progress = Progress(described, len(prepared), enabled=not kwargs.get('no_progress'))
494
508
  written = runs = 0
495
509
  elapsed = 0.0
496
510
  abandoned = False
497
511
 
498
- try:
512
+ with Progress(described, len(prepared), enabled=not kwargs.get('no_progress')) as progress:
499
513
  for offset, length, reason in plan_sector_writes(
500
514
  esp, address, prepared, on_scanned=lambda n: progress.update(scanned=n)):
501
515
  if reason == 'remainder':
@@ -514,8 +528,6 @@ def _write_changed_sectors(esp, address, prepared, described, kwargs):
514
528
  written += length
515
529
  runs += 1
516
530
  progress.update(written=written)
517
- finally:
518
- progress.finish()
519
531
 
520
532
  # esptool reports what it wrote and how fast after every write; a differential one is
521
533
  # made of many, so the same figures are totalled up and reported once here instead.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: idftool
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: The ultimate CLI tool for interacting with Espressif devices
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Source, https://github.com/nebkat/idftool
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.3.0",
3
+ "distance": 0,
4
+ "node": "gb08243cc7d12f7d75243ec81acd50c5cb9fa13d7",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-02"
8
+ }
@@ -487,7 +487,7 @@ def test_changed_sectors_are_written_as_they_are_found(monkeypatch, capsys):
487
487
  [(0x10000 + 5 * 0x1000, data[5 * 0x1000:7 * 0x1000])],
488
488
  [(0x10000 + 40 * 0x1000, data[40 * 0x1000:41 * 0x1000])],
489
489
  ]
490
- assert "wrote 12K of 256K in 2 regions" in capsys.readouterr().out
490
+ assert "wrote 12.00kB of 256.00kB in 2 regions" in capsys.readouterr().out
491
491
 
492
492
 
493
493
  def test_an_abandoned_scan_writes_the_region_whole(monkeypatch, capsys):
@@ -562,9 +562,8 @@ def test_app_commands_diff_by_default():
562
562
  def test_progress_is_silent_when_progress_is_not_wanted(capsys):
563
563
  from idftool.flash import Progress
564
564
 
565
- bar = Progress('x', 0x1000, enabled=False)
566
- bar.update(scanned=0x800)
567
- bar.finish()
565
+ with Progress('x', 0x1000, enabled=False) as bar:
566
+ bar.update(scanned=0x800)
568
567
  assert capsys.readouterr().out == ""
569
568
 
570
569
 
@@ -575,7 +574,8 @@ def test_progress_obeys_esptools_verbosity(capsys):
575
574
 
576
575
  log.set_verbosity('silent')
577
576
  try:
578
- Progress('x', 0x1000).update(scanned=0x800)
577
+ with Progress('x', 0x1000) as bar:
578
+ bar.update(scanned=0x800)
579
579
  assert capsys.readouterr().out == ""
580
580
  finally:
581
581
  log.set_verbosity('auto')
@@ -586,17 +586,31 @@ def test_progress_redraws_only_when_the_percentage_moves(capsys):
586
586
  and 256 lines of scroll on one that cannot."""
587
587
  from idftool.flash import Progress
588
588
 
589
- bar = Progress('x', 1000 * 0x1000)
590
- for sector in range(1, 21): # 20 updates spanning 0% to 2% of the region
591
- bar.update(scanned=sector * 0x1000)
589
+ with Progress('x', 1000 * 0x1000) as bar:
590
+ for sector in range(1, 21): # 20 updates spanning 0% to 2% of the region
591
+ bar.update(scanned=sector * 0x1000)
592
592
  assert capsys.readouterr().out.count('%') == 3 # the first, then 1% and 2%
593
593
 
594
594
 
595
- def test_progress_sizes_read_the_way_a_person_would_say_them():
595
+ def test_progress_sizes_match_esptools_bar():
596
596
  from idftool.flash import Progress
597
597
 
598
598
  assert [Progress.size(n) for n in (512, 0x1000, 0x40000, 0x100000, 0x180000)] == \
599
- ["512B", "4K", "256K", "1M", "1.5M"]
599
+ ["512B", "4.00kB", "256.00kB", "1.00MB", "1.50MB"]
600
+
601
+
602
+ def test_progress_uses_esptools_layout_and_stops_short_of_done(capsys):
603
+ """An abandoned scan must not be drawn as complete on the way out."""
604
+ from idftool.flash import Progress
605
+
606
+ with Progress('x', 4 * 0x1000) as bar:
607
+ bar.update(scanned=0x1000)
608
+ bar.update(written=0x1000)
609
+ bar.update(scanned=0x2000)
610
+ out = capsys.readouterr().out
611
+ assert "x (written 4.00kB)" in out
612
+ assert "8.00kB/16.00kB [" in out
613
+ assert "100.0%" not in out
600
614
 
601
615
 
602
616
  def test_quiet_esptool_keeps_what_matters_and_restores_itself(capsys):
@@ -224,7 +224,57 @@ def test_set_nvs_needs_something_to_do(run_offline, tmp_path):
224
224
  image = tmp_path / "nvs.bin"
225
225
  run_offline(f"create-nvs {SAMPLES / 'nvs.csv'} -o {image} --size 0x6000")
226
226
  out = run_offline(f"set-nvs -f {image}", expect_error=True)
227
- assert "at least one SPEC or --delete" in out
227
+ assert "at least one SPEC, --csv or --delete" in out
228
+
229
+
230
+ def test_set_nvs_csv_merges(run_offline, tmp_path):
231
+ image = tmp_path / "nvs.bin"
232
+ run_offline(f"create-nvs {SAMPLES / 'nvs.csv'} -o {image} --size 0x6000")
233
+ (tmp_path / "cert.der").write_bytes(b"\x01\x02\x03")
234
+ keys = tmp_path / "keys.csv"
235
+ keys.write_text(
236
+ "key,type,encoding,value\n"
237
+ "# comments are skipped\n"
238
+ "storage,namespace,,\n"
239
+ "counter,data,u16,0x2a\n"
240
+ "serial,data,string,SN-42\n"
241
+ "token,data,base64,3q2+7w==\n"
242
+ "pad,data,blob_sz_fill(4;0x00),ab\n"
243
+ f"cert,file,binary,{tmp_path / 'cert.der'}\n"
244
+ "other,namespace,,\n"
245
+ "flag,data,u8,1\n")
246
+ # SPECs apply after the CSV, so they win.
247
+ run_offline(["set-nvs", "-f", str(image), "--csv", str(keys), "storage:serial=SN-43"])
248
+ get = lambda *keys: run_offline(["get-nvs", "-f", str(image), *keys]).splitlines()
249
+ assert get("storage:counter", "storage:serial", "storage:token", "storage:pad",
250
+ "storage:cert", "other:flag") == ["42", "SN-43", "deadbeef", "0200000061620000",
251
+ "010203", "1"]
252
+ # Keys not in the CSV stay.
253
+ assert get("storage:device_name") == ["idftool-test"]
254
+
255
+
256
+ def test_set_nvs_csv_round_trips_extract(run_offline, tmp_path):
257
+ image, other = tmp_path / "nvs.bin", tmp_path / "other.bin"
258
+ run_offline(f"create-nvs {SAMPLES / 'nvs.csv'} -o {image} --size 0x6000")
259
+ run_offline(f"set-nvs -f {image} storage:blob:blob=00ff storage:counter=9")
260
+ run_offline(f"extract-nvs -f {image} {tmp_path / 'back.csv'}")
261
+ (tmp_path / "empty.csv").write_text("key,type,encoding,value\nx,namespace,,\nk,data,u8,0\n")
262
+ run_offline(f"create-nvs {tmp_path / 'empty.csv'} -o {other} --size 0x6000")
263
+ out = run_offline(f"set-nvs -f {other} --csv {tmp_path / 'back.csv'}")
264
+ assert "+ storage:blob (blob) = 00ff" in out
265
+ assert run_offline(f"get-nvs -f {other} storage:counter x:k").splitlines() == ["9", "0"]
266
+
267
+
268
+ def test_set_nvs_csv_errors_name_the_row(run_offline, tmp_path):
269
+ image = tmp_path / "nvs.bin"
270
+ run_offline(f"create-nvs {SAMPLES / 'nvs.csv'} -o {image} --size 0x6000")
271
+ bad = tmp_path / "bad.csv"
272
+ bad.write_text("key,type,encoding,value\nstorage,namespace,,\ncounter,data,u16,lots\n")
273
+ assert "'counter': 'lots' is not valid u16" in run_offline(
274
+ f"set-nvs -f {image} --csv {bad}", expect_error=True)
275
+ bad.write_text("key,type,encoding,value\ncounter,data,u16,1\n")
276
+ assert "before any namespace" in run_offline(f"set-nvs -f {image} --csv {bad}",
277
+ expect_error=True)
228
278
 
229
279
 
230
280
  def test_set_nvs_value_from_a_file(run_offline, tmp_path):
@@ -500,6 +500,21 @@ def test_print_bundle_without_a_table(run_offline, tmp_path):
500
500
  assert "2.0.0" in out
501
501
 
502
502
 
503
+ def test_bundle_sets_nvs_from_a_csv(run, tmp_path, esp):
504
+ (tmp_path / "example.csv").write_text(
505
+ "key,type,encoding,value\nconfig,namespace,,\nserial,data,string,SN-0042\n"
506
+ "keep,data,u8,1\n")
507
+ run("write-nvs", "nvs", "example.csv")
508
+ csv = ("key,type,encoding,value\nconfig,namespace,,\nserial,data,string,SN-0050\n"
509
+ "cert,file,binary,nvs/cert.der\n")
510
+ bundle("b.zip", {"nvs/keys.csv": csv, "nvs/cert.der": b"\xca\xfe",
511
+ "manifest.json": {"ops": [{"op": "set-nvs", "file": "nvs/keys.csv"}]}})
512
+ assert "keys from nvs/keys.csv" in run("print-bundle", "-f", "b.zip")
513
+ run("write-bundle", "b.zip")
514
+ assert run("get-nvs", "nvs", "config:serial", "config:keep", "config:cert").splitlines()[:3] \
515
+ == ["SN-0050", "1", "cafe"]
516
+
517
+
503
518
  def test_nvs_on_the_device(run, tmp_path, esp):
504
519
  (tmp_path / "example.csv").write_text(
505
520
  "key,type,encoding,value\nconfig,namespace,,\nserial,data,string,SN-0042\n")
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.2.0",
3
- "distance": 0,
4
- "node": "g151b902633ee2fdea932f57fd5721f2c37cb774d",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-01"
8
- }
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
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
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
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
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
File without changes
File without changes
File without changes