micropython-microbit-fs 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.
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Filesystem reading and writing functionality.
4
+
5
+ This module provides functions to read and write files to the MicroPython
6
+ filesystem embedded in an Intel Hex file.
7
+ """
8
+
9
+ from intelhex import IntelHex
10
+
11
+ from micropython_microbit_fs.device_info import DeviceInfo, DeviceVersion
12
+ from micropython_microbit_fs.exceptions import (
13
+ FilesystemError,
14
+ InvalidFileError,
15
+ StorageFullError,
16
+ )
17
+
18
+ # =============================================================================
19
+ # Chunk Constants
20
+ # =============================================================================
21
+
22
+ CHUNK_SIZE = 128
23
+ """Size of a filesystem chunk in bytes."""
24
+
25
+ CHUNK_DATA_SIZE = 126
26
+ """Size of data that can be stored in a chunk (128 - 1 marker - 1 tail)."""
27
+
28
+ CHUNK_MARKER_SIZE = 1
29
+ """Size of the chunk marker byte."""
30
+
31
+ MAX_CHUNKS = 252
32
+ """Maximum number of chunks (256 - 4 reserved markers)."""
33
+
34
+ MAX_FILENAME_LENGTH = 120
35
+ """Maximum length of a filename in bytes."""
36
+
37
+
38
+ class ChunkMarker:
39
+ """Chunk marker byte values."""
40
+
41
+ FREED = 0x00
42
+ """Chunk has been freed but not erased."""
43
+
44
+ PERSISTENT_DATA = 0xFD
45
+ """Persistent data page marker (first byte of last FS page)."""
46
+
47
+ FILE_START = 0xFE
48
+ """Start of a file."""
49
+
50
+ UNUSED = 0xFF
51
+ """Empty/unused chunk."""
52
+
53
+
54
+ # =============================================================================
55
+ # Address Utilities
56
+ # =============================================================================
57
+
58
+
59
+ def chunk_index_to_address(chunk_index: int, fs_start: int) -> int:
60
+ """Convert a 1-based chunk index to a flash address.
61
+
62
+ :param chunk_index: The 1-based chunk index (1-252).
63
+ :param fs_start: The filesystem start address in flash.
64
+ :returns: The flash address where this chunk begins.
65
+ """
66
+ return fs_start + (chunk_index - 1) * CHUNK_SIZE
67
+
68
+
69
+ def address_to_chunk_index(address: int, fs_start: int) -> int:
70
+ """Convert a flash address to a 1-based chunk index.
71
+
72
+ :param address: The flash address.
73
+ :param fs_start: The filesystem start address in flash.
74
+ :returns: The 1-based chunk index.
75
+ """
76
+ return ((address - fs_start) // CHUNK_SIZE) + 1
77
+
78
+
79
+ def get_fs_start_address(device_info: DeviceInfo) -> int:
80
+ """Get the effective filesystem start address.
81
+
82
+ The filesystem may have more space available than needed. The start
83
+ address is adjusted to ensure we don't exceed MAX_CHUNKS.
84
+
85
+ :param device_info: Device information from the hex file.
86
+ :returns: The effective filesystem start address.
87
+ """
88
+ fs_max_size = CHUNK_SIZE * MAX_CHUNKS
89
+ fs_end = get_fs_end_address(device_info)
90
+
91
+ # There might be more free space than the filesystem needs
92
+ start_for_max_fs = fs_end - fs_max_size
93
+ return max(device_info.fs_start_address, start_for_max_fs)
94
+
95
+
96
+ def get_fs_end_address(device_info: DeviceInfo) -> int:
97
+ """Get the filesystem end address.
98
+
99
+ For V1, we subtract one page for the magnetometer calibration data.
100
+
101
+ :param device_info: Device information from the hex file.
102
+ :returns: The filesystem end address.
103
+ """
104
+ end_address = device_info.fs_end_address
105
+
106
+ # In V1 the magnetometer calibration data takes one flash page
107
+ if device_info.device_version == DeviceVersion.V1:
108
+ end_address -= device_info.flash_page_size
109
+
110
+ return end_address
111
+
112
+
113
+ def get_last_page_address(device_info: DeviceInfo) -> int:
114
+ """Get the address of the last filesystem page (persistent page).
115
+
116
+ The last page is reserved for persistent data and not used for files.
117
+
118
+ :param device_info: Device information from the hex file.
119
+ :returns: The address where the last (persistent) page starts.
120
+ """
121
+ return get_fs_end_address(device_info) - device_info.flash_page_size
122
+
123
+
124
+ # =============================================================================
125
+ # Reading Files
126
+ # =============================================================================
127
+
128
+
129
+ def read_chunk(ih: IntelHex, address: int) -> bytes:
130
+ """Read a full chunk from the Intel Hex at the given address.
131
+
132
+ :param ih: The Intel Hex object.
133
+ :param address: The start address of the chunk.
134
+ :returns: The 128 bytes of the chunk.
135
+ """
136
+ return bytes(ih[address + i] for i in range(CHUNK_SIZE))
137
+
138
+
139
+ def read_files_from_hex(ih: IntelHex, device_info: DeviceInfo) -> dict[str, bytes]:
140
+ """Read all files from the MicroPython filesystem.
141
+
142
+ This scans the filesystem area for file start markers (0xFE), then
143
+ follows the chunk chain to extract the complete file content.
144
+
145
+ :param ih: The Intel Hex object containing MicroPython.
146
+ :param device_info: Device information from the hex file.
147
+ :returns: Dictionary mapping filenames to their content as bytes.
148
+ :raises FilesystemError: If the filesystem structure is corrupted.
149
+ """
150
+ start_address = get_fs_start_address(device_info)
151
+ end_address = get_last_page_address(device_info)
152
+
153
+ # First pass: collect all used chunks and identify file starts
154
+ used_chunks: dict[int, bytes] = {}
155
+ start_chunk_indexes: list[int] = []
156
+
157
+ chunk_addr = start_address
158
+ chunk_index = 1
159
+
160
+ while chunk_addr < end_address:
161
+ chunk = read_chunk(ih, chunk_addr)
162
+ marker = chunk[0]
163
+
164
+ # Skip unused, freed, and persistent data chunks
165
+ if marker not in (
166
+ ChunkMarker.UNUSED,
167
+ ChunkMarker.FREED,
168
+ ChunkMarker.PERSISTENT_DATA,
169
+ ):
170
+ used_chunks[chunk_index] = chunk
171
+ if marker == ChunkMarker.FILE_START:
172
+ start_chunk_indexes.append(chunk_index)
173
+
174
+ chunk_index += 1
175
+ chunk_addr += CHUNK_SIZE
176
+
177
+ # Second pass: follow chunk chains and extract file data
178
+ files: dict[str, bytes] = {}
179
+ seen_filenames: set[str] = set()
180
+
181
+ for start_idx in start_chunk_indexes:
182
+ start_chunk = used_chunks[start_idx]
183
+
184
+ # Parse file header:
185
+ # Byte 0: FILE_START marker (0xFE)
186
+ # Byte 1: End offset in last chunk
187
+ # Byte 2: Filename length
188
+ # Bytes 3+: Filename
189
+ end_offset = start_chunk[1]
190
+ name_len = start_chunk[2]
191
+ filename_bytes = start_chunk[3 : 3 + name_len]
192
+ filename = filename_bytes.decode("utf-8")
193
+
194
+ if filename in seen_filenames:
195
+ raise FilesystemError(f"Found multiple files named: {filename}")
196
+ seen_filenames.add(filename)
197
+
198
+ # Follow chunk chain and collect data
199
+ data = bytearray()
200
+ current_chunk = start_chunk
201
+ current_index = start_idx
202
+ # In first chunk, data starts after header
203
+ chunk_data_start = 3 + name_len
204
+
205
+ # Track iterations to detect infinite loops
206
+ max_iterations = len(used_chunks) + 1
207
+
208
+ while max_iterations > 0:
209
+ max_iterations -= 1
210
+ next_index = current_chunk[CHUNK_SIZE - 1] # Tail byte
211
+
212
+ if next_index == ChunkMarker.UNUSED:
213
+ # This is the last chunk - extract data up to end_offset
214
+ data.extend(current_chunk[chunk_data_start : 1 + end_offset])
215
+ break
216
+ else:
217
+ # Not the last chunk - extract all data bytes
218
+ data.extend(
219
+ current_chunk[chunk_data_start : CHUNK_SIZE - 1]
220
+ ) # All but tail
221
+
222
+ # Move to next chunk
223
+ next_chunk = used_chunks.get(next_index)
224
+ if next_chunk is None:
225
+ raise FilesystemError(
226
+ f"Chunk {current_index} points to unused index {next_index}"
227
+ )
228
+
229
+ # Verify the back-link
230
+ if next_chunk[0] != current_index:
231
+ raise FilesystemError(
232
+ f"Chunk index {next_index} did not link to "
233
+ f"previous chunk index {current_index}"
234
+ )
235
+
236
+ current_chunk = next_chunk
237
+ current_index = next_index
238
+ # After first chunk, data starts after the marker byte
239
+ chunk_data_start = CHUNK_MARKER_SIZE
240
+
241
+ if max_iterations <= 0:
242
+ raise FilesystemError("Malformed file chunks did not link correctly")
243
+
244
+ files[filename] = bytes(data)
245
+
246
+ return files
247
+
248
+
249
+ # =============================================================================
250
+ # Writing Files
251
+ # =============================================================================
252
+
253
+
254
+ def calculate_file_size(filename: str, content: bytes) -> int:
255
+ """Calculate the size in bytes a file will occupy in the filesystem.
256
+
257
+ This returns the total number of bytes the file will use, which is
258
+ always a multiple of the chunk size (128 bytes).
259
+
260
+ :param filename: The name of the file.
261
+ :param content: The file content as bytes.
262
+ :returns: Size in bytes (always a multiple of 128).
263
+ """
264
+ filename_bytes = filename.encode("utf-8") if isinstance(filename, str) else filename
265
+ header_size = 2 + len(filename_bytes) # end_offset + name_len + name
266
+
267
+ # Total data: header + content + trailing 0xFF byte
268
+ total_data = header_size + len(content) + 1
269
+
270
+ chunks_needed = (total_data + CHUNK_DATA_SIZE - 1) // CHUNK_DATA_SIZE
271
+ return chunks_needed * CHUNK_SIZE
272
+
273
+
274
+ def get_free_chunks(ih: IntelHex, device_info: DeviceInfo) -> list[int]:
275
+ """Get a list of free chunk indices in the filesystem.
276
+
277
+ Scans the filesystem area and returns indices of chunks that are
278
+ either unused (0xFF) or freed (0x00).
279
+
280
+ :param ih: The Intel Hex object.
281
+ :param device_info: Device information from the hex file.
282
+ :returns: List of 1-based chunk indices that are free.
283
+ """
284
+ start_address = get_fs_start_address(device_info)
285
+ end_address = get_last_page_address(device_info)
286
+
287
+ free_chunks: list[int] = []
288
+ chunk_addr = start_address
289
+ chunk_index = 1
290
+
291
+ while chunk_addr < end_address:
292
+ marker = ih[chunk_addr]
293
+ if marker == ChunkMarker.UNUSED or marker == ChunkMarker.FREED:
294
+ free_chunks.append(chunk_index)
295
+ chunk_index += 1
296
+ chunk_addr += CHUNK_SIZE
297
+
298
+ return free_chunks
299
+
300
+
301
+ def set_persistent_page(ih: IntelHex, device_info: DeviceInfo) -> None:
302
+ """Set the persistent page marker in the last filesystem page.
303
+
304
+ The last page of the filesystem is reserved for persistent data
305
+ and is marked with a special marker byte.
306
+
307
+ :param ih: The Intel Hex object to modify.
308
+ :param device_info: Device information from the hex file.
309
+ """
310
+ last_page_addr = get_last_page_address(device_info)
311
+ ih[last_page_addr] = ChunkMarker.PERSISTENT_DATA
312
+
313
+
314
+ def generate_file_header(filename: str, content: bytes) -> bytes:
315
+ """Generate the file header bytes for the first chunk.
316
+
317
+ The header contains:
318
+ - Byte 0: end_offset (where data ends in last chunk)
319
+ - Byte 1: filename length
320
+ - Bytes 2+: filename
321
+
322
+ :param filename: The name of the file.
323
+ :param content: The file content.
324
+ :returns: The header bytes to put in the first chunk's data area.
325
+ """
326
+ filename_bytes = filename.encode("utf-8")
327
+ header_size = 2 + len(filename_bytes) # end_offset + name_len + name
328
+
329
+ # Calculate end_offset: where data ends in the last chunk (within data area)
330
+ # This is (header_size + content_len) % CHUNK_DATA_SIZE
331
+ # But we also add 1 trailing 0xFF byte like MicroPython does
332
+ total_data = header_size + len(content)
333
+ end_offset = total_data % CHUNK_DATA_SIZE
334
+
335
+ header = bytearray(header_size)
336
+ header[0] = end_offset
337
+ header[1] = len(filename_bytes)
338
+ header[2:] = filename_bytes
339
+
340
+ return bytes(header)
341
+
342
+
343
+ def build_file_chunks(
344
+ filename: str, content: bytes, free_chunks: list[int]
345
+ ) -> tuple[list[tuple[int, bytes]], int]:
346
+ """Build the chunk data for a file.
347
+
348
+ Creates all the chunks needed to store the file, using the provided
349
+ free chunk indices.
350
+
351
+ :param filename: The name of the file.
352
+ :param content: The file content.
353
+ :param free_chunks: List of available chunk indices.
354
+ :returns: Tuple of (list of (chunk_index, chunk_bytes), chunks_used)
355
+ :raises InvalidFileError: If the filename is too long or empty.
356
+ :raises StorageFullError: If there aren't enough free chunks.
357
+ """
358
+ # Validate file
359
+ if not filename:
360
+ raise InvalidFileError("File must have a filename")
361
+
362
+ filename_bytes = filename.encode("utf-8")
363
+ if len(filename_bytes) > MAX_FILENAME_LENGTH:
364
+ raise InvalidFileError(
365
+ f"Filename '{filename}' is too long "
366
+ f"(max {MAX_FILENAME_LENGTH} bytes, got {len(filename_bytes)})"
367
+ )
368
+
369
+ if len(content) == 0:
370
+ raise InvalidFileError(f"File '{filename}' must have content")
371
+
372
+ # Build the full data stream: header + content + trailing 0xFF
373
+ # MicroPython adds a trailing 0xFF when file fills exactly to chunk boundary
374
+ header = generate_file_header(filename, content)
375
+ fs_data = bytearray(len(header) + len(content) + 1)
376
+ fs_data[: len(header)] = header
377
+ fs_data[len(header) : len(header) + len(content)] = content
378
+ fs_data[-1] = 0xFF
379
+
380
+ # Calculate how many chunks we need
381
+ chunks_needed = (len(fs_data) + CHUNK_DATA_SIZE - 1) // CHUNK_DATA_SIZE
382
+
383
+ if chunks_needed > len(free_chunks):
384
+ raise StorageFullError(
385
+ f"Not enough space for file '{filename}'. "
386
+ f"Need {chunks_needed} chunks, have {len(free_chunks)} free."
387
+ )
388
+
389
+ # Build the chunks
390
+ result: list[tuple[int, bytes]] = []
391
+ data_index = 0
392
+ chunks_to_use = free_chunks[:chunks_needed]
393
+
394
+ for i, chunk_idx in enumerate(chunks_to_use):
395
+ chunk = bytearray(CHUNK_SIZE)
396
+ # Fill with 0xFF first
397
+ for j in range(CHUNK_SIZE):
398
+ chunk[j] = 0xFF
399
+
400
+ if i == 0:
401
+ # First chunk: FILE_START marker
402
+ chunk[0] = ChunkMarker.FILE_START
403
+ else:
404
+ # Continuation chunk: back-pointer to previous chunk
405
+ chunk[0] = chunks_to_use[i - 1]
406
+
407
+ # Copy data into the chunk's data area (bytes 1-126)
408
+ data_end = min(len(fs_data), data_index + CHUNK_DATA_SIZE)
409
+ data_to_copy = fs_data[data_index:data_end]
410
+ chunk[CHUNK_MARKER_SIZE : CHUNK_MARKER_SIZE + len(data_to_copy)] = data_to_copy
411
+ data_index = data_end
412
+
413
+ # Set tail byte (next chunk index or 0xFF if last)
414
+ if i < len(chunks_to_use) - 1:
415
+ chunk[CHUNK_SIZE - 1] = chunks_to_use[i + 1]
416
+ # else: already 0xFF
417
+
418
+ result.append((chunk_idx, bytes(chunk)))
419
+
420
+ return result, chunks_needed
421
+
422
+
423
+ def add_files_to_hex(
424
+ ih: IntelHex,
425
+ device_info: DeviceInfo,
426
+ files: dict[str, bytes],
427
+ ) -> None:
428
+ """Write files to the MicroPython filesystem in an Intel Hex.
429
+
430
+ Modifies the Intel Hex object in place to add the files to the
431
+ filesystem region.
432
+
433
+ :param ih: The Intel Hex object to modify.
434
+ :param device_info: Device information from the hex file.
435
+ :param files: Dictionary mapping filenames to their content as bytes.
436
+ :raises InvalidFileError: If any file has invalid name or content.
437
+ :raises StorageFullError: If the files don't fit in the filesystem.
438
+ """
439
+ if not files:
440
+ return
441
+
442
+ fs_start = get_fs_start_address(device_info)
443
+
444
+ # Get all free chunks initially
445
+ free_chunks = get_free_chunks(ih, device_info)
446
+
447
+ if not free_chunks:
448
+ raise StorageFullError("No storage space available in filesystem")
449
+
450
+ # Write each file
451
+ for filename, content in files.items():
452
+ chunks, chunks_used = build_file_chunks(filename, content, free_chunks)
453
+
454
+ # Write chunks to hex
455
+ for chunk_idx, chunk_data in chunks:
456
+ chunk_addr = chunk_index_to_address(chunk_idx, fs_start)
457
+ for i, byte in enumerate(chunk_data):
458
+ ih[chunk_addr + i] = byte
459
+
460
+ # Remove used chunks from free list
461
+ free_chunks = free_chunks[chunks_used:]
462
+
463
+ # Set persistent page marker
464
+ set_persistent_page(ih, device_info)