> ## Documentation Index
> Fetch the complete documentation index at: https://totem-cb8b3887.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# f_lib: the support library

> Twelve f_lib modules decoded from the v5.0.3 bytecode — the task manager every coroutine on this device is launched through, the file manager, the RTC calendar and the RTC-memory allocator, the pack/unpack primitives underneath every wire format, and the logger that produced every log line quoted on this site.

`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](https://totem-cb8b3887.mintlify.site/protocols/overview.md) 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](https://totem-cb8b3887.mintlify.site/reference/modules.md); this page is the decode.

> [!NOTE]
>
> **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.

| Module | Lines of disassembly | Importers | What rests on it |
| - | - | - | - |
| `f_lib/logger.py` | 688 | 41 | Every log line quoted as evidence |
| `f_lib/task_mgr.py` | 305 | 28 | Every async task in the firmware |
| `f_lib/bitwise.py` | 526 | 18 | Every documented wire format |
| `f_lib/file_mgr.py` | 1470 | 18 | Config files, logs, OTA file install |
| `f_lib/rtc_v2.py` | 815 | 13 | Every timestamp and Unix time on the wire |
| `f_lib/rtc_mem.py` | 970 | 6 | Reboot mailbox, BLE bonds, device snapshot |
| `f_lib/async_helpers.py` | 348 | 2 | BLE timeouts and the BLE RX queue |
| `f_lib/tarfile.py` | 725 | 1 | Unpacking the OTA `.tgz` |
| `f_lib/unpack.py` | 229 | 1 | Unpacking the OTA `.tgz` |
| `f_lib/gzip.py` | 149 | 1 | Unpacking the OTA `.tgz` |
| `f_lib/firmware_helpers.py` | 75 | 1 | Reporting the running partition |
| `f_lib/__init__.py` | 15 | — | Nothing: the file is empty |

"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](https://totem-cb8b3887.mintlify.site/reference/modules.md).

`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 the `MAKE_FUNCTION_DEFARGS` tuples built in the module
body (`f_lib_bitwise.dis:66`–`111`).

| Function | Signature | Returns | Disassembly |
| - | - | - | - |
| `bin_to_hex` | `(data, sep=None)` | `bytes` of hex | `:115` |
| `bin_to_str` | `(data, sep=None)` | `str` of hex | `:134` |
| `get_mac_addr` | `()` | 12 lower-case hex chars | `:158` |
| `hex_to_bin` | `(val)` | `bytes` | `:197` |
| `pack_2bit_value` | `(byte_val, value, start_bit)` | `int` | `:215` |
| `pack_bits` | `(byte_val, start_bit, bit_width, value)` | `int` | `:270` |
| `pack_flags` | `(flags)` | `int` | `:343` |
| `pack_utf8_str` | `(buff=None, start=0, text=None, max_size=32)` | byte count written | `:370` |
| `str_to_bin` | `(val)` | `bytes` | `:394` |
| `unpack_2bit_value` | `(byte_val, start_bit)` | `int` 0-3 | `:428` |
| `unpack_bits` | `(byte_val, start_bit, bit_width)` | `int` | `:456` |
| `unpack_flags` | `(packed_byte=None)` | `list` of 8 `bool` | `:474` |
| `unpack_utf8_str` | `(buff=None, start=0, b_len=0)` | `str` | `:506` |

### Bit fields *(CONFIRMED, `f_lib_bitwise.dis:270`–`342`, `:456`–`473`)*

