folder2uf2 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- folder2uf2/__init__.py +734 -0
- folder2uf2/__main__.py +8 -0
- folder2uf2/selfextract.py +144 -0
- folder2uf2-0.1.0.dist-info/METADATA +215 -0
- folder2uf2-0.1.0.dist-info/RECORD +9 -0
- folder2uf2-0.1.0.dist-info/WHEEL +5 -0
- folder2uf2-0.1.0.dist-info/entry_points.txt +2 -0
- folder2uf2-0.1.0.dist-info/licenses/LICENSE +21 -0
- folder2uf2-0.1.0.dist-info/top_level.txt +1 -0
folder2uf2/__init__.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 Mikey Sklar
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
#
|
|
5
|
+
# folder2uf2: pack a folder into a FAT filesystem image wrapped in a UF2,
|
|
6
|
+
# targeting the CIRCUITPY drive region of an RP2040/RP2350 CircuitPython
|
|
7
|
+
# board. Lets a product ship code + libraries + assets as one drag-and-drop
|
|
8
|
+
# UF2 alongside (or after) the CircuitPython firmware UF2.
|
|
9
|
+
#
|
|
10
|
+
# The FAT geometry replicates what CircuitPython itself creates: oofatfs
|
|
11
|
+
# f_mkfs(FM_FAT, au=0) called on a virtual device whose sector 0 is a
|
|
12
|
+
# synthesized MBR with partition 1 at LBA 1 (supervisor/shared/flash.c).
|
|
13
|
+
# The raw flash region therefore holds a bare FAT volume (no MBR), whose
|
|
14
|
+
# BPB records 1 hidden sector. See README.md for the source derivation.
|
|
15
|
+
#
|
|
16
|
+
# Idea credit: jepler's archived mkfatimg (C/oofatfs) explored the same
|
|
17
|
+
# folder -> FAT image direction; this is an independent stdlib-only
|
|
18
|
+
# implementation.
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import os
|
|
22
|
+
import random
|
|
23
|
+
import struct
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
SECTOR = 512
|
|
28
|
+
UF2_MAGIC0 = 0x0A324655
|
|
29
|
+
UF2_MAGIC1 = 0x9E5D5157
|
|
30
|
+
UF2_MAGIC_END = 0x0AB16F30
|
|
31
|
+
UF2_FLAG_FAMILY_ID = 0x00002000
|
|
32
|
+
UF2_PAYLOAD = 256
|
|
33
|
+
|
|
34
|
+
FAMILY_RP2040 = 0xE48BFF56
|
|
35
|
+
FAMILY_RP2350_ARM_S = 0xE48BFF59
|
|
36
|
+
FAMILY_ABSOLUTE = 0xE48BFF57 # rp2xxx absolute (data anywhere in flash)
|
|
37
|
+
|
|
38
|
+
XIP_BASE = 0x10000000
|
|
39
|
+
|
|
40
|
+
# drive_start = CIRCUITPY_FIRMWARE_SIZE + CIRCUITPY_INTERNAL_NVM_SIZE (4K).
|
|
41
|
+
# Default firmware size is 1020K (ports/raspberrypi/mpconfigport.h); boards
|
|
42
|
+
# with CYW43 wifi override it to 1536K in their mpconfigboard.mk.
|
|
43
|
+
# flash_total from EXTERNAL_FLASH_DEVICES part number.
|
|
44
|
+
BOARDS = {
|
|
45
|
+
"raspberry_pi_pico": (FAMILY_RP2040, 0x100000, 2 * 1024 * 1024),
|
|
46
|
+
"raspberry_pi_pico_w": (FAMILY_RP2040, 0x181000, 2 * 1024 * 1024),
|
|
47
|
+
"raspberry_pi_pico2": (FAMILY_RP2350_ARM_S, 0x100000, 4 * 1024 * 1024),
|
|
48
|
+
"raspberry_pi_pico2_w": (FAMILY_RP2350_ARM_S, 0x181000, 4 * 1024 * 1024),
|
|
49
|
+
"adafruit_feather_rp2040": (FAMILY_RP2040, 0x100000, 8 * 1024 * 1024),
|
|
50
|
+
"adafruit_qtpy_rp2040": (FAMILY_RP2040, 0x100000, 8 * 1024 * 1024),
|
|
51
|
+
"adafruit_metro_rp2350": (FAMILY_RP2350_ARM_S, 0x100000, 16 * 1024 * 1024),
|
|
52
|
+
"adafruit_fruit_jam": (FAMILY_RP2350_ARM_S, 0x100000, 16 * 1024 * 1024),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Espressif boards keep the CIRCUITPY drive in a data/fat partition. tinyuf2
|
|
56
|
+
# on ESP32 only writes ota_0, so these produce a raw image for esptool rather
|
|
57
|
+
# than a UF2. Values are (offset, size) from
|
|
58
|
+
# ports/espressif/esp-idf-config/partitions-*.csv, sized to the fat partition
|
|
59
|
+
# ALONE, which is always safe.
|
|
60
|
+
#
|
|
61
|
+
# Real boards vary, so prefer --partition-table with a dump read off the chip:
|
|
62
|
+
# esptool --before no-reset --after no-reset read-flash 0x8000 0xc00 pt.bin
|
|
63
|
+
# A Metro ESP32-S3 16MB has ffat 11968K plus an ota_1 to extend into; a Metro
|
|
64
|
+
# ESP32-S2 4MB has ffat 960K and no ota_1 at all.
|
|
65
|
+
#
|
|
66
|
+
# With CIRCUITPY_STORAGE_EXTEND the filesystem may span the fat partition PLUS
|
|
67
|
+
# the spare OTA partition, since supervisor_flash_get_block_count() returns
|
|
68
|
+
# (_partition[0]->size + _partition[1]->size). The port decides at boot with
|
|
69
|
+
# storage_extended = (_partition[0]->size < fatfs_bytes());
|
|
70
|
+
# so sizing to the fat partition alone turns extension off and gives a smaller
|
|
71
|
+
# drive, while over-sizing writes a filesystem that runs past its partition.
|
|
72
|
+
# Use --storage-extend WITH --partition-table to size it correctly.
|
|
73
|
+
ESP_PARTITIONS = {
|
|
74
|
+
"esp32_4mb": (0x310000, 960 * 1024),
|
|
75
|
+
"esp32_8mb": (0x450000, 3776 * 1024),
|
|
76
|
+
"esp32_16mb": (0x450000, 11968 * 1024),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_partition_table(path):
|
|
81
|
+
"""Parse an esp-idf partition table dump. Returns (fat_offset, fat_size,
|
|
82
|
+
next_ota_size). next_ota_size is 0 when the table has no spare OTA slot."""
|
|
83
|
+
d = open(path, "rb").read()
|
|
84
|
+
fat = None
|
|
85
|
+
otas = []
|
|
86
|
+
for i in range(0, len(d) - 32, 32):
|
|
87
|
+
e = d[i:i + 32]
|
|
88
|
+
if e[:2] != b"\xaa\x50":
|
|
89
|
+
break
|
|
90
|
+
_, ptype, subtype, off, size, raw, _ = struct.unpack("<HBBII16sI", e)
|
|
91
|
+
name = raw.rstrip(b"\x00").decode("ascii", "replace")
|
|
92
|
+
if ptype == 1 and subtype == 0x81: # data, fat
|
|
93
|
+
fat = (off, size, name)
|
|
94
|
+
elif ptype == 0 and 0x10 <= subtype <= 0x1f: # app, ota_N
|
|
95
|
+
otas.append((subtype, off, size, name))
|
|
96
|
+
if fat is None:
|
|
97
|
+
raise ValueError("%s has no data/fat partition" % path)
|
|
98
|
+
# CircuitPython extends into the *next* OTA slot, which only exists when
|
|
99
|
+
# the table defines more than one.
|
|
100
|
+
next_ota = sorted(otas)[1][2] if len(otas) > 1 else 0
|
|
101
|
+
return fat[0], fat[1], next_ota
|
|
102
|
+
|
|
103
|
+
MAX_FAT12 = 0xFF5
|
|
104
|
+
MAX_FAT16 = 0xFFF5
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Geometry:
|
|
108
|
+
"""FAT12/16 geometry exactly as oofatfs f_mkfs computes it for
|
|
109
|
+
CircuitPython's flash blockdev (sz_blk=1, ss=512, au=0, 1 FAT)."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, volume_sectors):
|
|
112
|
+
sz_vol = volume_sectors
|
|
113
|
+
if sz_vol < 22:
|
|
114
|
+
raise ValueError("volume too small")
|
|
115
|
+
cst = [1, 4, 16, 64, 256, 512]
|
|
116
|
+
au = 0
|
|
117
|
+
while True:
|
|
118
|
+
pau = au
|
|
119
|
+
if pau == 0:
|
|
120
|
+
# C: for (i=0, pau=1; cst[i] && cst[i] <= n; i++, pau <<= 1);
|
|
121
|
+
n = sz_vol // 0x1000
|
|
122
|
+
pau = 1
|
|
123
|
+
for c in cst:
|
|
124
|
+
if c <= n:
|
|
125
|
+
pau <<= 1
|
|
126
|
+
else:
|
|
127
|
+
break
|
|
128
|
+
n_clst = sz_vol // pau
|
|
129
|
+
if n_clst > MAX_FAT12:
|
|
130
|
+
fmt = 16
|
|
131
|
+
nbytes = n_clst * 2 + 4
|
|
132
|
+
else:
|
|
133
|
+
fmt = 12
|
|
134
|
+
nbytes = (n_clst * 3 + 1) // 2 + 3
|
|
135
|
+
sz_fat = (nbytes + SECTOR - 1) // SECTOR
|
|
136
|
+
sz_rsv = 1
|
|
137
|
+
n_rootdir = 128 if sz_vol <= 256 else 512
|
|
138
|
+
sz_dir = n_rootdir * 32 // SECTOR
|
|
139
|
+
b_data = sz_rsv + sz_fat + sz_dir # relative to volume start
|
|
140
|
+
# sz_blk == 1 so no erase-block alignment adjustment
|
|
141
|
+
if sz_vol < b_data + pau * 16:
|
|
142
|
+
raise ValueError("volume too small")
|
|
143
|
+
n_clst = (sz_vol - sz_rsv - sz_fat - sz_dir) // pau
|
|
144
|
+
if fmt == 16:
|
|
145
|
+
if n_clst > MAX_FAT16:
|
|
146
|
+
if au == 0 and pau * 2 <= 64:
|
|
147
|
+
au = pau * 2
|
|
148
|
+
continue
|
|
149
|
+
raise ValueError("too many clusters for FAT16")
|
|
150
|
+
if n_clst <= MAX_FAT12:
|
|
151
|
+
au = pau * 2
|
|
152
|
+
if au <= 128:
|
|
153
|
+
continue
|
|
154
|
+
raise ValueError("cluster count in FAT12/16 gap")
|
|
155
|
+
if fmt == 12 and n_clst > MAX_FAT12:
|
|
156
|
+
raise ValueError("too many clusters for FAT12")
|
|
157
|
+
break
|
|
158
|
+
self.fat_type = fmt
|
|
159
|
+
self.sectors_per_cluster = pau
|
|
160
|
+
self.reserved = sz_rsv
|
|
161
|
+
self.fat_sectors = sz_fat
|
|
162
|
+
self.root_entries = n_rootdir
|
|
163
|
+
self.rootdir_sectors = sz_dir
|
|
164
|
+
self.total_sectors = sz_vol
|
|
165
|
+
self.cluster_count = n_clst
|
|
166
|
+
self.data_start = b_data # sector index within volume
|
|
167
|
+
self.cluster_bytes = pau * SECTOR
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
INVALID_SFN = set('"*+,./:;<=>?[\\]| ')
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _sfn_char(c):
|
|
174
|
+
c = c.upper()
|
|
175
|
+
if ord(c) < 0x20 or c in INVALID_SFN:
|
|
176
|
+
return "_"
|
|
177
|
+
return c
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def make_sfn(name, used):
|
|
181
|
+
"""Generate an 8.3 short name (11 bytes, space padded). Returns
|
|
182
|
+
(sfn_bytes, needs_lfn)."""
|
|
183
|
+
if "." in name and not name.startswith("."):
|
|
184
|
+
base, _, ext = name.rpartition(".")
|
|
185
|
+
else:
|
|
186
|
+
base, ext = name, ""
|
|
187
|
+
lossy = name.startswith(".")
|
|
188
|
+
base_f = "".join(_sfn_char(c) for c in base if c not in ". ")
|
|
189
|
+
ext_f = "".join(_sfn_char(c) for c in ext if c not in ". ")
|
|
190
|
+
if base_f != base or ext_f != ext or len(base_f) > 8 or len(ext_f) > 3 \
|
|
191
|
+
or not base_f:
|
|
192
|
+
lossy = True
|
|
193
|
+
fits = (not lossy and base == base_f and ext == ext_f
|
|
194
|
+
and len(base_f) <= 8 and len(ext_f) <= 3)
|
|
195
|
+
sfn = None
|
|
196
|
+
if fits:
|
|
197
|
+
cand = (base_f[:8].ljust(8) + ext_f[:3].ljust(3)).encode("ascii")
|
|
198
|
+
if cand not in used:
|
|
199
|
+
sfn = cand
|
|
200
|
+
if sfn is None:
|
|
201
|
+
base_f = (base_f or "_")[:8]
|
|
202
|
+
for tail in range(1, 1000000):
|
|
203
|
+
t = "~%d" % tail
|
|
204
|
+
cand_base = base_f[: 8 - len(t)] + t
|
|
205
|
+
cand = (cand_base.ljust(8) + ext_f[:3].ljust(3)).encode("ascii")
|
|
206
|
+
if cand not in used:
|
|
207
|
+
sfn = cand
|
|
208
|
+
break
|
|
209
|
+
lossy = True
|
|
210
|
+
used.add(sfn)
|
|
211
|
+
return sfn, lossy or not fits
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def lfn_checksum(sfn):
|
|
215
|
+
s = 0
|
|
216
|
+
for b in sfn:
|
|
217
|
+
s = (((s & 1) << 7) + (s >> 1) + b) & 0xFF
|
|
218
|
+
return s
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def lfn_entries(name, sfn):
|
|
222
|
+
"""VFAT long-name entries, in on-disk order (last logical first)."""
|
|
223
|
+
u = name.encode("utf-16-le")
|
|
224
|
+
chars = list(struct.unpack("<%dH" % (len(u) // 2), u))
|
|
225
|
+
chars.append(0)
|
|
226
|
+
while len(chars) % 13:
|
|
227
|
+
chars.append(0xFFFF)
|
|
228
|
+
n = len(chars) // 13
|
|
229
|
+
ck = lfn_checksum(sfn)
|
|
230
|
+
out = []
|
|
231
|
+
for i in range(n - 1, -1, -1):
|
|
232
|
+
seq = i + 1
|
|
233
|
+
if i == n - 1:
|
|
234
|
+
seq |= 0x40
|
|
235
|
+
c = chars[i * 13:(i + 1) * 13]
|
|
236
|
+
ent = struct.pack(
|
|
237
|
+
"<B10sBBB12sH4s",
|
|
238
|
+
seq,
|
|
239
|
+
struct.pack("<5H", *c[0:5]),
|
|
240
|
+
0x0F, 0, ck,
|
|
241
|
+
struct.pack("<6H", *c[5:11]),
|
|
242
|
+
0,
|
|
243
|
+
struct.pack("<2H", *c[11:13]),
|
|
244
|
+
)
|
|
245
|
+
out.append(ent)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def fat_datetime(ts):
|
|
250
|
+
t = time.localtime(ts)
|
|
251
|
+
year = max(1980, min(2107, t.tm_year))
|
|
252
|
+
d = ((year - 1980) << 9) | (t.tm_mon << 5) | t.tm_mday
|
|
253
|
+
tm = (t.tm_hour << 11) | (t.tm_min << 5) | (t.tm_sec // 2)
|
|
254
|
+
return d, tm
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def dirent(sfn, attr, cluster, size, ts):
|
|
258
|
+
d, tm = fat_datetime(ts)
|
|
259
|
+
return struct.pack(
|
|
260
|
+
"<11sBBBHHHHHHHI",
|
|
261
|
+
sfn, attr, 0, 0, tm, d, d, 0, tm, d, cluster & 0xFFFF, size)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
ATTR_DIR = 0x10
|
|
265
|
+
ATTR_VOLUME = 0x08
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class Node:
|
|
269
|
+
def __init__(self, name, path=None, is_dir=False, data=None, ts=None):
|
|
270
|
+
self.name = name
|
|
271
|
+
self.path = path
|
|
272
|
+
self.is_dir = is_dir
|
|
273
|
+
self.data = data # bytes for synthesized files
|
|
274
|
+
self.ts = ts
|
|
275
|
+
self.children = [] # for dirs
|
|
276
|
+
self.cluster = 0
|
|
277
|
+
self.size = 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
EXTRA_FILES = [
|
|
281
|
+
# (path-in-image, data) -- what CircuitPython's own formatter creates
|
|
282
|
+
# to keep macOS/Linux from writing trash/index files (filesystem.c).
|
|
283
|
+
(".fseventsd/no_log", b""),
|
|
284
|
+
(".metadata_never_index", b""),
|
|
285
|
+
(".Trashes", b""),
|
|
286
|
+
(".Trash-1000", b""),
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_tree(src, extra_files=True, build_ts=None):
|
|
291
|
+
if build_ts is None:
|
|
292
|
+
build_ts = time.time()
|
|
293
|
+
root = Node("", is_dir=True, ts=build_ts)
|
|
294
|
+
|
|
295
|
+
def get_dir(node, name, ts):
|
|
296
|
+
for c in node.children:
|
|
297
|
+
if c.name == name and c.is_dir:
|
|
298
|
+
return c
|
|
299
|
+
d = Node(name, is_dir=True, ts=ts)
|
|
300
|
+
node.children.append(d)
|
|
301
|
+
return d
|
|
302
|
+
|
|
303
|
+
if extra_files:
|
|
304
|
+
for relpath, data in EXTRA_FILES:
|
|
305
|
+
parts = relpath.split("/")
|
|
306
|
+
cur = root
|
|
307
|
+
for p in parts[:-1]:
|
|
308
|
+
cur = get_dir(cur, p, build_ts)
|
|
309
|
+
cur.children.append(Node(parts[-1], data=data, ts=build_ts))
|
|
310
|
+
|
|
311
|
+
def walk(dirpath, node):
|
|
312
|
+
for entry in sorted(os.listdir(dirpath)):
|
|
313
|
+
if entry in (".DS_Store",):
|
|
314
|
+
continue
|
|
315
|
+
full = os.path.join(dirpath, entry)
|
|
316
|
+
st = os.stat(full)
|
|
317
|
+
if os.path.isdir(full):
|
|
318
|
+
sub = get_dir(node, entry, st.st_mtime)
|
|
319
|
+
sub.ts = st.st_mtime
|
|
320
|
+
walk(full, sub)
|
|
321
|
+
else:
|
|
322
|
+
# user file replaces a synthesized one of the same name
|
|
323
|
+
node.children = [c for c in node.children
|
|
324
|
+
if not (c.name == entry and not c.is_dir)]
|
|
325
|
+
node.children.append(
|
|
326
|
+
Node(entry, path=full, ts=st.st_mtime,
|
|
327
|
+
data=None))
|
|
328
|
+
walk(src, root)
|
|
329
|
+
return root
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class FatBuilder:
|
|
333
|
+
def __init__(self, geo, label="CIRCUITPY", volume_id=None, build_ts=None):
|
|
334
|
+
self.geo = geo
|
|
335
|
+
self.label = label
|
|
336
|
+
self.volume_id = volume_id if volume_id is not None \
|
|
337
|
+
else random.getrandbits(32)
|
|
338
|
+
self.build_ts = build_ts if build_ts is not None else time.time()
|
|
339
|
+
self.image = bytearray(geo.total_sectors * SECTOR)
|
|
340
|
+
self.fat = [0] * (geo.cluster_count + 2)
|
|
341
|
+
self.next_cluster = 2
|
|
342
|
+
|
|
343
|
+
def alloc_chain(self, nbytes):
|
|
344
|
+
"""Allocate a contiguous cluster chain; returns first cluster."""
|
|
345
|
+
n = max(1, (nbytes + self.geo.cluster_bytes - 1)
|
|
346
|
+
// self.geo.cluster_bytes)
|
|
347
|
+
if self.next_cluster + n - 1 > self.geo.cluster_count + 1:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"content does not fit: need %d more clusters" % n)
|
|
350
|
+
first = self.next_cluster
|
|
351
|
+
for i in range(n):
|
|
352
|
+
c = first + i
|
|
353
|
+
self.fat[c] = c + 1 if i < n - 1 else \
|
|
354
|
+
(0xFFF if self.geo.fat_type == 12 else 0xFFFF)
|
|
355
|
+
self.next_cluster += n
|
|
356
|
+
return first
|
|
357
|
+
|
|
358
|
+
def cluster_offset(self, cluster):
|
|
359
|
+
return (self.geo.data_start + (cluster - 2)
|
|
360
|
+
* self.geo.sectors_per_cluster) * SECTOR
|
|
361
|
+
|
|
362
|
+
def write_cluster_data(self, cluster, data):
|
|
363
|
+
off = self.cluster_offset(cluster)
|
|
364
|
+
self.image[off:off + len(data)] = data
|
|
365
|
+
|
|
366
|
+
def entries_for(self, node, used_sfns):
|
|
367
|
+
sfn, needs_lfn = make_sfn(node.name, used_sfns)
|
|
368
|
+
ents = lfn_entries(node.name, sfn) if needs_lfn else []
|
|
369
|
+
attr = ATTR_DIR if node.is_dir else 0
|
|
370
|
+
ents.append(dirent(sfn, attr, node.cluster, node.size, node.ts))
|
|
371
|
+
return ents
|
|
372
|
+
|
|
373
|
+
def layout(self, root):
|
|
374
|
+
# Pass 1: read file data, then allocate clusters depth-first the
|
|
375
|
+
# way FatFs would create them sequentially.
|
|
376
|
+
def prep(node):
|
|
377
|
+
for c in node.children:
|
|
378
|
+
if c.is_dir:
|
|
379
|
+
prep(c)
|
|
380
|
+
else:
|
|
381
|
+
if c.data is None:
|
|
382
|
+
with open(c.path, "rb") as f:
|
|
383
|
+
c.data = f.read()
|
|
384
|
+
c.size = len(c.data)
|
|
385
|
+
|
|
386
|
+
def alloc(node, is_root):
|
|
387
|
+
if not is_root:
|
|
388
|
+
# entry count: . and .. plus children entries (with LFNs)
|
|
389
|
+
count = 2
|
|
390
|
+
used = set()
|
|
391
|
+
for c in node.children:
|
|
392
|
+
_, needs = make_sfn(c.name, used)
|
|
393
|
+
count += 1 + (len(lfn_entries(c.name, b"x" * 11))
|
|
394
|
+
if needs else 0)
|
|
395
|
+
node.size = 0
|
|
396
|
+
node.cluster = self.alloc_chain(count * 32)
|
|
397
|
+
for c in node.children:
|
|
398
|
+
if c.is_dir:
|
|
399
|
+
alloc(c, False)
|
|
400
|
+
for c in node.children:
|
|
401
|
+
if not c.is_dir:
|
|
402
|
+
c.cluster = self.alloc_chain(c.size) if c.size else 0
|
|
403
|
+
if c.size:
|
|
404
|
+
self.write_cluster_data(c.cluster, c.data)
|
|
405
|
+
prep(root)
|
|
406
|
+
alloc(root, True)
|
|
407
|
+
|
|
408
|
+
# Pass 2: emit directory entries.
|
|
409
|
+
def emit(node, parent_cluster, is_root):
|
|
410
|
+
used = set()
|
|
411
|
+
out = b""
|
|
412
|
+
if is_root:
|
|
413
|
+
d, tm = fat_datetime(self.build_ts)
|
|
414
|
+
out += struct.pack(
|
|
415
|
+
"<11sBBBHHHHHHHI",
|
|
416
|
+
self.label.upper().ljust(11)[:11].encode("ascii"),
|
|
417
|
+
ATTR_VOLUME, 0, 0, tm, d, d, 0, tm, d, 0, 0)
|
|
418
|
+
else:
|
|
419
|
+
out += dirent(b". ", ATTR_DIR, node.cluster, 0,
|
|
420
|
+
node.ts)
|
|
421
|
+
out += dirent(b".. ", ATTR_DIR,
|
|
422
|
+
parent_cluster if parent_cluster != 0 else 0,
|
|
423
|
+
0, node.ts)
|
|
424
|
+
for c in node.children:
|
|
425
|
+
out += b"".join(self.entries_for(c, used))
|
|
426
|
+
if is_root:
|
|
427
|
+
cap = self.geo.root_entries * 32
|
|
428
|
+
if len(out) > cap:
|
|
429
|
+
raise ValueError("too many entries in root directory")
|
|
430
|
+
off = self.geo.reserved * SECTOR \
|
|
431
|
+
+ self.geo.fat_sectors * SECTOR
|
|
432
|
+
self.image[off:off + len(out)] = out
|
|
433
|
+
else:
|
|
434
|
+
self.write_cluster_data(node.cluster, out)
|
|
435
|
+
for c in node.children:
|
|
436
|
+
if c.is_dir:
|
|
437
|
+
emit(c, 0 if is_root else node.cluster, False)
|
|
438
|
+
emit(root, 0, True)
|
|
439
|
+
self.write_boot_sector()
|
|
440
|
+
self.write_fat()
|
|
441
|
+
|
|
442
|
+
def write_boot_sector(self):
|
|
443
|
+
g = self.geo
|
|
444
|
+
b = bytearray(SECTOR)
|
|
445
|
+
b[0:3] = b"\xEB\xFE\x90"
|
|
446
|
+
b[3:11] = b"MSDOS5.0"
|
|
447
|
+
struct.pack_into("<H", b, 11, SECTOR) # bytes/sector
|
|
448
|
+
b[13] = g.sectors_per_cluster
|
|
449
|
+
struct.pack_into("<H", b, 14, g.reserved) # reserved sectors
|
|
450
|
+
b[16] = 1 # number of FATs
|
|
451
|
+
struct.pack_into("<H", b, 17, g.root_entries)
|
|
452
|
+
if g.total_sectors < 0x10000:
|
|
453
|
+
struct.pack_into("<H", b, 19, g.total_sectors)
|
|
454
|
+
else:
|
|
455
|
+
struct.pack_into("<I", b, 32, g.total_sectors)
|
|
456
|
+
b[21] = 0xF8 # media descriptor
|
|
457
|
+
struct.pack_into("<H", b, 22, g.fat_sectors)
|
|
458
|
+
struct.pack_into("<H", b, 24, 63) # sectors/track
|
|
459
|
+
struct.pack_into("<H", b, 26, 255) # heads
|
|
460
|
+
# BPB_HiddSec: volume begins at LBA 1 of CircuitPython's virtual
|
|
461
|
+
# MBR device (supervisor/shared/flash.c PART1_START_BLOCK).
|
|
462
|
+
struct.pack_into("<I", b, 28, 1)
|
|
463
|
+
b[36] = 0x80 # drive number
|
|
464
|
+
b[38] = 0x29 # ext boot signature
|
|
465
|
+
struct.pack_into("<I", b, 39, self.volume_id)
|
|
466
|
+
# oofatfs f_mkfs leaves the BPB label as NO NAME; the real label
|
|
467
|
+
# lives in the root directory entry (f_setlabel behavior).
|
|
468
|
+
# oofatfs writes literally "FAT " here for FAT12 and FAT16
|
|
469
|
+
b[43:62] = b"NO NAME " + b"FAT "
|
|
470
|
+
b[510] = 0x55
|
|
471
|
+
b[511] = 0xAA
|
|
472
|
+
self.image[0:SECTOR] = b
|
|
473
|
+
|
|
474
|
+
def write_fat(self):
|
|
475
|
+
g = self.geo
|
|
476
|
+
self.fat[0] = 0xF8 | (0xF00 if g.fat_type == 12 else 0xFF00)
|
|
477
|
+
self.fat[1] = 0xFFF if g.fat_type == 12 else 0xFFFF
|
|
478
|
+
raw = bytearray(g.fat_sectors * SECTOR)
|
|
479
|
+
if g.fat_type == 16:
|
|
480
|
+
for i, v in enumerate(self.fat):
|
|
481
|
+
struct.pack_into("<H", raw, i * 2, v & 0xFFFF)
|
|
482
|
+
else:
|
|
483
|
+
for i, v in enumerate(self.fat):
|
|
484
|
+
off = i * 3 // 2
|
|
485
|
+
if i % 2 == 0:
|
|
486
|
+
raw[off] = v & 0xFF
|
|
487
|
+
raw[off + 1] = (raw[off + 1] & 0xF0) | ((v >> 8) & 0x0F)
|
|
488
|
+
else:
|
|
489
|
+
raw[off] = (raw[off] & 0x0F) | ((v << 4) & 0xF0)
|
|
490
|
+
raw[off + 1] = (v >> 4) & 0xFF
|
|
491
|
+
off = g.reserved * SECTOR
|
|
492
|
+
self.image[off:off + len(raw)] = raw
|
|
493
|
+
|
|
494
|
+
def used_bytes(self):
|
|
495
|
+
"""Bytes of image actually meaningful (metadata + allocated
|
|
496
|
+
clusters), for trimmed UF2 output."""
|
|
497
|
+
end_cluster = self.next_cluster
|
|
498
|
+
off = self.cluster_offset(end_cluster - 1) + self.geo.cluster_bytes \
|
|
499
|
+
if end_cluster > 2 else self.geo.data_start * SECTOR
|
|
500
|
+
return off
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def image_blocks(image, target_addr, trim_to=None):
|
|
504
|
+
"""Split an image into (address, payload) UF2 block payloads."""
|
|
505
|
+
data = image if trim_to is None else image[:trim_to]
|
|
506
|
+
# pad to erase-sector (4K) boundary so the bootloader erases/writes
|
|
507
|
+
# whole sectors deterministically
|
|
508
|
+
pad = (-len(data)) % 4096
|
|
509
|
+
data = bytes(data) + b"\xff" * pad
|
|
510
|
+
n = (len(data) + UF2_PAYLOAD - 1) // UF2_PAYLOAD
|
|
511
|
+
return [(target_addr + i * UF2_PAYLOAD,
|
|
512
|
+
data[i * UF2_PAYLOAD:(i + 1) * UF2_PAYLOAD]) for i in range(n)]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def read_uf2(path):
|
|
516
|
+
"""Return (blocks, family_id) from an existing UF2 file."""
|
|
517
|
+
raw = open(path, "rb").read()
|
|
518
|
+
if len(raw) % SECTOR:
|
|
519
|
+
raise ValueError("%s is not a whole number of 512-byte UF2 blocks"
|
|
520
|
+
% path)
|
|
521
|
+
blocks = []
|
|
522
|
+
family = None
|
|
523
|
+
for i in range(len(raw) // SECTOR):
|
|
524
|
+
b = raw[i * SECTOR:(i + 1) * SECTOR]
|
|
525
|
+
m0, m1, flags, addr, size, _no, _total, fam = struct.unpack(
|
|
526
|
+
"<IIIIIIII", b[:32])
|
|
527
|
+
if m0 != UF2_MAGIC0 or m1 != UF2_MAGIC1:
|
|
528
|
+
raise ValueError("%s: block %d has a bad UF2 magic" % (path, i))
|
|
529
|
+
if flags & UF2_FLAG_FAMILY_ID:
|
|
530
|
+
if family is not None and fam != family:
|
|
531
|
+
raise ValueError("%s mixes family ids 0x%08x and 0x%08x"
|
|
532
|
+
% (path, family, fam))
|
|
533
|
+
family = fam
|
|
534
|
+
blocks.append((addr, b[32:32 + size]))
|
|
535
|
+
return blocks, family
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def write_uf2_blocks(path, blocks, family_id):
|
|
539
|
+
"""Write blocks as one UF2, numbering them across the whole file."""
|
|
540
|
+
total = len(blocks)
|
|
541
|
+
with open(path, "wb") as f:
|
|
542
|
+
for i, (addr, chunk) in enumerate(blocks):
|
|
543
|
+
head = struct.pack(
|
|
544
|
+
"<IIIIIIII",
|
|
545
|
+
UF2_MAGIC0, UF2_MAGIC1, UF2_FLAG_FAMILY_ID,
|
|
546
|
+
addr, len(chunk), i, total, family_id)
|
|
547
|
+
f.write(head + chunk.ljust(476, b"\x00")
|
|
548
|
+
+ struct.pack("<I", UF2_MAGIC_END))
|
|
549
|
+
return total
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def write_uf2(path, image, target_addr, family_id, trim_to=None):
|
|
553
|
+
return write_uf2_blocks(
|
|
554
|
+
path, image_blocks(image, target_addr, trim_to), family_id)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def parse_size(s):
|
|
558
|
+
return int(s, 0)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def main(argv=None):
|
|
562
|
+
p = argparse.ArgumentParser(
|
|
563
|
+
prog="folder2uf2",
|
|
564
|
+
description="Pack a folder into a CIRCUITPY FAT filesystem UF2 "
|
|
565
|
+
"for RP2040/RP2350 CircuitPython boards.")
|
|
566
|
+
p.add_argument("source", nargs="?", help="folder to pack")
|
|
567
|
+
p.add_argument("-o", "--output", help="output UF2 path (default: "
|
|
568
|
+
"<source>.uf2)")
|
|
569
|
+
p.add_argument("--board", choices=sorted(BOARDS) + sorted(ESP_PARTITIONS),
|
|
570
|
+
help="board name (sets flash offset, size, family id). "
|
|
571
|
+
"esp32_* targets write a raw image for esptool, not a UF2")
|
|
572
|
+
p.add_argument("--combine", metavar="FIRMWARE.UF2",
|
|
573
|
+
help="prepend a firmware UF2 so one file flashes both "
|
|
574
|
+
"firmware and filesystem")
|
|
575
|
+
p.add_argument("--flash-offset", type=parse_size,
|
|
576
|
+
help="CIRCUITPY drive start offset in flash (e.g. "
|
|
577
|
+
"0x100000)")
|
|
578
|
+
p.add_argument("--flash-size", type=parse_size,
|
|
579
|
+
help="total flash size (drive runs to end of flash)")
|
|
580
|
+
p.add_argument("--fs-size", type=parse_size,
|
|
581
|
+
help="filesystem size in bytes (overrides "
|
|
582
|
+
"flash-size - flash-offset)")
|
|
583
|
+
p.add_argument("--family-id", type=parse_size,
|
|
584
|
+
help="UF2 family id (default from --board)")
|
|
585
|
+
p.add_argument("--base-addr", type=parse_size, default=XIP_BASE,
|
|
586
|
+
help="flash XIP base address (default 0x10000000)")
|
|
587
|
+
p.add_argument("--label", default="CIRCUITPY",
|
|
588
|
+
help="volume label (default CIRCUITPY)")
|
|
589
|
+
p.add_argument("--volume-id", type=parse_size,
|
|
590
|
+
help="FAT volume serial number (default random)")
|
|
591
|
+
p.add_argument("--full", action="store_true",
|
|
592
|
+
help="write the entire filesystem region instead of "
|
|
593
|
+
"only the used portion (wipes residual data; larger "
|
|
594
|
+
"UF2)")
|
|
595
|
+
p.add_argument("--no-extra-files", action="store_true",
|
|
596
|
+
help="skip the .fseventsd/.metadata_never_index/"
|
|
597
|
+
".Trashes files CircuitPython normally creates")
|
|
598
|
+
p.add_argument("--partition-table", metavar="PT.BIN",
|
|
599
|
+
help="ESP32: derive offset and size from a partition table "
|
|
600
|
+
"read off the chip at 0x8000")
|
|
601
|
+
p.add_argument("--storage-extend", action="store_true",
|
|
602
|
+
help="ESP32: extend the filesystem into the spare OTA "
|
|
603
|
+
"partition, matching CIRCUITPY_STORAGE_EXTEND. Requires "
|
|
604
|
+
"--partition-table")
|
|
605
|
+
p.add_argument("--img-out", help="also write the raw FAT image here")
|
|
606
|
+
p.add_argument("--self-extract", action="store_true",
|
|
607
|
+
help="emit a single self-extracting code.py instead of an "
|
|
608
|
+
"image. Drag it onto CIRCUITPY and it unpacks itself. "
|
|
609
|
+
"Works on any port, no bootloader or esptool needed")
|
|
610
|
+
p.add_argument("--list-boards", action="store_true",
|
|
611
|
+
help="list built-in boards and exit")
|
|
612
|
+
args = p.parse_args(argv)
|
|
613
|
+
|
|
614
|
+
if args.list_boards:
|
|
615
|
+
for name, (fam, off, tot) in sorted(BOARDS.items()):
|
|
616
|
+
print("%-28s family=0x%08x offset=0x%06x fs_size=%.1fMB"
|
|
617
|
+
% (name, fam, off, (tot - off) / 1e6))
|
|
618
|
+
for name, (off, size) in sorted(ESP_PARTITIONS.items()):
|
|
619
|
+
print("%-28s esptool offset=0x%06x fs_size=%.1fMB"
|
|
620
|
+
% (name, off, size / 1e6))
|
|
621
|
+
return 0
|
|
622
|
+
|
|
623
|
+
if args.self_extract:
|
|
624
|
+
from . import selfextract
|
|
625
|
+
text, raw, biggest = selfextract.build(args.source)
|
|
626
|
+
out = args.output or "code.py"
|
|
627
|
+
with open(out, "w") as f:
|
|
628
|
+
f.write(text)
|
|
629
|
+
print("%s: %d files, %d bytes of source packed into %d bytes"
|
|
630
|
+
% (out, text.count("\n#>"), raw, len(text)))
|
|
631
|
+
print("largest single file %d bytes, which bounds RAM on the board"
|
|
632
|
+
% biggest)
|
|
633
|
+
print("drag onto CIRCUITPY; it unpacks and reboots")
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
if not args.source:
|
|
637
|
+
p.error("source folder is required")
|
|
638
|
+
if not os.path.isdir(args.source):
|
|
639
|
+
p.error("source %r is not a directory" % args.source)
|
|
640
|
+
|
|
641
|
+
esp = args.board in ESP_PARTITIONS if args.board else False
|
|
642
|
+
if esp:
|
|
643
|
+
if args.combine:
|
|
644
|
+
p.error("--combine is UF2 only; ESP32 images are flashed with "
|
|
645
|
+
"esptool")
|
|
646
|
+
offset, part_size = ESP_PARTITIONS[args.board]
|
|
647
|
+
ota_size = 0
|
|
648
|
+
if args.partition_table:
|
|
649
|
+
offset, part_size, ota_size = parse_partition_table(
|
|
650
|
+
args.partition_table)
|
|
651
|
+
fs_size = part_size
|
|
652
|
+
if args.storage_extend:
|
|
653
|
+
if not args.partition_table:
|
|
654
|
+
p.error("--storage-extend needs --partition-table, since the "
|
|
655
|
+
"spare OTA partition size varies by board")
|
|
656
|
+
if not ota_size:
|
|
657
|
+
p.error("this partition table has no spare OTA slot, so "
|
|
658
|
+
"storage cannot extend")
|
|
659
|
+
fs_size = part_size + ota_size
|
|
660
|
+
if args.fs_size is not None:
|
|
661
|
+
fs_size = args.fs_size
|
|
662
|
+
geo = Geometry(fs_size // SECTOR)
|
|
663
|
+
root = build_tree(args.source, extra_files=not args.no_extra_files)
|
|
664
|
+
fb = FatBuilder(geo, label=args.label, volume_id=args.volume_id)
|
|
665
|
+
fb.layout(root)
|
|
666
|
+
out = args.output or os.path.basename(
|
|
667
|
+
os.path.abspath(args.source)) + ".bin"
|
|
668
|
+
data = fb.image if args.full else fb.image[:fb.used_bytes()]
|
|
669
|
+
with open(out, "wb") as f:
|
|
670
|
+
f.write(data)
|
|
671
|
+
print("%s: FAT%d, %d bytes, volume %dK at offset 0x%06x%s"
|
|
672
|
+
% (out, geo.fat_type, len(data), fs_size // 1024, offset,
|
|
673
|
+
" (storage extended)" if args.storage_extend else ""))
|
|
674
|
+
print("flash with: esptool --before no-reset --after no-reset "
|
|
675
|
+
"write-flash 0x%06x %s" % (offset, out))
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
family = args.family_id
|
|
679
|
+
offset = args.flash_offset
|
|
680
|
+
fs_size = args.fs_size
|
|
681
|
+
if args.board:
|
|
682
|
+
bfam, boff, btot = BOARDS[args.board]
|
|
683
|
+
family = family if family is not None else bfam
|
|
684
|
+
offset = offset if offset is not None else boff
|
|
685
|
+
if fs_size is None:
|
|
686
|
+
fs_size = (args.flash_size or btot) - offset
|
|
687
|
+
elif fs_size is None and args.flash_size is not None \
|
|
688
|
+
and offset is not None:
|
|
689
|
+
fs_size = args.flash_size - offset
|
|
690
|
+
if family is None or offset is None or fs_size is None:
|
|
691
|
+
p.error("need --board, or --flash-offset with --flash-size/"
|
|
692
|
+
"--fs-size and --family-id")
|
|
693
|
+
if fs_size <= 0 or fs_size % 4096:
|
|
694
|
+
p.error("filesystem size must be positive and 4K aligned")
|
|
695
|
+
if offset % 4096:
|
|
696
|
+
p.error("flash offset must be 4K aligned")
|
|
697
|
+
|
|
698
|
+
geo = Geometry(fs_size // SECTOR)
|
|
699
|
+
root = build_tree(args.source, extra_files=not args.no_extra_files)
|
|
700
|
+
fb = FatBuilder(geo, label=args.label, volume_id=args.volume_id)
|
|
701
|
+
fb.layout(root)
|
|
702
|
+
|
|
703
|
+
out = args.output or os.path.basename(
|
|
704
|
+
os.path.abspath(args.source)) + ".uf2"
|
|
705
|
+
trim = None if args.full else fb.used_bytes()
|
|
706
|
+
fs_addr = args.base_addr + offset
|
|
707
|
+
blocks = image_blocks(fb.image, fs_addr, trim_to=trim)
|
|
708
|
+
|
|
709
|
+
if args.combine:
|
|
710
|
+
fw_blocks, fw_family = read_uf2(args.combine)
|
|
711
|
+
if fw_family is not None and fw_family != family:
|
|
712
|
+
p.error("firmware family 0x%08x does not match 0x%08x; wrong "
|
|
713
|
+
"board?" % (fw_family, family))
|
|
714
|
+
fw_end = max(a + len(c) for a, c in fw_blocks)
|
|
715
|
+
if fw_end > fs_addr:
|
|
716
|
+
p.error("firmware reaches 0x%08x, past the filesystem start "
|
|
717
|
+
"0x%08x" % (fw_end, fs_addr))
|
|
718
|
+
blocks = fw_blocks + blocks
|
|
719
|
+
print("combined: %d firmware blocks + %d filesystem blocks"
|
|
720
|
+
% (len(fw_blocks), len(blocks) - len(fw_blocks)))
|
|
721
|
+
nblocks = write_uf2_blocks(out, blocks, family)
|
|
722
|
+
if args.img_out:
|
|
723
|
+
with open(args.img_out, "wb") as f:
|
|
724
|
+
f.write(fb.image)
|
|
725
|
+
used_kb = fb.used_bytes() / 1024
|
|
726
|
+
print("%s: FAT%d, %d sectors/cluster, %d clusters, %.0fKB used of "
|
|
727
|
+
"%.0fKB" % (out, geo.fat_type, geo.sectors_per_cluster,
|
|
728
|
+
geo.cluster_count, used_kb, fs_size / 1024))
|
|
729
|
+
lo = min(a for a, _ in blocks)
|
|
730
|
+
hi = max(a + len(c) for a, c in blocks)
|
|
731
|
+
print("UF2: %d blocks, flash 0x%08x..0x%08x, family 0x%08x"
|
|
732
|
+
% (nblocks, lo, hi, family))
|
|
733
|
+
return 0
|
|
734
|
+
|
folder2uf2/__main__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Generate a self-extracting CIRCUITPY installer as a single code.py.
|
|
2
|
+
|
|
3
|
+
The payload rides in trailing comment lines. CircuitPython's lexer reads
|
|
4
|
+
through a 255-byte buffer and discards comments without allocating, so the
|
|
5
|
+
file compiles cheaply however large the payload is. At run time the installer
|
|
6
|
+
reopens /code.py, walks past the header and inflates one member at a time, so
|
|
7
|
+
peak RAM is the largest single file rather than the whole tree.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import os
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
MARKER = "# ---8<--- folder2uf2 payload, do not edit below this line"
|
|
15
|
+
|
|
16
|
+
STUB = '''\
|
|
17
|
+
# Self-extracting CircuitPython installer, generated by folder2uf2.
|
|
18
|
+
# Drag this onto CIRCUITPY. It unpacks itself and reboots.
|
|
19
|
+
import binascii
|
|
20
|
+
import microcontroller
|
|
21
|
+
import os
|
|
22
|
+
import storage
|
|
23
|
+
import time
|
|
24
|
+
import zlib
|
|
25
|
+
|
|
26
|
+
MARKER = "{marker}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mkdirs(path):
|
|
30
|
+
parts = path.split("/")[:-1]
|
|
31
|
+
grown = ""
|
|
32
|
+
for part in parts:
|
|
33
|
+
if not part:
|
|
34
|
+
continue
|
|
35
|
+
grown += "/" + part
|
|
36
|
+
try:
|
|
37
|
+
os.mkdir(grown)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _members():
|
|
43
|
+
"""Yield (path, data) one at a time, holding only one member in RAM.
|
|
44
|
+
|
|
45
|
+
Base64 is decoded line by line into a bytearray. Joining the lines into
|
|
46
|
+
one string first costs an extra 4/3 of the compressed size, which
|
|
47
|
+
overflows a 264KB RP2040 well before the decompression itself would.
|
|
48
|
+
"""
|
|
49
|
+
with open("/code.py", "r") as f:
|
|
50
|
+
for line in f:
|
|
51
|
+
if line.rstrip("\\n") == MARKER:
|
|
52
|
+
break
|
|
53
|
+
path = None
|
|
54
|
+
buf = None
|
|
55
|
+
pos = 0
|
|
56
|
+
for line in f:
|
|
57
|
+
line = line.rstrip("\\n")
|
|
58
|
+
if not line.startswith("#"):
|
|
59
|
+
continue
|
|
60
|
+
body = line[1:]
|
|
61
|
+
if body.startswith(">"):
|
|
62
|
+
if path is not None:
|
|
63
|
+
# memoryview slice, so the exact length is passed
|
|
64
|
+
# without copying the buffer
|
|
65
|
+
yield path, zlib.decompress(memoryview(buf)[:pos])
|
|
66
|
+
n, _, path = body[1:].partition("|")
|
|
67
|
+
# preallocate: growing a bytearray reallocates and the spike
|
|
68
|
+
# is what overflows a 264KB RP2040
|
|
69
|
+
buf = bytearray((int(n) * 3) // 4 + 3)
|
|
70
|
+
pos = 0
|
|
71
|
+
elif path is not None:
|
|
72
|
+
chunk = binascii.a2b_base64(body)
|
|
73
|
+
buf[pos:pos + len(chunk)] = chunk
|
|
74
|
+
pos += len(chunk)
|
|
75
|
+
if path is not None:
|
|
76
|
+
yield path, zlib.decompress(memoryview(buf)[:pos])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
print("folder2uf2 installer: unpacking")
|
|
80
|
+
# Let the host flush anything still cached before the drive disappears.
|
|
81
|
+
time.sleep({settle})
|
|
82
|
+
storage.unsafe_disable_usb_drive()
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
deferred = None
|
|
86
|
+
count = 0
|
|
87
|
+
for path, data in _members():
|
|
88
|
+
if path == "code.py":
|
|
89
|
+
deferred = data
|
|
90
|
+
continue
|
|
91
|
+
_mkdirs(path)
|
|
92
|
+
with open("/" + path, "wb") as out:
|
|
93
|
+
out.write(data)
|
|
94
|
+
count += 1
|
|
95
|
+
print(" " + path)
|
|
96
|
+
|
|
97
|
+
# code.py last: until it lands, a crash leaves the installer rerunnable.
|
|
98
|
+
if deferred is not None:
|
|
99
|
+
with open("/code.py.new", "wb") as out:
|
|
100
|
+
out.write(deferred)
|
|
101
|
+
os.rename("/code.py.new", "/code.py")
|
|
102
|
+
count += 1
|
|
103
|
+
except Exception as e: # noqa: BLE001 - must not strand the user
|
|
104
|
+
# Give the drive back, or CIRCUITPY stays hidden and the installer
|
|
105
|
+
# reruns and fails on every boot, locking the user out entirely.
|
|
106
|
+
print("install failed:", e)
|
|
107
|
+
print("re-enabling CIRCUITPY so you can remove this file")
|
|
108
|
+
storage.enable_usb_drive()
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
print("unpacked %d files, resetting" % count)
|
|
112
|
+
time.sleep(0.5)
|
|
113
|
+
# Hard reset, not supervisor.reload(): a soft reload leaves the USB drive
|
|
114
|
+
# disabled, so CIRCUITPY never comes back for the user.
|
|
115
|
+
microcontroller.reset()
|
|
116
|
+
'''
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build(src, settle=3.0, wrap=120):
|
|
120
|
+
"""Return the text of a self-extracting code.py for the tree at src."""
|
|
121
|
+
out = [STUB.format(marker=MARKER, settle=settle), MARKER]
|
|
122
|
+
total_raw = 0
|
|
123
|
+
biggest = 0
|
|
124
|
+
for path in sorted(_walk(src)):
|
|
125
|
+
full = os.path.join(src, path)
|
|
126
|
+
raw = open(full, "rb").read()
|
|
127
|
+
total_raw += len(raw)
|
|
128
|
+
biggest = max(biggest, len(raw))
|
|
129
|
+
blob = base64.b64encode(zlib.compress(raw, 9)).decode("ascii")
|
|
130
|
+
out.append("#>%d|%s" % (len(blob), path.replace(os.sep, "/")))
|
|
131
|
+
for i in range(0, len(blob), wrap):
|
|
132
|
+
out.append("#" + blob[i:i + wrap])
|
|
133
|
+
return "\n".join(out) + "\n", total_raw, biggest
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _walk(src):
|
|
137
|
+
for root, dirs, files in os.walk(src):
|
|
138
|
+
dirs[:] = [d for d in dirs
|
|
139
|
+
if d not in (".fseventsd", ".Trashes", "__pycache__")]
|
|
140
|
+
for name in files:
|
|
141
|
+
if name.startswith("._") or name in (
|
|
142
|
+
".metadata_never_index", ".Trash-1000", "boot_out.txt"):
|
|
143
|
+
continue
|
|
144
|
+
yield os.path.relpath(os.path.join(root, name), src)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: folder2uf2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ship a CircuitPython project as one file: a self-extracting code.py, a UF2 for RP2, or an image for ESP32
|
|
5
|
+
Author-email: Mikey Sklar <mikeysklar@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mikeysklar/folder2uf2
|
|
8
|
+
Project-URL: Issues, https://github.com/mikeysklar/folder2uf2/issues
|
|
9
|
+
Keywords: circuitpython,uf2,rp2040,rp2350,esp32,adafruit,fat
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
16
|
+
Classifier: Topic :: System :: Hardware
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# folder2uf2
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
Ships a CircuitPython project as one file, so customers copy once.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install folder2uf2
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Python 3.8 or newer, standard library only. No dependencies.
|
|
37
|
+
|
|
38
|
+
## Three ways to ship
|
|
39
|
+
|
|
40
|
+
| Output | Goes onto | Board must be |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `code.py` | `CIRCUITPY` | running CircuitPython |
|
|
43
|
+
| `.uf2` | `RPI-RP2`, `RP2350` | in the UF2 bootloader |
|
|
44
|
+
| `.bin` | flashed by esptool | ESP32, any state |
|
|
45
|
+
|
|
46
|
+
## Self-extract: one file, no bootloader
|
|
47
|
+
|
|
48
|
+
Pack the project. Output must be `code.py`, the name CircuitPython runs.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
folder2uf2 --self-extract -o code.py myproject/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### What your customer does
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
1. drag code.py onto CIRCUITPY
|
|
58
|
+
2. CIRCUITPY disappears for about 4 seconds
|
|
59
|
+
3. board reboots with code.py, lib/ and assets in place
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Works on every port, RP2 and ESP32 alike.
|
|
63
|
+
|
|
64
|
+
### If it fails
|
|
65
|
+
|
|
66
|
+
The drive is handed back with an error. Nobody gets locked out.
|
|
67
|
+
|
|
68
|
+
### How it works
|
|
69
|
+
|
|
70
|
+
`storage.unsafe_disable_usb_drive()` makes CIRCUITPY writable from `code.py`.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# payload rides in trailing comments, inflated one file at a time
|
|
74
|
+
# peak RAM is the largest single file, not the whole tree
|
|
75
|
+
# remount() cannot: MSC holds the block device lock while mounted
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Limits
|
|
79
|
+
|
|
80
|
+
Needs CircuitPython installed already. CIRCUITPY vanishes while it runs.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
zlib required: default wherever CIRCUITPY_FULL_BUILD is set
|
|
84
|
+
60KB incompressible single file verified on a 264KB RP2040
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## UF2, for RP2 boards
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
# filesystem only
|
|
91
|
+
folder2uf2 --board adafruit_metro_rp2350 -o fs.uf2 myproject/
|
|
92
|
+
|
|
93
|
+
# firmware and filesystem together
|
|
94
|
+
folder2uf2 --board adafruit_metro_rp2350 \
|
|
95
|
+
--combine firmware.uf2 -o product.uf2 myproject/
|
|
96
|
+
|
|
97
|
+
# wipe residual data rather than writing only used sectors
|
|
98
|
+
folder2uf2 --board adafruit_metro_rp2350 --full -o fs.uf2 myproject/
|
|
99
|
+
|
|
100
|
+
# a board with no built-in profile
|
|
101
|
+
folder2uf2 --flash-offset 0x100000 --flash-size 0x1000000 \
|
|
102
|
+
--family-id 0xe48bff59 -o fs.uf2 myproject/
|
|
103
|
+
|
|
104
|
+
folder2uf2 --list-boards
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## ESP32
|
|
108
|
+
|
|
109
|
+
tinyuf2 writes only `ota_0`, so ESP32 gets a raw image for esptool.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
folder2uf2 --board esp32_16mb -o fs.bin myproject/
|
|
113
|
+
esptool --before no-reset --after no-reset write-flash 0x450000 fs.bin
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Storage extend
|
|
117
|
+
|
|
118
|
+
Layouts differ, so read the table off your chip.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
esptool --before no-reset --after no-reset read-flash 0x8000 0xc00 pt.bin
|
|
122
|
+
folder2uf2 --board esp32_16mb --partition-table pt.bin --storage-extend -o fs.bin src/
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```c
|
|
126
|
+
storage_extended = (_partition[0]->size < fatfs_bytes());
|
|
127
|
+
/* S3 16MB has that slot: 28032 sectors. S2 4MB does not: 1920. */
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Download mode
|
|
131
|
+
|
|
132
|
+
One-shot: one esptool run per entry, then power cycle.
|
|
133
|
+
|
|
134
|
+
### Bootloader
|
|
135
|
+
|
|
136
|
+
Install the tinyuf2 bootloader first, per your board's learn guide.
|
|
137
|
+
|
|
138
|
+
### Browser instead of esptool
|
|
139
|
+
|
|
140
|
+
Takes a file and an offset, so customers need no Python.
|
|
141
|
+
|
|
142
|
+
<https://adafruit.github.io/Adafruit_WebSerial_ESPTool/>
|
|
143
|
+
|
|
144
|
+
## How it was tested
|
|
145
|
+
|
|
146
|
+
### Self-extract, three boards
|
|
147
|
+
|
|
148
|
+
RP2040 is the tight one.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2040 with rp2040
|
|
152
|
+
Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2350 with rp2350b
|
|
153
|
+
Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro ESP32S3 with ESP32S3
|
|
154
|
+
|
|
155
|
+
60144 bytes of source packed into 84356 bytes
|
|
156
|
+
assets.bin 60000 bytes incompressible, md5 matched
|
|
157
|
+
lib/mypkg/__init__.py created, nested dir
|
|
158
|
+
code.py replaced by the product, printed its marker
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Drive returned unaided. All boards restored, identical.
|
|
162
|
+
|
|
163
|
+
### What the RP2040 taught
|
|
164
|
+
|
|
165
|
+
Two MemoryErrors before it fit, both invisible on roomier chips.
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
"".join(chunks) -> allocating 80037 bytes, failed
|
|
169
|
+
growing a bytearray -> allocating 49680 bytes, failed
|
|
170
|
+
preallocate + memoryview -> fits
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Combine, Metro RP2350
|
|
174
|
+
|
|
175
|
+
Before, then after one UF2 carrying the 10.3.0 release.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
Adafruit CircuitPython 10.3.0-alpha.4 on 2026-07-23; Adafruit Metro RP2350 with rp2350b
|
|
179
|
+
|
|
180
|
+
Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2350 with rp2350b
|
|
181
|
+
code.py output:
|
|
182
|
+
FOLDER2UF2-COMBINE-OK marker 8f3a21
|
|
183
|
+
lib import works
|
|
184
|
+
|
|
185
|
+
restored by a second combined UF2, 108 files identical
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### ESP32 image, Metro ESP32-S3
|
|
189
|
+
|
|
190
|
+
Partition table read from the chip, not assumed.
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
ffat data fat 0x450000 0xBB0000 11968K
|
|
194
|
+
wrote 43520 bytes, hash verified
|
|
195
|
+
volume 28032 sectors, files intact
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Safety
|
|
199
|
+
|
|
200
|
+
Firmware is untouched unless you pass `--combine`.
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
--self-extract writes only your files, leaving others alone
|
|
204
|
+
an ESP32 image sized wrong overflows, so read the table
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Credit
|
|
208
|
+
|
|
209
|
+
Idea from jepler's archived mkfatimg. Thanks @todbot for the requests.
|
|
210
|
+
|
|
211
|
+
https://github.com/adafruit/circuitpython/issues/10074
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
folder2uf2/__init__.py,sha256=Ayo_F7OL2KYgIH8RArVZFtUSS8_syf-MDU2cLSgR8QQ,29550
|
|
2
|
+
folder2uf2/__main__.py,sha256=AHgGk36Tizo_gBV9SSVbJ0sf7uhvbsdz-oLVjSZRiug,147
|
|
3
|
+
folder2uf2/selfextract.py,sha256=vJk1-oItWUmKZObxnU2RnJM_Rs4q_WlThx2nafAMPlM,4861
|
|
4
|
+
folder2uf2-0.1.0.dist-info/licenses/LICENSE,sha256=tR02FHnMCboe54oDaDzBViSWiaZgMKIY_gEz_jWCmdQ,1068
|
|
5
|
+
folder2uf2-0.1.0.dist-info/METADATA,sha256=c7aLhfANqgRa89nygL9sCq3SdLvVzH21bID9R4Y7sRo,5570
|
|
6
|
+
folder2uf2-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
folder2uf2-0.1.0.dist-info/entry_points.txt,sha256=2hbw76I9sN2WxNlnOH0AL0fERMPKha04aZy8U9YL3fM,47
|
|
8
|
+
folder2uf2-0.1.0.dist-info/top_level.txt,sha256=Ca-9jN_5ExTFOxdElraX5x9JpaZBzyyURc-QxKohDD8,11
|
|
9
|
+
folder2uf2-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikey Sklar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
folder2uf2
|