Skip to main content
f_lib/ is the layer everything else in this firmware sits on. f_lib/bitwise.py is where every flags byte, bit field and embedded UTF-8 string in the protocols is actually built. f_lib/logger.py owns errors.log and formats every log line quoted elsewhere as evidence. f_lib/task_mgr.py is how every coroutine in the image gets started. All of them were listed as NAME-ONLY or partial in the module reference; this page is the decode.
Evidence convention on this page. Claims are confirmed when they were read directly out of the v5.0.3 disassembly (cited as file.dis:line), inferred when they are reasoned from strings or structure, and called out explicitly when they are not recoverable from the frozen bytecode. Line numbers refer to re/v5.0.3/mpy/*.dis.The Python shown below is reconstructed: the source is not in the image. The operations, their order and the constants are exact; names of local variables that the bytecode does not carry are ours, and are marked where it matters.
“Importers” counts modules containing an IMPORT_NAME f_lib.<name> for that module, across all 94 disassemblies. The eight f_lib modules not on this page — wifi.py, wifi_v2.py, requests.py, generators.py, helpers.py, neopixel_v2.py, firmware_ota.py and firmware_rollback.py — belong to the subsystem pages that use them; see the module reference. f_lib/logger.py is the most-imported module in the image. All 41 importers import the same thing — the module-level singleton log — and 11 of them also import ErrCode (confirmed: IMPORT_FROM log appears 41 times across the 94 disassemblies, IMPORT_FROM ErrCode 11 times). Nothing anywhere constructs its own Logger.

f_lib/bitwise.py

The module body is thirteen functions and one import, binascii (f_lib_bitwise.dis:62113). No classes, no constants, no module state. machine is imported lazily, inside get_mac_addr only.

The public surface (CONFIRMED)

Defaults below are exact — they are the MAKE_FUNCTION_DEFARGS tuples built in the module body (f_lib_bitwise.dis:66111).

Bit fields (CONFIRMED, f_lib_bitwise.dis:270342, :456473)

Both error strings are verbatim from obj_table (f_lib_bitwise.dis:56). The three validation branches are three separate jumps at :286, :290 and :296, all landing on the same raise. The pair is asymmetric. pack_bits validates the range, the width and the value and masks the result to a byte; unpack_bits validates nothing. unpack_bits(v, 6, 4) reads past bit 7 and returns whatever is there rather than raising, which matters when reading a field out of a value that is not a single byte — the UBX parsers in navigation call it directly on parsed words.

Two-bit tri-state fields (CONFIRMED, f_lib_bitwise.dis:215268, :428454)

A two-bit field here holds three values, not four. pack_2bit_value rejects 3 ('Value must be 0, 1, or 2', f_lib_bitwise.dis:233) while unpack_2bit_value returns (byte_val >> start_bit) & 3 with no such restriction (:449453). A 3 in one of these fields on the wire is therefore a value this firmware cannot have written — a corrupt byte, a foreign sender, or a field that a different function packed.The argument order differs from pack_bits. pack_2bit_value(byte_val, value, start_bit) puts the value before the position; pack_bits(byte_val, start_bit, bit_width, value) puts it last. Both are confirmed from the args lists at f_lib_bitwise.dis:218 and :273.
pack_2bit_value also does not mask its result to 8 bits, where pack_bits does (22:81:7f / LOAD_CONST_SMALL_INT 255 at f_lib_bitwise.dis:335, with no counterpart in pack_2bit_value). For a byte_val already in 0-255 the two agree; for a larger accumulator they do not. The one caller in the image is nav_logger, which packs fusion.orientation at bit 5 of a flags byte (nav_logger.dis:885891). That a fusion.orientation of 3 would raise ValueError inside the navigation logger is inferred — the orientation’s own value range was not checked.

Flags bytes (CONFIRMED, f_lib_bitwise.dis:343368, :474504)

This is the function behind every pack_flags(...) bit order quoted in message format and building a client. Three properties are worth stating explicitly, because the wire-format pages assume them:
  • Element 0 is bit 0. The index from enumerate is the shift (f_lib_bitwise.dis:360364), so the first item in a pack_flags tuple is the least significant bit.
  • Truthiness, not bool. The test is a bare POP_JUMP_IF_FALSE on the element (:359). Any truthy object sets the bit; 0, None, '' and False all clear it. The pack_flags(is_sos, is_eco_mode, ...) expressions documented elsewhere are passing raw mode attributes, not normalised booleans.
  • pack_flags has no eight-element limit; unpack_flags has nothing else. pack_flags iterates the whole sequence, so a nine-element argument can return a value above 255 (which struct.pack into a B would then reject). unpack_flags is a fixed range(8) loop (:481501) and always returns exactly eight bools, so it cannot round-trip such a value.
unpack_flags’s packed_byte=None default (:474, defargs tuple at :100103) is not usable: None & 1 raises TypeError. The same is true of the buff=None default on pack_utf8_str.

Strings (CONFIRMED, f_lib_bitwise.dis:370426, :506525)

pack_utf8_str accepts a max_size and never reads it. The parameter is declared (args: ['buff', 'start', 'text', 'max_size'], f_lib_bitwise.dis:373) with a default of 32 (:89), and LOAD_FAST 3 — the only opcode that could read it — does not appear anywhere in the body, which is nine instructions long (:375392). The function is a bare buff[start:start+n] = str_to_bin(text) that returns n. Confirmed.It is dead on the calling side too: a search of all 94 disassemblies for the qstr max_size returns only f_lib_bitwise.dis itself. No caller has ever passed a bound, and passing one would not have helped.The consequence on the ESP-NOW peer message — a device name long enough to overwrite the fields after it — is worked through under Peer frame in message format.
Two more edges in str_to_bin, both confirmed from the branch structure at f_lib_bitwise.dis:399426:
  • Falsy input returns b'', not an error. None, '', 0 and b'' all take the first jump (POP_JUMP_IF_FALSE at :400) to LOAD_CONST_OBJ b''. So pack_utf8_str(buff=b, start=i, text=None) writes nothing and returns 0.
  • Only str and bytes are handled directly. Anything else goes through str(val).encode('utf-8') (:418424). That a bytearray or memoryview therefore serialises as its reprbytearray(b'...') — rather than its contents is inferred from MicroPython’s type hierarchy, in which bytearray is not a subclass of bytes; it was not observed on a device.
unpack_utf8_str copies through bytes(...) before decoding (:511519), so it accepts a memoryview or bytearray slice as well as bytes. It has no length or validity guard: a b_len that runs past the end of buff silently yields a shorter string (Python slice semantics), and a truncated multi-byte sequence raises from decode. The latter is inferred — the decode call is confirmed, its failure mode is standard MicroPython behaviour rather than something read out of this module.

Hex and the MAC address (CONFIRMED, f_lib_bitwise.dis:115214)

bin_to_str and hex_to_bin are both idempotent by design — handed something already in the target form they return it unchanged (:144, :212). bin_to_hex tests if sep: rather than if sep is not None: (:120), so an empty separator is silently ignored. get_mac_addr is the identifier in every cloud URL on the OTA and WiFi paths. The '{:02x}' format string (f_lib_bitwise.dis:189) is the reason it is lower-case, and the ''.join (:170176) is the reason there are no separators. The machine import is function-local (:166), not module-level. That the result is twelve characters follows from machine.unique_id() returning the 6-byte base MAC on ESP32 — an ESP32 property, inferred, not something this bytecode states.

Who uses which primitive (CONFIRMED)

Counting the IMPORT_FROM opcodes that follow each IMPORT_NAME f_lib.bitwise across the 18 importing modules: Two functions are never imported by any module: bin_to_hex, which is reached only indirectly through bin_to_str, and unpack_2bit_value, which is dead code — no IMPORT_FROM and no LOAD_GLOBAL for it exists outside f_lib_bitwise.dis. The 2-bit orientation field nav_logger writes is never read back on the device. The single pack_bits user is ble_manager.gen_live_data, which builds one byte from two calls (ble_manager.dis:61616175): pack_bits(0, 0, 3, config.power_mode) puts the power mode in bits 0-2, then pack_bits(b, 3, 5, 31) sets bits 3-7 to a constant 0b11111. Overall the device packs far more than it unpacks — the mesh and BLE payloads are written here and parsed on the phone.

f_lib/logger.py

The module imports const from micropython, stat from os, print_exception from sys and the rtc singleton from f_lib.rtc_v2; defines ErrCode and Logger; instantiates log = Logger(); and defines a module-level exists(file=None) (f_lib_logger.dis:97144). const is a compile-time marker — mpy-cross folds it away, so no named level constants survive into the image and every level below is an inline integer.

log is a singleton created with level 4 (CONFIRMED)

Both defaults are LOAD_CONST_SMALL_INT 4 in the defargs tuple at f_lib_logger.dis:197201. log = Logger() passes no arguments, and nothing in the image ever constructs another Logger (confirmed: no LOAD_GLOBAL Logger or LOAD_NAME Logger outside f_lib_logger.dis).
_write_lvl is set once, to 4, and never changed again. The qstr _write_lvl does not appear in any of the other 93 disassemblies. There is no setter for it — the class has set_print_level and nothing else. Every path that could lower it would have to assign the private attribute, and none does.So on any shipped device, errors.log can only ever contain ERR and EXC records. Any DBG, INF or WAR line quoted anywhere in these docs is a console line that was never written to flash.

Levels (CONFIRMED)

The names come from the tuple in obj_table (f_lib_logger.dis:91), indexed by level in _log (:374376). NOT has no wrapper. Only _log’s own lvl=0 default (f_lib_logger.dis:208) can produce it, so a level-0 record requires a direct _log call; nothing in the image makes one.

The gate lives in the wrappers, not in _log (CONFIRMED)

Bodies at f_lib_logger.dis:449573; the is_write defaults are the LOAD_CONST_FALSE / LOAD_CONST_TRUE entries in the defargs tuples at :217255. Every wrapper calls _log with five keyword arguments (CALL_METHOD 1280 = 0 positional, 5 keyword). Two consequences that matter when reading the rest of this site:
  • _print_lvl gates the record entirely, not just the console. A debug call at print level 4 never reaches _log, so its is_write is irrelevant. Raising verbosity is the only way to get lower-severity records anywhere at all.
  • err and exc cannot be silenced. They have no gate, and _log itself has no level check — it formats and prints unconditionally. Setting the print level to 5 does not suppress errors.
Call sites in the image, counted as LOAD_GLOBAL log immediately followed by a LOAD_METHOD (a lower bound — module-level calls use LOAD_NAME): At the boot default of level 4, the first three rows — 495 of 571 call sites, about 87% of the logging in this firmware — produce nothing at all.

What a record looks like (CONFIRMED, f_lib_logger.dis:289447)

The five fields are timestamp, level name, title, body, exception. An absent title or body becomes the literal --, and so does an absent exception, which is why -- appears so often in log evidence on this site. rtc.timestamp is a property on RTCv2, not a call — the opcode is LOAD_ATTR (f_lib_logger.dis:373), and f_lib_rtc_v2.dis:143145 wraps it in property(). It formats machine.RTC().datetime() as '{}-{:02}-{:02} {:02}:{:02}:{:02}.{:06}' — year, month, day, hour, minute, second, microsecond, skipping the weekday field (f_lib_rtc_v2.dis:239280). A record therefore looks like this (assembled from the confirmed format, not a captured line):
Multi-line exception text is flattened to a single line: newlines become », then ' '.join(x.split()) collapses every whitespace run. A traceback in errors.log is one long line with » where its line breaks were. Three details that are easy to get wrong:
  • exc.__class__.__name__ is computed and discarded. It is stored to a local (f_lib_logger.dis:326328) that no later opcode reads — LOAD_FAST 8 does not appear again in the function. The exception type still reaches the record, but only as part of print_exception’s traceback text.
  • Only is None is tested for title and body, then .strip() is called. A non-string title (an int, say) raises AttributeError from inside the logger. Confirmed from the <is> comparison at :296.
  • if exc: is truthiness, so an exception object that is somehow falsy is skipped. In practice every caller passes a caught exception.

Writing to flash: the 8000-byte cliff (CONFIRMED, f_lib_logger.dis:627664)

stat(...)[6] is st_size; the threshold 8000 is LOAD_CONST_SMALL_INT 8000 at f_lib_logger.dis:641.
There is no rotation. When errors.log passes 8000 bytes, _write returns and the device stops recording errors to flash — permanently, until something external removes the file. Nothing in f_lib/logger.py truncates, renames or trims it, and the check is a plain > 8000 with no else branch.This is a real gap in any post-mortem taken off a device: a log that is a little over 8000 bytes is not a log that filled up and rotated, it is a log that stopped at some unknown point in the past, and everything after that moment is gone.
_write opens, writes and closes on every record — no buffering — and has no try/except around the file operations. An OSError (full filesystem, corrupt VFS) therefore propagates out of log.err(...) and into whatever called it. Confirmed: the function contains no SETUP_EXCEPT. Callers that cannot afford that pass is_write=False; for example ble_manager.dis:15961603 logs 'pending_tx gate unavailable; kicking' with exc=e, is_write=False.

Who deletes errors.log (CONFIRMED)

The qstr errors.log appears in exactly three modules across the whole image. So the only automatic reset is a successful OTA. On a device that never updates and never has show_logs(True) run against it from the REPL, errors.log grows to 8000 bytes once and then freezes for the life of the device.
The three literals are in obj_table (f_lib_logger.dis:91). This is what project_main.show_logs() calls.

exists (CONFIRMED, f_lib_logger.dis:666687)

A module-level function, not a method, defined after log = Logger(). Note that f_lib/file_mgr.py exports its own exists, which project_main imports separately — they are different functions with the same name.

ErrCode (CONFIRMED, f_lib_logger.dis:146186)

A bare class used as a string enum: fifteen class attributes, each a str. Eleven modules import it.
The module reference lists only the eight of these that appear in obj_table. The other seven — ConnFailed, MemError, NotFound, NotReady, SyntaxErr, Timeout, UnknownMsg — are short enough that mpy-cross interned them as qstrs instead (f_lib_logger.dis:490), which is why reading obj_table alone undercounts them. The full set is the fifteen above.

Changing the level: debug.mode (CONFIRMED)

The print level is set in exactly two ways. At boot, to 4. project_main’s module body runs log.set_print_level(lvl=4) (project_main.dis:319326), immediately after importing the singleton. The level a device runs at is therefore 4 unless the file below changes it. Via a one-shot file. project_main.debug(lvl) writes save_obj('debug.mode', {'lvl': lvl}) and then machine.soft_reset() (project_main.dis:9921020). On the next boot, start does (project_main.dis:12421270):
The file is deleted as it is read, so a lowered level survives exactly one boot. This is the mechanism behind the debug features elsewhere in this firmware that key off log._print_lvl == 1: espnow_conn_v2 (8 sites), f_ble/file_upload.py (6), compass (4), ble_manager (3), svc_ble_transfer (2) and f_ble/ble_lite.py (1) all read the private attribute directly rather than calling a method — 24 LOAD_ATTR _print_lvl sites across six modules (confirmed). f_ota/main.py is the one place that writes the private attribute instead of calling the setter: log._print_lvl = 1 if cfg.is_verbose else 4 (f_ota_main.dis:12721280).

WdtBlockers.LOG_ROTATE is not this module (CONFIRMED)

WdtBlockers.LOG_ROTATE = 0 (wdt_manager.dis:142; VFS_WRITE = 1, WLAN_KICK = 2 follow). The qstr LOG_ROTATE appears in only two disassemblies: wdt_manager.dis, which defines it, and nav_logger.dis, which uses it in check_log_storage — a try/finally that sets the blocker, warns 'VFS free space too low to safely write log' when statvfs('/')[3] (free blocks) is below 39, removes old logs, and clears the blocker on the way out (nav_logger.dis:12261245, :13991407). That is the navigation event log (events.bin), not errors.log. f_lib/logger.py imports neither wdt_manager nor WdtBlockers and never touches the watchdog. Its 8000-byte write is unguarded — which is consistent, since the write is a single small append rather than the multi-second rotation the blocker exists to protect.

f_lib/task_mgr.py

Everything asynchronous in this firmware is started here. 28 modules import it and all 28 import exactly one name — the module-level singleton tasks (confirmed: an IMPORT_NAME f_lib.task_mgr in 28 disassemblies, each followed by IMPORT_FROM tasks and nothing else). Nothing constructs its own Tasks. The whole module is 305 lines of disassembly and fits on one screen.

The module (CONFIRMED, f_lib_task_mgr.dis:35304)

Details behind the reconstruction, all confirmed:
  • The two del statements are LOAD_NULL / ROT_THREE / STORE_SUBSCR (f_lib_task_mgr.dis:151153 and :300:302) — MicroPython’s encoding for del obj[key].
  • The **kwargs on launch, schedule and _schedule are the var-keyword scope flag in each prelude, and the calls are CALL_FUNCTION_VAR_KW / CALL_METHOD_VAR_KW (:177, :190, :216, :254).
  • Event is imported at :48 and no opcode in the module loads it again. It is a dead import; _schedule calls event.wait() on whatever object the caller passed.
  • tasks = Tasks() is STORE_NAME tasks at :58.

The registry (CONFIRMED)

Four consequences of this design matter elsewhere on this site.
A task name stays occupied after the task finishes. launch tests task_name not in self.cur (f_lib_task_mgr.dis:168169); nothing removes the entry when the coroutine ends. A one-shot task launched under a name therefore blocks every later launch of that name — silently, with no log line and no return value to check — until something calls stop(name) or cleanup().cleanup() is called from exactly four sites in the whole image (confirmed: LOAD_METHOD cleanup on the tasks global): device_power.shut_down_tasks (device_power.dis:273275), compass.backend_checks (compass.dis:19571959), svc_ble_transfer.backend_checks (svc_ble_transfer.dis:271273) and touch_button_v2.stop (touch_button_v2.dis:10521054). There is no periodic sweep. Names whose owner does not also call stop depend on one of those four running.show_tasks is dead code: the qstr appears in no other disassembly, so the only way to list the registry is from the REPL.
task_mgr does nothing about exceptions — and neither does anything else by default. There is no SETUP_EXCEPT anywhere in f_lib/task_mgr.py. launch hands the coroutine to asyncio.create_task and forgets it: it does not wrap cb, does not await the task, does not register a done-callback, and never restarts anything.What happens to a raising task is therefore entirely MicroPython’s frozen asyncio. In run_until_complete, a task that ends on an exception and is not being awaited on (t.state is None) has its exception stored, and the loop calls Loop.call_exception_handler with the module-level _exc_context dict (asyncio_core.dis:919939). _exc_context is built once at import with message = "Task exception wasn't retrieved" (:177:187). call_exception_handler falls back to Loop.default_exception_handler whenever Loop._exc_handler is unset (:11341150), and that handler prints to sys.stderr (:10931133):
No exception handler is ever installed. set_exception_handler is defined at asyncio_core.dis:1073 and the qstr appears in no other disassembly. So an uncaught exception in a launched task:
  1. kills that task permanently — it is not retried, and its name stays in tasks.cur until cleanup() or stop() removes it;
  2. prints to the console, not to the logger — it goes through sys.print_exception, never log.exc, so it is not written to errors.log and cannot be recovered from a device after the fact;
  3. leaves the rest of the event loop running, so the device stays up with one subsystem quietly dead.
This is the failure shape behind several of the stories elsewhere on this site. It is also why the one place that cares wraps the coroutine itself: espnow_conn_v2.communicate_supervised is a while True around await self.communicate_v2() that catches (RuntimeError, OSError), logs 'communicate_v2 died; restarting', sleeps 500 ms and loops (espnow_conn_v2.dis:27452810). That restart behaviour belongs to the supervisor, not to task_mgr — and note it catches only those two types, so any other exception escapes it and ends the mesh task for good.
launch passes keyword arguments only. The call it builds is cb(**kwargs) — zero positional arguments, one double-star pair (CALL_FUNCTION_VAR_KW 256 at f_lib_task_mgr.dis:177, whose operand decodes as 0 positional and 1 keyword). Every tasks.launch(fn, task_name='x', foo=1) in the image therefore reaches fn as fn(foo=1). A coroutine that takes a positional-only first argument cannot be launched this way.launch never returns the task. Both branches end in POP_TOP or STORE_SUBSCR, and the function returns None (:182:194). The only handle to a running task is tasks.cur[name], which four modules reach into directly — compass (5 sites), compassing and peer_management (2 each) and ota_callback (1).
An unnamed launch cannot be stopped. launch(cb) with no task_name takes the else branch, which creates the task and discards it (f_lib_task_mgr.dis:184192). Nothing holds a reference, so it can only end by finishing or raising.schedule’s wrapper is not tracked either. schedule creates a task for _schedule(...) and drops it (:202:218); only the eventual inner launch registers anything. Calling stop(name) before the delay elapses removes nothing, and the wrapper still fires and re-registers the name afterwards. schedule is used from six modules — compass (5 sites), debugger, espnow_conn_v2, f_ota/hotspot, imu_fusion_auto and nav_logger (1 each).
cleanup calls .done() on the stored task. Task comes from the native _asyncio module — asyncio/core.py does from _asyncio import TaskQueue, Task inside a try, and the pure-Python fallback asyncio/task.py is not frozen into this image (asyncio_core.dis:145, with the fallback IMPORT_NAME task at :157; there is no asyncio_task.dis among the 94 modules). So what done() counts as done is C code that is not in the bytecode, and is not recoverable from it.

f_lib/file_mgr.py

Nineteen module-level names, no state, two constants: VFS_DIR = 16384 and VFS_FILE = 32768 (f_lib_file_mgr.dis:161164) — the os.stat / os.ilistdir mode values for a directory and a regular file. asyncio, os and log are imported at module level; deflate, hashlib and json are imported lazily inside the functions that need them.

The public surface (CONFIRMED)

Defaults are the MAKE_FUNCTION_DEFARGS tuples in the module body (f_lib_file_mgr.dis:164272). exists is the most-used export (10 modules), then save_obj (8) and rebuild_obj (5). Note that f_lib/logger.py defines its own separate exists — same name, different function, as the logger section above says.

save_obj and rebuild_obj (CONFIRMED, f_lib_file_mgr.dis:934998, :12021268)

save_obj opens the destination with mode 'w', which truncates it before anything is written. There is no temporary file, no rename and no sync — as OTA already states. The atomic dance that is in this firmware lives one level up, in project_data.update_config, which calls save_obj('config.json.tmp', config) and renames the result over config.json only if it returned truthy (project_data.dis:24302441).
save_obj returns True for every OSError except ENOSPC. The handler tests e.errno == 28 (f_lib_file_mgr.dis:12491251); when that test fails, control falls through the handler to the function’s tail, which is LOAD_CONST_TRUE / RETURN_VALUE (:12661267). There is no re-raise and no other return.So an I/O error, a corrupt filesystem or a bad path produces a truncated or empty file and a True return. Any caller that treats the return value as “the write succeeded” is wrong in exactly the case it was checking for. Only a non-OSError — for example a TypeError from obj.__dict__ on an object that has none — actually propagates.
rebuild_obj will overwrite methods. The guard is hasattr(obj, k) (f_lib_file_mgr.dis:976980), which is true for class attributes and bound methods, not just instance data. A JSON file whose key happens to be a method name puts the JSON value on the instance, shadowing the method. is_strict=True narrows the set to list(obj.__dict__) — instance attributes only — and is the safe mode; it is off by default (:225231).rebuild_obj also cannot distinguish “file missing”, “file corrupt” and “file contains an empty object”: get_file_contents catches OSError and ValueError and returns False (:9991029), and the caller then does if not data: return False (:953956), which an empty dict also satisfies. All three cases come back as False with nothing logged.

The OTA file install: move_files (CONFIRMED, f_lib_file_mgr.dis:783933)

The two print format strings are shown abbreviated here because their literal text contains brace placeholders; the exact strings are 'Source directory: {} not found, cannot move files', 'OSError replacing: {} | errno: {}' and '{:.<16}{}' with 'Updated:', all from obj_table and the qstr table (f_lib_file_mgr.dis:133 and :60:61). Three things in this function are worth stating plainly.
The “source directory missing” path crashes instead of reporting. The opcodes are LOAD_GLOBAL move_files / CALL_FUNCTION 0 inside the format call (f_lib_file_mgr.dis:812813) — it calls itself with no arguments, and source_dir then defaults to None, so the first line of the recursive call raises AttributeError on None.strip('/'). The return False two instructions later is unreachable. Confirmed; whatever the source meant to interpolate, this is what the bytecode does.
  • The directory branch is dead. walk_up appends a path only when the entry mode is not VFS_DIR and recurses otherwise (:14081425), so every element of the list is a regular file and is_dir(src) is always false. The os.mkdir arm can never run; directories are created by the make_parents(dst) call above it.
  • A partial install is not rolled back. Each file is moved with an individual os.rename, and a failure sets ok = False and continues with the rest of the list. The staging directory is kept (the rmtree is inside if ok), but the files already renamed stay renamed. A half-installed update is a state this function can leave behind.

The rest, briefly (CONFIRMED)

  • gen_file_hash is a coroutine. It returns hashlib.sha256(...).digest() — 32 raw bytes, not hex (:561, :612) — reading the file in len(buff)-sized chunks through readinto into a memoryview, with buff defaulting to bytearray(256). It awaits asyncio.sleep_ms(0) every yield_every reads (default 4), so hashing a large file does not stall the event loop. A missing file logs 'File not found, cannot gen hash for: {}' at err level and returns None; any other exception logs 'Cannot gen hash for: {}' at exc level and returns None. Both pass is_write=False, so neither reaches errors.log.
  • compress_file is also a coroutine: deflate.DeflateIO(out, deflate.ZLIB) over 256-byte reads, awaiting asyncio.sleep_ms(pause_ms) after each chunk (default 10 ms). It raises FileErr(..., 'NotFound') for a missing input and a plain FileErr if the name’s second dot-separated field is already gz; the output path defaults to the input with .gz appended to its first field. Despite the .gz name the stream is zlib, not gzip (:361, :389).
  • vfs_storage() returns (total, free, used) in bytes, computed from os.statvfs('/') as f_bsize * f_blocks and f_bsize * f_bfree (:13521375). Note it uses f_bsize (index 0), not f_frsize (index 1).
  • get_files yields names, not paths, and only for entries whose mode is exactly VFS_FILE; directories are skipped entirely. Its dir_path default is the empty string, so get_oldest_file and remove_oldest_log, which never pass one, scan only the filesystem root.
  • vfs_del joins with no separator — os.remove(dir_path + entry_name) (:13121318). That is correct only because dir_path defaults to '/'; a caller passing 'logs' would try to remove logsfoo.bin. It is also non-recursive, and it returns early when file_types is falsy.
  • rmtree recurses with is_del_root=True regardless of the caller’s argument (:11681177), so is_del_root=False spares only the top directory. It has no try/except: one OSError aborts the walk part-way through.
  • write_bin_line opens with 'ab' and appends b'\n' after every record (:14591463). This is the newline-terminated binary-record convention used by the event and message logs.
walk_up has a mutable default argument. The default for path_list is BUILD_LIST 0 in the MAKE_FUNCTION_DEFARGS tuple (f_lib_file_mgr.dis:262266) — one list object, created once at import, shared by every call that does not pass its own. walk_up(d) called twice returns the first walk’s results appended to the second’s.In this image it never bites, because the only caller in the module passes an explicit [] (move_files, :829832), and no other module imports walk_up. It is a loaded gun rather than a fired one — but anything reusing this module needs to know. move_files’s own excluded=[] default (:218224) is the same shape, though it is only read.

f_lib/rtc_v2.py

Not RTC storage. class RTCv2(machine.RTC) plus six module-level calendar functions and a singleton rtc = RTCv2() (f_lib_rtc_v2.dis:9193). Thirteen modules import it; all thirteen import rtc, and three also import a calendar function (ubx_gnss takes time_to_unix and unix_to_time, espnow_conn_v2 takes unix_to_time, compass takes ms_until_sub_interval). machine.RTC().datetime() on ESP32 returns an eight-tuple (year, month, day, weekday, hour, minute, second, microsecond). Every property below indexes into it.

The class (CONFIRMED, f_lib_rtc_v2.dis:118361)

itod, itos, sec and timestamp are properties (each wrapped by a property() call in the class body, :130:145); unix and ms_until are ordinary methods. That asymmetry is easy to get wrong — the logger reads rtc.timestamp with LOAD_ATTR, while every caller of unix uses LOAD_METHOD.
ms_until compares hours only. The roll-forward test is f_time[0] < now[4] (f_lib_rtc_v2.dis:343348) — the target hour against the current hour. A target earlier in the same hour than now returns a negative number of milliseconds rather than wrapping to tomorrow. f_time is a four-tuple of (hour, minute, second, microsecond). No caller in the image uses it — the only ms_until qstrs elsewhere belong to espnow_conn_v2’s unrelated ms_until_radio_on.

The calendar functions (CONFIRMED, f_lib_rtc_v2.dis:362814)

The epoch is 1970-01-01 UTC — the - 1970 and the 31536000 (365 days) at f_lib_rtc_v2.dis:511513. There is no timezone term anywhere in the module, so what time_to_unix returns is UTC if and only if the RTC was set to UTC. 1968 in is_leap_year is a leap year chosen as the base, which makes the test arithmetically identical to yyyy % 4 == 0. leap_years_since(y) counts the leap years in [1969, y), plus y itself when y is a leap year — which is why both time_to_unix and unix_to_time carry a correction for that case.
time_to_unix is exact for the whole plausible life of the device. Re-implementing the reconstruction above and comparing it against Python’s datetime for every day from 1970-01-01 to 2099-12-31 (47,482 days, at 12:34:56 each) gives zero mismatches. The maths is right.is_leap_year disagrees with the Gregorian calendar at exactly one year in 1970-2199: 2100, which it calls a leap year and which is not one.
unix_to_time is not the inverse of time_to_unix at the end of a year. Running the same check in reverse — unix_to_time(true_unix_seconds) against the true date, every day from 1970 to 2099 — gives 1,681 wrong days out of 47,482, spread over 97 of the 130 years. They are always a contiguous window at the end of December, and the window grows by roughly one day every four years as the leap-day offset accumulates:Every affected year’s window is a contiguous run ending on 31 December, with no isolated bad days elsewhere in the year; 33 of the 130 years — 2027 and 2031 among them — have none at all.Inside the window the result is one day ahead; on 31 December it rolls into the next year and returns day 0 of January, a date that does not exist. The cause is the pair of corrections at f_lib_rtc_v2.dis:760773: if is_leap_year(yyyy2): days += 1 and if yyyy2 != yyyy1: days += 1, which both fire once the first days // 365 estimate lands in the following year.The function is used in two places — espnow_conn_v2 and ubx_gnss (confirmed, from the IMPORT_FROM unix_to_time sites at espnow_conn_v2.dis:731 and ubx_gnss.dis:313). Anything on those paths that formats or compares a converted date in late December is working from a wrong day.

timestamp_to_unix and what goes on the wire (CONFIRMED, f_lib_rtc_v2.dis:542690)

rtc.unix(precision=...) is a thin wrapper over timestamp_to_unix, which parses the string timestamp produces rather than reading the RTC tuple again. It validates with re.match against \d\d\d\d-\d\d-\d\d\s\d\d:\d\d:\d\d (:560) and returns None for a falsy, blank or non-matching input — three separate early returns. The match is anchored at the start only, so the trailing fractional seconds are allowed through and then split off on '.'.
Every mesh call site passes precision=1. All seven rtc.unix(...) calls in espnow_conn_v2 load LOAD_CONST_SMALL_INT 1 for the precision keyword (espnow_conn_v2.dis:1726, :1809, :7059, :7213, :7361, :7991, :9565), so the value that reaches the ESP-NOW frames is a plain integer second count, not a float. That is consistent with the signed 32-bit time field described in message format.Callers that omit precision get the float default, which is why some log lines carry a fractional Unix time and others do not.
ms_until_sub_interval(interval_sec=6) returns the milliseconds until the next wall-clock second that is a multiple of interval_sec, minus the milliseconds already elapsed in the current second. On an exact boundary it returns a full interval rather than zero. Its only caller is compass (compass.dis:9737). The default of 6 seconds is the MAKE_FUNCTION_DEFARGS constant at f_lib_rtc_v2.dis:100.

f_lib/rtc_mem.py

A byte-packed allocator over machine.RTC().memory(). One class, one singleton rtc_mem = RtcMem() (f_lib_rtc_mem.dis:8587), six importers. That RTC slow memory survives a reset but not a power cycle is an ESP32 property, inferred, not something this module states.

Frame format (CONFIRMED)

Every frame is five header bytes plus a payload:
rtc_mem never checks the magic. add validates only that the payload is at least 5 bytes and that len(payload) == 5 + payload[4] (f_lib_rtc_mem.dis:161196); reload walks the blob taking blob[i + 4] as a length and blob[i + 2] as a category with no signature test at all. b'\xa7t' is a convention the writers observe (ble_manager.dis:6912, project_data.dis:2243) — it is not validated on the way back in.The practical consequence: a corrupted length byte does not fail a magic check, it re-frames the rest of RTC memory. reload stops when the next frame would run past the end of the blob, but everything up to that point has already been indexed under whatever category bytes the garbage happened to contain.
The three categories in use, all confirmed from their producers and consumers: Category 0’s writer is worth reading: it builds bytearray(5), writes the magic into [0:2], the category into [2], the service id (1 or 2) into [3] and 0 into [4], then calls delete_cat followed by add (ble_manager.dis:69066952). project_main reads frame[3], deletes the frame and dispatches on it — see boot flow. For category 1, device_power reads ble_keys.bin whole and hands the bytes straight to add, and writes get(offset) straight back to the file. The file on flash therefore already contains the 5-byte frame header; it is not a bare key blob.

The API (CONFIRMED)

add refuses to grow the blob past 2048 bytes (f_lib_rtc_mem.dis:214, warning 'Not enough space in RTC mem'), appends at self.mem_size, and writes the whole buffer back with self._rtc.memory(bytes(buf)). delete compacts the blob by copying the tail down over the removed frame, truncates, writes back, and then rebuilds the index in its finally (:582584). Every method except delete_cat wraps its body in try/except Exception (SETUP_EXCEPT at :198, :336, :445, :617, :702 and :854) and logs at exc level with is_write=False, so none of this module’s failures reach errors.log. Four behaviours that are not obvious from the names:
add’s is_del_if_exists parameter is never read. It is declared with a default of False (f_lib_rtc_mem.dis:102106, :149) and LOAD_FAST 2 — the only opcode that could read it — appears nowhere in the 184-line body (:146329). Callers that want replace-not-append semantics must call delete_cat first, which is exactly what ble_manager and project_data do. This is the same dead-parameter shape as pack_utf8_str’s max_size above.delete_cat deletes only the first frame of a category. It takes self.contents[cat_id][0] and passes it to delete (:392405). If a category ever holds two frames, the second survives.
clear() does not clear self.contents. It writes b'' to RTC memory and zeroes frame_count and mem_size (f_lib_rtc_mem.dis:335351), but the category index is left populated — LOAD_METHOD clear on contents appears only in _rebuild_contents (:862, :951). After a clear() the object still reports offsets that no longer exist; a following delete_cat takes one of them and delete rejects it as an invalid byte index. It fails safe, but it fails noisily and for the wrong reason.clear() also returns None on success and False on error, where every sibling returns a real boolean.
reload() appends to the index; _rebuild_contents() replaces it. reload never clears self.contents before walking (f_lib_rtc_mem.dis:701805), so calling it twice registers every offset twice. _rebuild_contents calls self.contents.clear() first (:855862) and is the one used after a delete. In practice reload() runs once, from project_main’s module body — see boot flow.On an exception, reload logs and then sets frame_count = 0, mem_size = 0 and _is_reloaded = True (:824830) — without clearing the RTC blob itself. The next add then writes at offset 0 into a copy of the old blob and stores the whole thing back, so the stale tail past the new frame is preserved on the device while mem_size says it is not there. Confirmed from the code; the resulting re-parse on the next boot is inferred.
get and add share an error string. Both log 'Uncaught error recording volume' (f_lib_rtc_mem.dis:305 and :671, the same obj_table entry). Neither has anything to do with volume. A log line with that text identifies the module but not the operation.

The small modules

f_lib/async_helpers.py (CONFIRMED, 348 lines, 2 importers)

Two classes, both used exactly once in the image.
EventTimeout turns “my own task was cancelled” into a TimeoutError. The countdown task sleeps, then clears _timeout_task and cancels the task that built the object; __exit__ uses that cleared attribute as the signal that the cancellation was its own doing rather than someone else’s. It is a synchronous context manager — __enter__ / __exit__, used with with, not async with — and __enter__ returns None, so binding it with as yields None. f_ble/ble_lite.py is the only user, at three sites (f_ble_ble_lite.dis:490, :598, :721).
An async-iterable bounded queue, used once, by f_ble/ble_data.py (f_ble_ble_data.dis:350). Three notes:
  • The queue silently drops the oldest item when it is full. super().__init__ passes only (seq, queue_size) — no third flags argument (f_lib_async_helpers.dis:257262) — and MicroPython’s deque raises on overflow only when the overflow-check flag is set. This is inferred from MicroPython’s deque semantics, not from this bytecode, but the absent argument is confirmed.
  • _max_len is stored and never read. The qstr appears nowhere else in the image.
  • The default seq is a shared mutable [] (:233237), the same shape as walk_up’s. Harmless here, since MicroPython’s deque only accepts an empty initial sequence anyway.

f_lib/gzip.py (CONFIRMED, 149 lines, 1 importer)

A thin shim over the native deflate module. GzipFile(fileobj) is a function, not a class: it returns deflate.DeflateIO(fileobj, deflate.GZIP, 15) (f_lib_gzip.dis:6477). open(filename, mode='rb') wraps builtins.open the same way with a fourth argument True, which makes the DeflateIO close the underlying file (:7896). compress and decompress round-trip through io.BytesIO. The interesting line is in the module body: compress and decompress are defined only if hasattr(deflate.DeflateIO, 'write') (:5160). On a build without deflate write support the two names simply do not exist. Its only importer is f_lib/unpack.py, which uses gzip.open alone.

f_lib/tarfile.py (CONFIRMED, 725 lines, 1 importer)

The upstream micropython-lib read-only tar reader: _roundup, FileSection, TarInfo, TarFile. TarFile is iterable and yields TarInfo objects; extractfile(tarinfo) returns the FileSection positioned at that member’s bytes. Two things are worth knowing before trusting a tar on this device.
The header parser reads two fields and ignores the rest. _TAR_HEADER is a uctypes descriptor with exactly name (bytes 0-99) and size (bytes 124-134, octal ASCII) — f_lib_tarfile.dis:93116. The 512-byte POSIX header’s checksum is never computed or compared, and neither are the typeflag, mode, uid, gid, mtime, linkname, magic or prefix fields.TarInfo.type is derived from the name alone: mode is set to 16384 if the name ends in / and 32768 otherwise (:350371), and isdir / isreg test that synthetic mode. A corrupted or hostile archive is not detected here.Write support is optional and absent from this image: TarFile’s class body does from .write import _open_write, _close_write, addfile, add inside a try/except ImportError (:459485), and there is no such module among the 94. A TarFile opened in any mode other than 'r' raises NotImplementedError('Install tarfile-write') (:533538).
TarFile.next() returns None at end-of-archive — either a short read or a header whose first name byte is 0 (:578605) — and __next__ turns that into StopIteration. Member content is read through FileSection, which tracks the declared length and the padding to the next 512-byte boundary and skips it on the way to the next header.

f_lib/unpack.py (CONFIRMED, 229 lines, 1 importer)

The two-step OTA archive unpacker, and the only importer of f_lib/tarfile.py and f_lib/gzip.py. Its own only importer is f_ota/install_ota.py (f_ota_install_ota.dis:278, :280).
unzip_tar decompresses .tgz to .tar; unpack_tar then extracts it. PaxHeader members are skipped by substring match on the name (f_lib_unpack.dis:156161).
Member names are not sanitised. out is dest_path + '/' + info.name with no check for a leading / or for .. components, and make_parents(out) will happily create the path (f_lib_unpack.dis:162168, :199202). An archive member named ../../boot.py writes outside the destination directory. Nothing in this module, in f_lib/tarfile.py or in f_lib/file_mgr.make_parents rejects it. Confirmed from the absence of any such check; whether the OTA archives are attacker-reachable is a question for OTA, not for this page.Both with blocks also call close() on the handle inside or immediately after the block (:121126, :219221), which is redundant — the context manager has already closed it.

f_lib/firmware_helpers.py (CONFIRMED, 75 lines, 1 importer)

One function, and the smallest module in the package that contains any code.
esp32.Partition.info() returns a six-tuple whose element 4 is the partition label, so this reports which app slot the device booted from. It reports failure by returning False and prints the traceback straight to the console with sys.print_exception rather than through f_lib/logger.py, so a failure here leaves nothing in errors.log. Nothing calls it. project_main imports the name into its module namespace (project_main.dis:415419) and then never loads it again — the qstr running_partition appears four times in project_main.dis, all in that import, and in no other disassembly. Since ota_daemon does from project_main import *, the effect is to make running_partition() available at the REPL, which is inferred to be the point of it.

f_lib/__init__.py (CONFIRMED)

Empty. The entire module body is LOAD_CONST_NONE / RETURN_VALUE (f_lib___init__.dis:1314). f_lib is a plain namespace with no package-level exports, which is why every importer names a submodule explicitly.

What is not recoverable

  • Docstrings, comments and parameter intent. Frozen .mpy files carry no docstrings. Whether pack_utf8_str’s max_size was meant as a truncation bound or a buffer-capacity assertion cannot be answered from the image; only that it does neither.
  • Why the write level is fixed at 4. Whether write_lvl was intended to be configurable and the setter was never written, or 4 was always the design, is not recoverable.
  • Original local variable names inside function bodies. The bytecode carries argument names but not locals, so names like max_val, mask, exc_txt and out in the reconstructions above are ours except where a qstr fixes them (data, b_len, packed_byte, out, file). The same applies to every reconstruction further down this page — done, rel, dst, ok, blob, buf, size, cat_id, doy and the loop variables are ours.
  • What Task.done() actually tests. Task comes from the native _asyncio module, which is C compiled into the firmware binary and is not part of the frozen bytecode. So the exact condition Tasks.cleanup is filtering on is outside this image. The same is true of deque’s overflow behaviour behind AsyncDeque and of deflate.DeflateIO’s stream handling behind f_lib/gzip.py.
  • The timezone the RTC holds. f_lib/rtc_v2.py has no timezone term anywhere. Whether time_to_unix yields real UTC depends entirely on what set the RTC, which is a question for the modules that set it, not for this one.
  • Whether the known defects were known. unix_to_time’s end-of-year drift, the move_files error path that calls itself, the save_obj return value that is True on most failures, rtc_mem.clear’s stale index and the never-read is_del_if_exists and _max_len parameters are all confirmed from the bytecode. Whether any of them was known, intended or simply never hit is not something the image can answer.
  • Byte 3 of an RTC-memory frame. It is a header slot whose meaning is set entirely by the producer and consumer of each category — a service id for category 0, whatever ble_keys.bin happens to carry for category 1, zero for category 2. There is no module-level definition of it to recover.
  • The provenance of OTA archives. f_lib/tarfile.py and f_lib/unpack.py validate neither the tar checksum nor member paths. Whether that matters depends on how the archives are built and served, which is not in this image.
  • The const import in logger.py. micropython.const is folded at compile time, so whether the level numbers had names like LVL_ERR in the source, and what they were, is gone.
  • Runtime behaviour of the string paths on non-str/bytes input. The branch structure is confirmed; the exact MicroPython semantics for bytearray in isinstance(x, bytes) and for resizing slice assignment on a bytearray are inferred from the interpreter, not observed on hardware.