```python theme={null}
def pack_bits(byte_val, start_bit, bit_width, value):
    if not (0 <= start_bit <= 7) or bit_width < 1 or start_bit + bit_width > 8:
        raise ValueError('Bit range must satisfy 0 <= start_bit and start_bit + bit_width <= 8')
    max_val = (1 << bit_width) - 1
    if not (0 <= value <= max_val):
        raise ValueError('Value must be 0-%d for a %d-bit range' % (max_val, bit_width))
    mask = max_val << start_bit
    return (byte_val & ~mask) & 0xFF | (value << start_bit)


def unpack_bits(byte_val, start_bit, bit_width):
    return (byte_val >> start_bit) & ((1 << bit_width) - 1)
```

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](https://totem-cb8b3887.mintlify.site/subsystems/navigation.md) call it directly on parsed words.

### Two-bit tri-state fields *(CONFIRMED, `f_lib_bitwise.dis:215`–`268`, `:428`–`454`)*

```python theme={null}
def pack_2bit_value(byte_val, value, start_bit):
    if not (0 <= value <= 2):
        raise ValueError('Value must be 0, 1, or 2')
    if not (0 <= start_bit <= 6):
        raise ValueError('start_bit must be 0-6 to fit 2 bits')
    byte_val &= ~(3 << start_bit)
    byte_val |= value << start_bit
    return byte_val


def unpack_2bit_value(byte_val, start_bit):
    if not (0 <= start_bit <= 6):
        raise ValueError('start_bit must be 0-6 to fit 2 bits')
    return (byte_val >> start_bit) & 3
```

> [!WARNING]
>
> **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 (`:449`–`453`). 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: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`)*

```python theme={null}
def pack_flags(flags):
    packed = 0
    for i, f in enumerate(flags):
        if f:
            packed |= 1 << i
    return packed


def unpack_flags(packed_byte=None):
    flags = []
    for i in range(8):
        flags.append(bool(packed_byte & (1 << i)))
    return flags
```

This is the function behind every `pack_flags(...)` bit order quoted in
[message format](https://totem-cb8b3887.mintlify.site/protocols/message-format.md) and
[building a client](https://totem-cb8b3887.mintlify.site/reference/building-a-client.md). 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:360`–`364`), 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 (`:481`–`501`) and always returns exactly eight `bool`s, 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`)*

```python theme={null}
def str_to_bin(val):
    if val:
        if isinstance(val, str):
            return val.encode('utf-8')
        if isinstance(val, bytes):
            return val
        return str(val).encode('utf-8')
    return b''


def pack_utf8_str(buff=None, start=0, text=None, max_size=32):
    data = str_to_bin(text)
    n = len(data)
    buff[start:start + n] = data
    return n


def unpack_utf8_str(buff=None, start=0, b_len=0):
    return bytes(buff[start:start + b_len]).decode('utf-8')
```

> [!WARNING]
>
> **`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 (`:375`–`392`). 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](https://totem-cb8b3887.mintlify.site/protocols/message-format.md).

Two more edges in `str_to_bin`, both **confirmed** from the branch structure at
`f_lib_bitwise.dis:399`–`426`:

* **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')` (`:418`–`424`). That a `bytearray` or `memoryview` therefore
  serialises as its `repr` — `bytearray(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 (`: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`)*

```python theme={null}
def bin_to_hex(data, sep=None):
    if sep:
        return binascii.hexlify(data, sep)
    return binascii.hexlify(data)


def bin_to_str(data, sep=None):
    if isinstance(data, str):
        return data
    return bin_to_hex(data, sep=sep).decode('utf-8')


def hex_to_bin(val):
    if isinstance(val, str):
        return binascii.unhexlify(val)
    return val


def get_mac_addr():
    from machine import unique_id
    return ''.join(['{:02x}'.format(b) for b in unique_id()])
```

`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](https://totem-cb8b3887.mintlify.site/subsystems/ota.md) and
[WiFi](https://totem-cb8b3887.mintlify.site/protocols/wifi-ota.md) 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 the `IMPORT_FROM` opcodes that follow each `IMPORT_NAME f_lib.bitwise` across the
18 importing modules:

| Primitive | Modules | Which |
| - | - | - |
| `pack_flags` | 7 | `ble_core`, `ble_manager`, `espnow_conn_v2`, `espnow_msg`, `f_ble/chunking`, `nav_logger`, `project_data` |
| `bin_to_str` | 6 | `ble_manager`, `compass`, `espnow_conn_v2`, `f_ble/chunking`, `peer_auto_bond`, `peer_management` |
| `get_mac_addr` | 5 | `compass`, `data_upload_v2`, `f_ota/config`, `f_ota/main`, `ota_callback` |
| `hex_to_bin` | 5 | `ble_core`, `ble_manager`, `chat_msg`, `compass`, `espnow_conn_v2` |
| `pack_utf8_str` | 4 | `ble_core`, `ble_manager`, `espnow_conn_v2`, `f_ble/chunking` |
| `str_to_bin` | 3 | `ble_manager`, `data_upload_v2`, `f_lib/wifi_v2` |
| `unpack_utf8_str` | 3 | `ble_manager`, `espnow_conn_v2`, `ota_ble` |
| `unpack_bits` | 2 | `ble_manager`, `ubx_gnss` |
| `unpack_flags` | 2 | `ble_manager`, `project_data` |
| `pack_2bit_value` | 1 | `nav_logger` |
| `pack_bits` | 1 | `ble_manager` |

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)*

```python theme={null}
log = Logger()   # f_lib_logger.dis:135-137


class Logger:
    def __init__(self, print_lvl=4, write_lvl=4):
        self._print_lvl = print_lvl
        self._write_lvl = write_lvl
```

Both defaults are `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`).

