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:62–113). 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 theMAKE_FUNCTION_DEFARGS tuples built in the module
body (f_lib_bitwise.dis:66–111).
Bit fields (CONFIRMED, f_lib_bitwise.dis:270–342, :456–473)
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:215–268, :428–454)
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:885–891). 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:343–368, :474–504)
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
enumerateis the shift (f_lib_bitwise.dis:360–364), so the first item in apack_flagstuple is the least significant bit. - Truthiness, not
bool. The test is a barePOP_JUMP_IF_FALSEon the element (:359). Any truthy object sets the bit;0,None,''andFalseall clear it. Thepack_flags(is_sos, is_eco_mode, ...)expressions documented elsewhere are passing raw mode attributes, not normalised booleans. pack_flagshas no eight-element limit;unpack_flagshas nothing else.pack_flagsiterates the whole sequence, so a nine-element argument can return a value above 255 (whichstruct.packinto aBwould then reject).unpack_flagsis a fixedrange(8)loop (:481–501) and always returns exactly eightbools, so it cannot round-trip such a value.
unpack_flags’s packed_byte=None default (:474, defargs tuple at :100–103) 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:370–426, :506–525)
str_to_bin, both confirmed from the branch structure at
f_lib_bitwise.dis:399–426:
- Falsy input returns
b'', not an error.None,'',0andb''all take the first jump (POP_JUMP_IF_FALSEat:400) toLOAD_CONST_OBJ b''. Sopack_utf8_str(buff=b, start=i, text=None)writes nothing and returns0. - Only
strandbytesare handled directly. Anything else goes throughstr(val).encode('utf-8')(:418–424). That abytearrayormemoryviewtherefore serialises as itsrepr—bytearray(b'...')— rather than its contents is inferred from MicroPython’s type hierarchy, in whichbytearrayis not a subclass ofbytes; it was not observed on a device.
unpack_utf8_str copies through bytes(...) before decoding (:511–519), 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:115–214)
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 (:170–176) 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 theIMPORT_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:6161–6175): 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:97–144). 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)
LOAD_CONST_SMALL_INT 4 in the defargs tuple at f_lib_logger.dis:197–
201. 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).
Levels (CONFIRMED)
The names come from the tuple inobj_table (f_lib_logger.dis:91), indexed by level in
_log (:374–376).
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)
f_lib_logger.dis:449–573; the is_write defaults are the
LOAD_CONST_FALSE / LOAD_CONST_TRUE entries in the defargs tuples at :217–255. 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_lvlgates the record entirely, not just the console. Adebugcall at print level 4 never reaches_log, so itsis_writeis irrelevant. Raising verbosity is the only way to get lower-severity records anywhere at all.errandexccannot be silenced. They have no gate, and_logitself has no level check — it formats and prints unconditionally. Setting the print level to 5 does not suppress errors.
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:289–447)
--, 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:143–145 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:239–280). A record therefore
looks like this (assembled from the confirmed format, not a captured line):
», 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:326–328) that no later opcode reads —LOAD_FAST 8does not appear again in the function. The exception type still reaches the record, but only as part ofprint_exception’s traceback text.- Only
is Noneis tested for title and body, then.strip()is called. A non-string title (anint, say) raisesAttributeErrorfrom 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:627–664)
stat(...)[6] is st_size; the threshold 8000 is LOAD_CONST_SMALL_INT 8000 at
f_lib_logger.dis:641.
_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:1596–1603 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.
print_logs (CONFIRMED, f_lib_logger.dis:574–625)
obj_table (f_lib_logger.dis:91). This is what
project_main.show_logs() calls.
exists (CONFIRMED, f_lib_logger.dis:666–687)
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:146–186)
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:4–90), 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:319–326), 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:992–1020). On the next boot, start does
(project_main.dis:1242–1270):
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:1272–1280).
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:1226–1245, :1399–1407).
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:35–304)
- The two
delstatements areLOAD_NULL/ROT_THREE/STORE_SUBSCR(f_lib_task_mgr.dis:151–153and:300–:302) — MicroPython’s encoding fordel obj[key]. - The
**kwargsonlaunch,scheduleand_scheduleare the var-keyword scope flag in each prelude, and the calls areCALL_FUNCTION_VAR_KW/CALL_METHOD_VAR_KW(:177,:190,:216,:254). Eventis imported at:48and no opcode in the module loads it again. It is a dead import;_schedulecallsevent.wait()on whatever object the caller passed.tasks = Tasks()isSTORE_NAME tasksat:58.
The registry (CONFIRMED)
Four consequences of this design matter elsewhere on this site.
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:184–192).
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:161–164) — 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 theMAKE_FUNCTION_DEFARGS tuples in the module body
(f_lib_file_mgr.dis:164–272).
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:934–998, :1202–1268)
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:2430–2441).
The OTA file install: move_files (CONFIRMED, f_lib_file_mgr.dis:783–933)
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 directory branch is dead.
walk_upappends a path only when the entry mode is notVFS_DIRand recurses otherwise (:1408–1425), so every element of the list is a regular file andis_dir(src)is always false. Theos.mkdirarm can never run; directories are created by themake_parents(dst)call above it. - A partial install is not rolled back. Each file is moved with an individual
os.rename, and a failure setsok = Falseand continues with the rest of the list. The staging directory is kept (thermtreeis insideif 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_hashis a coroutine. It returnshashlib.sha256(...).digest()— 32 raw bytes, not hex (:561,:612) — reading the file inlen(buff)-sized chunks throughreadintointo amemoryview, withbuffdefaulting tobytearray(256). It awaitsasyncio.sleep_ms(0)everyyield_everyreads (default 4), so hashing a large file does not stall the event loop. A missing file logs'File not found, cannot gen hash for: {}'aterrlevel and returnsNone; any other exception logs'Cannot gen hash for: {}'atexclevel and returnsNone. Both passis_write=False, so neither reacheserrors.log.compress_fileis also a coroutine:deflate.DeflateIO(out, deflate.ZLIB)over 256-byte reads, awaitingasyncio.sleep_ms(pause_ms)after each chunk (default 10 ms). It raisesFileErr(..., 'NotFound')for a missing input and a plainFileErrif the name’s second dot-separated field is alreadygz; the output path defaults to the input with.gzappended to its first field. Despite the.gzname the stream is zlib, not gzip (:361,:389).vfs_storage()returns(total, free, used)in bytes, computed fromos.statvfs('/')asf_bsize * f_blocksandf_bsize * f_bfree(:1352–1375). Note it usesf_bsize(index 0), notf_frsize(index 1).get_filesyields names, not paths, and only for entries whose mode is exactlyVFS_FILE; directories are skipped entirely. Itsdir_pathdefault is the empty string, soget_oldest_fileandremove_oldest_log, which never pass one, scan only the filesystem root.vfs_deljoins with no separator —os.remove(dir_path + entry_name)(:1312–1318). That is correct only becausedir_pathdefaults to'/'; a caller passing'logs'would try to removelogsfoo.bin. It is also non-recursive, and it returns early whenfile_typesis falsy.rmtreerecurses withis_del_root=Trueregardless of the caller’s argument (:1168–1177), sois_del_root=Falsespares only the top directory. It has notry/except: oneOSErroraborts the walk part-way through.write_bin_lineopens with'ab'and appendsb'\n'after every record (:1459–1463). This is the newline-terminated binary-record convention used by the event and message logs.
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:91–93). 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:118–361)
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:343–348) — 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:362–814)
- 1970 and the 31536000 (365 days) at
f_lib_rtc_v2.dis:511–513. 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.timestamp_to_unix and what goes on the wire (CONFIRMED, f_lib_rtc_v2.dis:542–690)
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:85–87), 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:
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:6906–6952). 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 (:582–584). 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:
reload() appends to the index; _rebuild_contents() replaces it. reload never
clears self.contents before walking (f_lib_rtc_mem.dis:701–805), so calling it
twice registers every offset twice. _rebuild_contents calls self.contents.clear()
first (:855–862) 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 (:824–830) — 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).
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 thirdflagsargument (f_lib_async_helpers.dis:257–262) — and MicroPython’sdequeraises on overflow only when the overflow-check flag is set. This is inferred from MicroPython’sdequesemantics, not from this bytecode, but the absent argument is confirmed. _max_lenis stored and never read. The qstr appears nowhere else in the image.- The default
seqis a shared mutable[](:233–237), the same shape aswalk_up’s. Harmless here, since MicroPython’sdequeonly 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:64–77). open(filename, mode='rb') wraps builtins.open the same way
with a fourth argument True, which makes the DeflateIO close the underlying file
(:78–96). 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') (:51–60). 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.
TarFile.next() returns None at end-of-archive — either a short read or a header whose
first name byte is 0 (:578–605) — 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:156–161).
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:415–419) 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:13–14). 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
.mpyfiles carry no docstrings. Whetherpack_utf8_str’smax_sizewas 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_lvlwas 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_txtandoutin 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,doyand the loop variables are ours. - What
Task.done()actually tests.Taskcomes from the native_asynciomodule, which is C compiled into the firmware binary and is not part of the frozen bytecode. So the exact conditionTasks.cleanupis filtering on is outside this image. The same is true ofdeque’s overflow behaviour behindAsyncDequeand ofdeflate.DeflateIO’s stream handling behindf_lib/gzip.py. - The timezone the RTC holds.
f_lib/rtc_v2.pyhas no timezone term anywhere. Whethertime_to_unixyields 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, themove_fileserror path that calls itself, thesave_objreturn value that isTrueon most failures,rtc_mem.clear’s stale index and the never-readis_del_if_existsand_max_lenparameters 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.binhappens 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.pyandf_lib/unpack.pyvalidate 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
constimport inlogger.py.micropython.constis folded at compile time, so whether the level numbers had names likeLVL_ERRin the source, and what they were, is gone. - Runtime behaviour of the string paths on non-
str/bytesinput. The branch structure is confirmed; the exact MicroPython semantics forbytearrayinisinstance(x, bytes)and for resizing slice assignment on abytearrayare inferred from the interpreter, not observed on hardware.