> [!WARNING]
>
> **`_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` (`:374`–`376`).

| Level | Name | Method | Printed when | Written when |
| - | - | - | - | - |
| 0 | `NOT` | — | no wrapper emits level 0 | — |
| 1 | `DBG` | `debug` | `_print_lvl <= 1` | never, in practice |
| 2 | `INF` | `info` | `_print_lvl <= 2` | never, in practice |
| 3 | `WAR` | `warn` | `_print_lvl <= 3` | never, in practice |
| 4 | `ERR` | `err` | **always** | `is_write` and `4 >= _write_lvl` |
| 5 | `EXC` | `exc` | **always** | `is_write` and `5 >= _write_lvl` |

`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)*

```python theme={null}
    def debug(self, title=None, body=None, exc=None, is_write=False):
        if self._print_lvl <= 1:
            self._log(lvl=1, title=title, body=body, exc=exc, is_write=is_write)

    def info(self, title=None, body=None, exc=None, is_write=False):
        if self._print_lvl <= 2:
            self._log(lvl=2, title=title, body=body, exc=exc, is_write=is_write)

    def warn(self, title=None, body=None, exc=None, is_write=True):
        if self._print_lvl <= 3:
            self._log(lvl=3, title=title, body=body, exc=exc, is_write=is_write)

    def err(self, title=None, body=None, exc=None, is_write=True):
        self._log(lvl=4, title=title, body=body, exc=exc, is_write=is_write)

    def exc(self, title=None, body=None, exc=None, is_write=True):
        self._log(lvl=5, title=title, body=body, exc=exc, is_write=is_write)
```

Bodies at `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_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`):

| Method | Call sites |
| - | - |
| `debug` | 291 |
| `info` | 108 |
| `warn` | 96 |
| `exc` | 45 |
| `err` | 31 |
| `set_print_level` | 1 |

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`)*

```python theme={null}
    def _log(self, lvl=0, title=None, body=None, exc=None, is_write=True):
        title = '--' if title is None else title.strip()
        body = '--' if body is None else body.strip()
        exc_txt = '--'
        if exc:
            from io import StringIO
            _ = exc.__class__.__name__          # computed, never used
            buf = StringIO()
            print_exception(exc, buf)
            exc_txt = buf.getvalue()
            if exc_txt[-1:] == '\n':
                exc_txt = exc_txt[:-1]
            exc_txt = exc_txt.replace('\n', ' » ')
            exc_txt = ' '.join(exc_txt.split())
        out = '{} | {} | {} | {} | {}'.format(
            rtc.timestamp, ('NOT', 'DBG', 'INF', 'WAR', 'ERR', 'EXC')[lvl],
            title, body, exc_txt)
        if lvl <= 1:
            print('\x1b[37m' + out + '\x1b[0m')     # grey
        elif lvl == 2:
            print('\x1b[94m' + out + '\x1b[0m')     # bright blue
        elif lvl == 3:
            print('\x1b[33m' + out + '\x1b[0m')     # yellow
        elif lvl > 3:
            print('\x1b[31m' + out + '\x1b[0m')     # red
        if is_write and lvl >= self._write_lvl:
            self._write(out)
```

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: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):

```text theme={null}
2025-06-01 12:34:56.000123 | ERR | Battery critical | pct=3 | --
```

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:326`–`328`) 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:627`–`664`)*

```python theme={null}
    def _write(self, out):
        if exists('errors.log') and stat('errors.log')[6] > 8000:
            return
        f = open('errors.log', 'a')
        f.write(out + '\n')
        f.close()
```

`stat(...)[6]` is `st_size`; the threshold `8000` is `LOAD_CONST_SMALL_INT 8000` at
`f_lib_logger.dis:641`.

> [!WARNING]
>
> **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: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.

| Site | What it does |
| - | - |
| `f_lib_logger.dis:580`, `:598`, `:633`, `:637`, `:647` | `print_logs` reads it; `_write` stats, opens and appends to it |
| `project_main.dis:764`–`790` | `show_logs(del_logs=None)` prints the log, then `os.remove('errors.log')` if `del_logs` is truthy — a REPL helper |
| `ota_callback.dis:757`–`763` | On OTA step 7 completion, `if exists('errors.log'): remove('errors.log')` |

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`)*

```python theme={null}
    def print_logs(self):
        if not exists('errors.log'):
            print('No log file on device')
            return
        print('\x1b[94m' + '--- Logged Events ---' + '\x1b[0m')
        with open('errors.log') as f:
            for line in f:
                print(line.strip())
        print('\x1b[94m' + '--- End Logged Events ---' + '\x1b[0m')
```

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:666`–`687`)*

```python theme={null}
def exists(file=None):
    try:
        stat(file)
        return True
    except OSError:
        return False
```

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:146`–`186`)*

A bare class used as a string enum: fifteen class attributes, each a `str`. Eleven modules
import it.

| Attribute | Value | Attribute | Value |
| - | - | - | - |
| `conn_failed` | `ConnFailed` | `param_invalid` | `ParamInvalid` |
| `invalid_auth` | `InvalidAuth` | `syntax_err` | `SyntaxErr` |
| `limit_reached` | `LimitReached` | `timeout` | `Timeout` |
| `mem_err` | `MemError` | `hardware_err` | `HardwareErr` |
| `not_found` | `NotFound` | `invalid_len` | `InvalidLength` |
| `not_ready` | `NotReady` | `invalid_val` | `InvalidValue` |
| `operation_failed` | `OperationFailed` | `unknown_msg` | `UnknownMsg` |
| `param_missing` | `ParamMissing` | | |

> [!NOTE]
>
> The [module reference](https://totem-cb8b3887.mintlify.site/reference/modules.md) 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`):

```python theme={null}
if exists('debug.mode'):
    d = rebuild_obj('debug.mode')
    if 'lvl' in d:
        log.set_print_level(lvl=d['lvl'])
    os.remove('debug.mode')
```

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: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`)*

```python theme={null}
import asyncio
from asyncio import Event          # imported, then never referenced again


class Tasks:
    def __init__(self):
        self.cur = {}

    def cleanup(self):
        if self.cur:
            done = []
            for name in self.cur:
                if self.cur[name].done():
                    done.append(name)
            for name in done:
                del self.cur[name]

    def launch(self, cb, task_name=None, **kwargs):
        if task_name:
            if task_name not in self.cur:
                self.cur[task_name] = asyncio.create_task(cb(**kwargs))
        else:
            asyncio.create_task(cb(**kwargs))

    def schedule(self, cb, delay_ms=None, event=None, task_name=None, **kwargs):
        asyncio.create_task(self._schedule(
            task_name=task_name, delay_ms=delay_ms, event=event, cb=cb, **kwargs))

    async def _schedule(self, cb=None, task_name=None, delay_ms=None, event=None, **kwargs):
        if delay_ms:
            await asyncio.sleep_ms(delay_ms)
        if event:
            await event.wait()
        self.launch(cb, task_name=task_name, **kwargs)

    def show_tasks(self):
        for name in self.cur:
            print(name)

    def stop(self, task_name=None):
        if task_name not in self.cur:
            return
        self.cur[task_name].cancel()
        del self.cur[task_name]


tasks = Tasks()
```

Details behind the reconstruction, all **confirmed**:

* The two `del` statements are `LOAD_NULL` / `ROT_THREE` / `STORE_SUBSCR`
  (`f_lib_task_mgr.dis:151`–`153` 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)*

| Method | What it does | Returns |
| - | - | - |
| `launch(cb, task_name=None, **kwargs)` | Registers `task_name` and creates a task for `cb(**kwargs)`; with no name, creates an untracked task | `None` |
| `schedule(cb, delay_ms=None, event=None, task_name=None, **kwargs)` | Creates an untracked wrapper task that waits, then calls `launch` | `None` |
| `stop(task_name=None)` | Cancels the registered task and drops the entry | `None` |
| `cleanup()` | Drops every entry whose task reports `done()` | `None` |
| `show_tasks()` | Prints the registered names | `None` |

Four consequences of this design matter elsewhere on this site.

> [!WARNING]
>
> **A task name stays occupied after the task finishes.** `launch` tests
> `task_name not in self.cur` (`f_lib_task_mgr.dis:168`–`169`); 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:273`–`275`), `compass.backend_checks` (`compass.dis:1957`–`1959`),
> `svc_ble_transfer.backend_checks` (`svc_ble_transfer.dis:271`–`273`) and
> `touch_button_v2.stop` (`touch_button_v2.dis:1052`–`1054`). 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.

> [!WARNING]
>
> **`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:919`–`939`). `_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 (`:1134`–`1150`), and that handler prints to `sys.stderr`
> (`:1093`–`1133`):
>
> ```text theme={null}
> Task exception wasn't retrieved
> future: <Task> coro= <generator>
> Traceback (most recent call last): ...
> ```
>
> **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:2745`–`2810`). 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.

> [!NOTE]
>
> **`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).

> [!NOTE]
>
> **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 the `MAKE_FUNCTION_DEFARGS` tuples in the module body
(`f_lib_file_mgr.dis:164`–`272`).

| Name | Signature | Notes | Disassembly |
| - | - | - | - |
| `FileErr` | `(msg='', code=None)` | `Exception` subclass; stores both | `:275` |
| `compress_file` | `async (file_path=None, pause_ms=10, is_delete=True, output_path=None)` | zlib, 256-byte chunks | `:315` |
| `copyfileobj` | `(src, dest, length=512)` | `readinto` path or `read` path | `:434` |
| `exists` | `(file=None)` | `os.stat` in `try`/`except OSError` | `:498` |
| `gen_file_hash` | `async (file_path=None, buff=None, yield_every=4)` | raw SHA-256 digest | `:522` |
| `get_files` | `generator (dir_path='', prefix=None)` | files only, not directories | `:644` |
| `get_file_diambig` | `(file_name=None)` | middle field of `prefix-NNN.ext` | `:687` |
| `is_dir` | `(path=None)` | `os.stat(path)[0] == 16384` | `:715` |
| `make_parents` | `(file_path=None, root='')` | `mkdir` each component | `:730` |
| `move_files` | `(source_dir=None, dest_dir='', excluded=[])` | the OTA file install | `:783` |
| `rebuild_obj` | `(file_path=None, obj=None, is_strict=False)` | JSON into an object | `:934` |
| `get_oldest_file` | `(prefix=None)` | lowest `-NNN.` number | `:1030` |
| `remove_oldest_log` | `(prefix=None)` | `get_oldest_file` then `os.remove` | `:1094` |
| `rmtree` | `(dir_path=None, is_del_root=True)` | recursive delete | `:1150` |
| `save_obj` | `(file_path=None, obj=None)` | JSON out | `:1202` |
| `vfs_del` | `(dir_path='/', file_types=('mpy',))` | delete by extension | `:1269` |
| `vfs_storage` | `()` | `(total, free, used)` in bytes | `:1346` |
| `walk_up` | `(root_dir, path_list=[])` | recursive file list | `:1382` |
| `write_bin_line` | `(filename, memview, data_len=0)` | append a record plus `b'\n'` | `:1432` |

`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`)*

```python theme={null}
def save_obj(file_path=None, obj=None):
    from json import dump
    try:
        with open(file_path, 'w') as f:
            if isinstance(obj, dict):
                dump(obj, f)
            else:
                dump(obj.__dict__, f)
    except OSError as e:
        if e.errno == 28:                                    # ENOSPC
            print('No space left on device to save file')
            return False
    return True


def rebuild_obj(file_path=None, obj=None, is_strict=False):
    from json import load

    def get_file_contents():
        try:
            with open(file_path) as f:
                return load(f)
        except (OSError, ValueError):
            return False

    data = get_file_contents()
    if not data:
        return False
    if obj is None:
        return data
    keys = []
    if is_strict:
        keys = list(obj.__dict__)
    for k in data:
        if hasattr(obj, k) and (not is_strict or k in keys):
            setattr(obj, k, data[k])
    return obj
```

`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](https://totem-cb8b3887.mintlify.site/subsystems/ota.md) 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`).

> [!WARNING]
>
> **`save_obj` returns `True` for every `OSError` except `ENOSPC`.** The handler tests
> `e.errno == 28` (`f_lib_file_mgr.dis:1249`–`1251`); when that test fails, control falls
> through the handler to the function's tail, which is `LOAD_CONST_TRUE` / `RETURN_VALUE`
> (`:1266`–`1267`). 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.

> [!WARNING]
>
> **`rebuild_obj` will overwrite methods.** The guard is `hasattr(obj, k)`
> (`f_lib_file_mgr.dis:976`–`980`), 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** (`:225`–`231`).
>
> `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` (`:999`–`1029`), and the caller then does `if not data: return False`
> (`:953`–`956`), 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:783`–`933`)*

```python theme={null}
def move_files(source_dir=None, dest_dir='', excluded=[]):
    source_dir = source_dir.strip('/')
    dest_dir = dest_dir.strip('/')
    print('\nUpdating files on device...')
    if source_dir not in os.listdir():
        print('Source directory: ... not found, cannot move files'.format(move_files()))
        return False
    print('Excluded files:')
    print(excluded)
    ok = True
    for src in walk_up(source_dir, []):
        rel = src.replace(source_dir + '/', '', 1)
        if rel in excluded:
            continue
        dst = (dest_dir + '/' + rel).strip('/')
        if '/' in dst:
            make_parents(dst)
        if is_dir(src):
            if not exists(dst):
                os.mkdir(dst)
        else:
            try:
                os.rename(src, dst)
            except OSError as e:
                print('OSError replacing: ... errno: ...'.format(dst, e.errno))
                ok = False
                continue
            print('Updated: ...'.format(dst))
    if ok:
        rmtree(dir_path=source_dir)
    return ok
```

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.

> [!WARNING]
>
> **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:812`–`813`) — 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 (`:1408`–`1425`), 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` (`:1352`–`1375`).
  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)`
  (`:1312`–`1318`). 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
  (`:1168`–`1177`), 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
  (`:1459`–`1463`). This is the newline-terminated binary-record convention used by the
  event and message logs.

> [!WARNING]
>
> **`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:262`–`266`) —
> 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`, `:829`–`832`), 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 (`:218`–`224`) 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: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`)*

```python theme={null}
class RTCv2(RTC):
    def __init__(self):
        super().__init__()

    @property
    def itod(self):                                      # ms into the day
        dt = self.datetime()
        return (dt[4] * 3600 + dt[5] * 60 + dt[6]) * 1000 + int(dt[7] / 1000)

    @property
    def itos(self):                                      # ms into the current second
        return int(self.datetime()[7] / 1000)

    @property
    def sec(self):                                       # wall-clock seconds field, 0-59
        return self.datetime()[6]

    @property
    def timestamp(self):
        dt = self.datetime()
        return '{}-{:02}-{:02} {:02}:{:02}:{:02}.{:06}'.format(
            dt[0], dt[1], dt[2], dt[4], dt[5], dt[6], dt[7])

    def unix(self, precision=2):
        return timestamp_to_unix(time_str=self.timestamp, precision=precision)

    def ms_until(self, f_time=None):
        target = f_time[0] * 3600 + f_time[1] * 60 + f_time[2] + f_time[3] / 1000000
        now = self.datetime()
        cur = now[4] * 3600 + now[5] * 60 + now[6] + now[7] / 1000000
        if f_time[0] < now[4]:
            target += 86400
        return round((target - cur) * 1000)
```

`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`.

> [!NOTE]
>
> **`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`)*

```python theme={null}
days_per_month = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)


def is_leap_year(yyyy):
    return (yyyy - 1968) % 4 == 0


def leap_years_since(yyyy):
    return int((yyyy - 1968) / 4)


def days_since_jan_1st(yyyy, mm, dd):
    days = dd
    for i in range(mm - 1):
        days += days_per_month[i]
    if is_leap_year(yyyy) and mm > 2:
        days += 1
    return days - 1


def time_to_unix(tt):                     # tt = (year, month, day, hour, minute, second)
    days = days_since_jan_1st(tt[0], tt[1], tt[2])
    total = tt[5] + tt[4] * 60 + tt[3] * 3600 + days * 86400
    total += (tt[0] - 1970) * 31536000
    ly = leap_years_since(tt[0])
    if is_leap_year(tt[0]):
        ly -= 1
    total += ly * 86400
    return total


def ms_until_sub_interval(interval_sec=6):
    s = rtc.sec
    if s % interval_sec == 0:
        return interval_sec * 1000 - rtc.itos
    return (interval_sec - s % interval_sec) * 1000 - rtc.itos
```

**The epoch is 1970-01-01 UTC** — the `- 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.

> [!NOTE]
>
> **`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.

> [!WARNING]
>
> **`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:
>
> | Year | Wrong days | First wrong day | What 31 December returns |
> | - | - | - | - |
> | 2024 | 15 | 2024-12-17 | `(2025, 1, 0, ...)` |
> | 2025 | 15 | 2025-12-17 | `(2026, 1, 0, ...)` |
> | 2026 | 15 | 2026-12-17 | `(2027, 1, 0, ...)` |
> | 2028 | 16 | 2028-12-16 | `(2029, 1, 0, ...)` |
> | 2030 | 16 | 2030-12-16 | `(2031, 1, 0, ...)` |
> | 2098 | 33 | 2098-11-29 | `(2099, 1, 0, ...)` |
>
> 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:760`–`773`: `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: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 `'.'`.

| `precision` | Returns |
| - | - |
| `1` | `int` — whole Unix seconds |
| `2` *(the default)* | `float` — seconds plus the fraction rounded to 3 places |
| anything else | `float` — seconds plus the fraction rounded to 6 places |

> [!NOTE]
>
> **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](https://totem-cb8b3887.mintlify.site/protocols/message-format.md).
>
> 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:

| Offset | Bytes | Meaning | Where it is read |
| - | - | - | - |
| 0-1 | 2 | Magic, always `b'\xa7t'` | **nowhere in this module** |
| 2 | 1 | Category id — the key of `contents` | `f_lib_rtc_mem.dis:254`, `:495`, `:760` |
| 3 | 1 | A per-category value byte | read by the consumers, not here |
| 4 | 1 | Payload length `N` | `:177`, `:470`, `:741` |
| 5 | `N` | Payload | — |

> [!WARNING]
>
> **`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:161`–`196`); `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:

| Id | Contents | Written by | Read by |
| - | - | - | - |
| 0 | Reboot-into-service mailbox. 5-byte frame, payload length 0; byte 3 is the service id | `ble_manager.execute_cmd` (`ble_manager.dis:6905`–`6952`) | `project_main.start` (`project_main.dis:1168`–`1193`) |
| 1 | Byte-for-byte mirror of `ble_keys.bin`, the BLE bonding secrets | `device_power` (`device_power.dis:461`–`479`), `ble_core` | `ble_core` (`ble_core.dis:405`–`424`), `device_power.save_ble_secrets` (`device_power.dis:529`–`600`) |
| 2 | 7-byte device snapshot: 5-byte header, payload length 2, byte 6 is a `pack_flags` byte | `project_data.set_device_snapshot` (`project_data.dis:2240`–`2330`) | `project_data.get_device_snapshot` (`:2185`–`2215`) |

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](https://totem-cb8b3887.mintlify.site/firmware/boot-flow.md).

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)*

| Method | Returns | Guard |
| - | - | - |
| `reload()` | `None` | — |
| `add(payload, is_del_if_exists=False)` | `True` / `False` | Needs `reload()`; 2048-byte cap |
| `get(byte_index)` | `bytes` or `None` | Needs `reload()`; bounds-checked |
| `delete(byte_index)` | `True` / `False` | Needs `reload()`; bounds-checked |
| `delete_cat(cat_id)` | `None` | — |
| `clear()` | `None` / `False` | — |

`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:

> [!WARNING]
>
> **`add`'s `is_del_if_exists` parameter is never read.** It is declared with a default of
> `False` (`f_lib_rtc_mem.dis:102`–`106`, `:149`) and `LOAD_FAST 2` — the only opcode that
> could read it — appears nowhere in the 184-line body (`:146`–`329`). 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` (`:392`–`405`). If a category ever
> holds two frames, the second survives.

> [!WARNING]
>
> **`clear()` does not clear `self.contents`.** It writes `b''` to RTC memory and zeroes
> `frame_count` and `mem_size` (`f_lib_rtc_mem.dis:335`–`351`), 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.

> [!NOTE]
>
> **`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](https://totem-cb8b3887.mintlify.site/firmware/boot-flow.md).
>
> 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**.

> [!NOTE]
>
> **`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.

```python theme={null}
class EventTimeout:
    def __init__(self, timeout_ms=None):
        self._task = asyncio.current_task()
        self._timeout_task = None
        self._timeout_ms = timeout_ms

    def __enter__(self):
        if self._timeout_ms:
            self._timeout_task = asyncio.create_task(self._countdown())

    def __exit__(self, e_type, e_val, e_tb):
        try:
            if (e_type == asyncio.CancelledError
                    and self._timeout_ms and self._timeout_task is None):
                raise asyncio.TimeoutError
        finally:
            if self._timeout_task:
                self._timeout_task.cancel()

    async def _countdown(self):
        try:
            await asyncio.sleep_ms(self._timeout_ms)
        except asyncio.CancelledError:
            return
        self._timeout_task = None
        self._task.cancel()
```

`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`).

```python theme={null}
class AsyncDeque(deque):
    def __init__(self, seq=[], queue_size=10):
        super().__init__(seq, queue_size)
        self._max_len = queue_size
        self._evt_not_empty = asyncio.ThreadSafeFlag()

    def __aiter__(self):
        return self

    async def __anext__(self):
        return await self.get()

    async def get(self):
        if not len(self):
            await self._evt_not_empty.wait()
        elif len(self) == 1:
            self._evt_not_empty.clear()
        return self.popleft()

    def put(self, item):
        self.append(item)
        self._evt_not_empty.set()
```

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:257`–`262`) — 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 `[]`** (`:233`–`237`), 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: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.

> [!WARNING]
>
> **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:93`–`116`. 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 (`:350`–`371`), 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` (`:459`–`485`), and there is no such module among the 94. A
> `TarFile` opened in any mode other than `'r'` raises
> `NotImplementedError('Install tarfile-write')` (`:533`–`538`).

`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`).

```python theme={null}
def unzip_tar(tar_path=None, dest_path=None, is_del=True):
    with gzip.open(tar_path, 'rb') as src:
        with open(dest_path, 'wb') as dest:
            copyfileobj(src, dest)
    src.close()
    dest.close()
    if is_del:
        os.remove(tar_path)


def unpack_tar(tar_path=None, dest_path=None):
    dest_path = dest_path.strip('/')
    t = TarFile(tar_path)
    for info in t:
        if 'PaxHeader' in info.name:
            continue
        out = dest_path + '/' + info.name
        print('Unpack ... to: ... | ...'.format(info.name, out, info.type))
        if info.type == DIRTYPE:
            if info.name != './':
                os.mkdir(out.strip('/'))
        else:
            make_parents(out)
            f = t.extractfile(info)
            with open(out, 'wb') as dest:
                copyfileobj(f, dest)
                dest.close()
```

`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`).

> [!WARNING]
>
> **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:162`–`168`, `:199`–`202`). 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](https://totem-cb8b3887.mintlify.site/subsystems/ota.md), not for this page.
>
> Both `with` blocks also call `close()` on the handle *inside* or immediately after the
> block (`:121`–`126`, `:219`–`221`), 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.

```python theme={null}
def running_partition():
    try:
        return Partition(Partition.RUNNING).info()[4]
    except Exception as e:
        from sys import print_exception
        print_exception(e)
        return False
```

`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 `.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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.