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

# Telemetry: the debug recorder

> debugger.py decoded from the v5.0.3 bytecode — a binary flight recorder that packs battery, sleep, GNSS and orientation counters into debug.bin and ships them to the cloud, the arming flag that nothing on a shipped device ever sets, the NameError that caps the file at one batch, and a second unused log writer sitting beside it.

`debugger.py` is not a set of debug hooks. It is a **binary telemetry recorder**: a
coroutine that packs a fixed-width record of battery, sleep, GNSS and orientation counters
once per second, batches three of them at a time, and appends them to `debug.bin` on the
filesystem — a file that [`data_upload_v2`](https://totem-cb8b3887.mintlify.site/reference/networking) later uploads to the
vendor's API. It also contains a second, entirely separate log-writer class, `WriteLog`,
with its own queue and gzip rotation, which nothing in the image instantiates.

Neither half runs on a shipped device. This page decodes both, and says exactly what
stands between them and production.

> [!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.

## What is in the module

`debugger.py` defines two unrelated classes and one module-level singleton (**confirmed**,
`debugger.dis:243`–`255`):

| Name | Kind | Line | What it is |
| - | - | - | - |
| `Debugger` | class | `:259` | The telemetry recorder — `generic`, `power_perf`, `start`, `save` |
| `debug` | instance | `:250` | `debug = Debugger()`, the singleton both importers pull in |
| `WriteLog` | class | `:796` | A generic queued log writer with gzip rotation — `add`, `_scheduled_rotation`, `rotate_logs` |

The two classes share no code, no state and no file. `Debugger` writes `debug.bin` and
rotates by renaming; `WriteLog` writes whatever filename it is constructed with and rotates
by compressing. Only `Debugger` is reachable from outside the module, and only through the
`debug` singleton.

The module is **byte-identical between v5.0.2 and v5.0.3** (**confirmed**: `diff` of the
two disassemblies is empty). Nothing here changed in the release.

## `Debugger`: the recorder

### Construction *(CONFIRMED, `debugger.dis:281`–`310`)*

```python theme={null}
class Debugger:
    def __init__(self):
        self.interval_sec = 1
        self.last_saved = time.ticks_ms()
        self.is_generic = False
        self.is_power_perf = False
        self._generic_buff = None
        self._pwr_prf_buff = None
        self.temp = []


debug = Debugger()
```

`interval_sec` is `LOAD_CONST_SMALL_INT 1` at `:286`. Both `is_` flags start `False`
(`:295`, `:298`) and there is no argument to override them — `__init__` has a prelude of
`(3, 0, 0, 1, 0, 0)`, one positional argument (`self`) and no defaults.

### Arming: `generic` and `power_perf` *(CONFIRMED, `debugger.dis:312`–`377`)*

```python theme={null}
    def generic(self):
        if not self.is_generic:
            size = struct.calcsize('<BiHbe')
            self._generic_buff = memoryview(bytearray(size))
            self.is_generic = True
            modes.evt_debugger_active.set()

    def power_perf(self):
        if not self.is_power_perf:
            size = struct.calcsize('<BihhHH3i')
            self._pwr_prf_buff = memoryview(bytearray(size))
            self.generic()
            self.is_power_perf = True
            modes.evt_debugger_active.set()
```

Both are plain methods, not coroutines — scope flags `0` in both preludes. `power_perf`
calls `generic` (`:364`), so arming power telemetry always arms the generic record too;
the reverse is not true.

`modes.evt_debugger_active` is an `asyncio.Event` created in `project_data`
(**confirmed**, `project_data.dis:1110`, `Event()` then `STORE_ATTR evt_debugger_active`).
These two methods are the **only** places in the whole image that call `.set()` on it.

### The two record formats *(CONFIRMED)*

Both records are packed little-endian with no alignment padding, into a preallocated
`memoryview` reused every cycle.

**Generic record** — format `'<BiHbe'`, 10 bytes, packed at `debugger.dis:433`–`445`:

| Offset | Code | Field | Source |
| - | - | - | - |
| 0 | `B` | record tag, literal `0` | `LOAD_CONST_SMALL_INT 0` at `:439` |
| 1 | `i` | Unix seconds | `rtc.unix(precision=1)` (`:399`–`404`) |
| 5 | `H` | seconds since boot | `int(time.ticks_diff(time.ticks_ms(), modes.booted) / 1000)` (`:405`–`417`) |
| 7 | `b` | orientation | `fusion.orientation` (`:443`) |
| 8 | `e` | battery volts, IEEE half-float | `modes.batt_volts`, see below |

`rtc.unix(precision=1)` returns whole integer seconds, per the
[`f_lib/rtc_v2` decode](https://totem-cb8b3887.mintlify.site/reference/f-lib). The battery value is chosen at `:418`–`432`:

```python theme={null}
        volts = modes.batt_volts
        if modes.is_charging:
            volts = modes.batt_volts_max
        if volts is None:
            volts = 0
```

That is, **while the charger is attached the record carries the learned maximum, not the
live cell voltage** — `modes.batt_volts_max` is the learned ceiling described on the
[power page](https://totem-cb8b3887.mintlify.site/subsystems/power.md). A telemetry series taken across a charge cycle therefore
flatlines at the learned maximum rather than tracking the charge curve. **Confirmed** from
the `LOAD_ATTR is_charging` branch at `:422`.

**Power-performance record** — format `'<BihhHH3i'`, 25 bytes, packed at
`debugger.dis:570`–`585`:

| Offset | Code | Field | Source |
| - | - | - | - |
| 0 | `B` | record tag, literal `2` | `LOAD_CONST_SMALL_INT 2` at `:576` |
| 1 | `i` | `modes.lightsleep_ms` | `:460` |
| 5 | `h` | `modes.rtc_sync_sec`, or `-1` if `None` | `:461`–`470` |
| 7 | `h` | `modes.gnss_ttfl`, or `-1` if `None` | `:471`–`480` |
| 9 | `H` | `modes.gnss_rx` | `:483` |
| 11 | `H` | `modes.gnss_rx_errs` | `:486` |
| 13 | `i` | `fusion.sec_horizontal` | `:489` |
| 17 | `i` | `fusion.sec_vertical` | `:492` |
| 21 | `i` | `modes.eco_mode_sec` | `:495` |

The last three are **cumulative second counters that the recorder tops up in-place before
packing**, without writing the result back to `modes` or `fusion` (**confirmed**,
`:497`–`569`):

```python theme={null}
        eco_sec = modes.eco_mode_sec
        if modes.eco_mode_start is not None:
            eco_sec += round(time.ticks_diff(time.ticks_ms(), modes.eco_mode_start) / 1000)
        if fusion.orientation == 1:
            if fusion.orientation_start > 0:
                sec_vertical += round(
                    time.ticks_diff(time.ticks_ms(), fusion.orientation_start) / 1000)
        elif fusion.orientation == 2:
            if fusion.orientation_start > 0:
                sec_horizontal += round(
                    time.ticks_diff(time.ticks_ms(), fusion.orientation_start) / 1000)
```

So each record reports the counter *plus the currently open interval*, which is the right
thing for a sampled series and means consecutive records are not independent deltas. The
orientation values `1` and `2` are the vertical and horizontal states described on the
[navigation page](https://totem-cb8b3887.mintlify.site/subsystems/navigation.md).

> [!NOTE]
>
> **The record tag is `0` and `2`, not `0` and `1`.** Both are literal small-int constants
> in the `pack_into` calls, not derived from anything. Whether a tag `1` record type once
> existed is **not recoverable** — there is no trace of one in this image.

### The loop: `start` *(CONFIRMED, `debugger.dis:378`–`622`)*

```python theme={null}
    async def start(self):
        log.debug('Starting debug logging')
        await modes.evt_debugger_active.wait()
        while True:
            if self.is_generic:
                ...                                   # pack the 10-byte record
                self.temp.append(bytes(self._generic_buff))
            if self.is_power_perf:
                ...                                   # pack the 25-byte record
                self.temp.append(bytes(self._pwr_prf_buff))
            if len(self.temp) >= 3:
                self.save()
            gc.collect()
            await asyncio.sleep(self.interval_sec)
```

`start` is a coroutine (scope flags `1`). The `len(self.temp) >= 3` test is **outside**
both `if` blocks — the `POP_JUMP_IF_FALSE 225` guarding `is_power_perf` lands exactly on
the `LOAD_GLOBAL len` that begins it (byte 397 of the function, `:596`). So the flush runs
every cycle regardless of which record types are armed.

With both types armed and `interval_sec = 1`, `self.temp` reaches three entries after two
cycles, so `save()` is called roughly every two seconds; with only the generic record, every
three seconds.

### The writer: `save` *(CONFIRMED, `debugger.dis:624`–`794`)*

`save` is **not** a coroutine — scope flags `0`, and the function contains no `YIELD_FROM`.
It does blocking filesystem work on whatever task calls it, which is why it takes the
watchdog blocker around itself.

```python theme={null}
    def save(self):
        log.debug('Writing debug logs to VFS')
        self.last_saved = time.ticks_ms()
        try:
            modes.evt_vfs_not_busy.clear()
            wdt_mgr.set_block(WdtBlockers.VFS_WRITE, True)
            if statvfs('/')[3] < 39:                       # free blocks
                log.warn('VFS free space too low to safely write log')
                remove_oldest_log('debug-')
                remove_oldest_log('debug-')
                return False
            count = 0
            for _ in get_files(prefix='debug-'):
                count += 1
            if count > 10:
                remove_oldest_log('debug-')
            if exists('debug.bin') and stat(log_path)[6] > 7100:
                ts = rtc.unix(precision=1)
                rename('debug.bin', 'debug-{}.bin'.format(ts))
            with open('debug.bin', 'ab') as f:
                for buff in self.temp:
                    f.write(buff)
                    f.write(b'\n')
            self.temp.clear()
        except Exception as e:
            log.err('Error in debugger logs', exc=e, is_write=False)
        finally:
            wdt_mgr.set_block(WdtBlockers.VFS_WRITE, False)
            modes.evt_vfs_not_busy.set()
```

The constants are all literal: `39` at `:660`, `10` at `:693`, `7100` at `:709`. The
`statvfs('/')[3] < 39` free-block guard is the **same check, with the same warning string**,
that `nav_logger` uses before writing `events.bin` — see the
[navigation page](https://totem-cb8b3887.mintlify.site/subsystems/navigation.md). The block size behind `39` is **not recoverable**
from the bytecode; it depends on the VFS the image is mounted on.

`remove_oldest_log('debug-')` really is called **twice in a row** in the low-space branch
(`:668` and `:672`, identical argument). The `POP_JUMP_IF_FALSE 25` at `:662` skips exactly
the 25 bytes that cover the warning, both calls and the `return False`, so both are inside
the branch. Whether that is a deliberate "free two files" or a duplicated line is
**inferred at best** — the bytecode cannot distinguish them.

> [!WARNING]
>
> **`save` raises `NameError` on every call after the first, and `debug.bin` never grows
> past one batch.** *(CONFIRMED)*
>
> The size check reads a global named `log_path`:
>
> ```text theme={null}
> debugger.dis:701   23:05       LOAD_CONST_OBJ 'debug.bin'
> debugger.dis:702   34:01       CALL_FUNCTION 1          # exists('debug.bin')
> debugger.dis:704   12:09       LOAD_GLOBAL stat
> debugger.dis:705   12:79       LOAD_GLOBAL log_path
> ```
>
> **`log_path` is never defined.** The qstr appears exactly twice in the whole module — once
> in the qstr table at `debugger.dis:126`, and once as that `LOAD_GLOBAL` at `:705`. There
> is no `STORE_NAME log_path` anywhere in the module, no `IMPORT_FROM` that binds it, and
> the qstr does not appear in any of the other 93 disassemblies. It is not a MicroPython
> builtin, so `LOAD_GLOBAL` raises `NameError`.
>
> The branch is guarded by `exists('debug.bin')`, so the failure is not immediate:
>
> * **First call**, on a device with no `debug.bin`: `exists` is false, the second test is
>   skipped, the file is created and the batch is written. This works.
> * **Every later call**, for the life of the file: `exists` is true, `LOAD_GLOBAL log_path`
>   raises, and `except Exception` catches it and logs `'Error in debugger logs'`. The
>   `with open(...)` block is never reached, and **`self.temp.clear()` is never reached
>   either**.
>
> So `debug.bin` contains exactly one batch of three records, forever, while `self.temp`
> grows without bound at one or two entries per second until the device runs out of memory.
> `debug.bin` also persists across reboots, so a device only gets that one good write once.
>
> This is also why the rename-based rotation to `debug-{}.bin` at `:718`–`:724` can never
> fire: it sits on the far side of the same `NameError`. No `debug-*.bin` file can ever be
> produced by this code, which makes the `get_files(prefix='debug-')` count at `:680` and
> both `remove_oldest_log('debug-')` calls permanent no-ops.
>
> (Whether the source wrote this as one `and` or as two nested `if`s is **not
> recoverable** — both `POP_JUMP_IF_FALSE`s resolve to the same target, 39 bytes on from
> `:703` and 25 bytes on from `:711`. The behaviour is identical either way.)
>
> Whether `log_path` was a module constant that an edit removed is **not recoverable**.
> Nothing outside the module could supply it either — the only two importers do
> `from debugger import debug` and never touch the module object, so only a REPL user
> could inject `debugger.log_path`.

Note that the `log.err` call passes `is_write=False` (`:768`–`:769`), so this failure is a
**console line only**. It never reaches `errors.log`. See
[the logger decode](https://totem-cb8b3887.mintlify.site/reference/f-lib.md) for why that matters.

## Is any of this reachable?

**No. Not on a shipped device.** *(CONFIRMED)*

Two modules import `debugger`, and both import only the singleton:

| Importer | Site | What it does |
| - | - | - |
| `compass` | `compass.dis:912` | `from debugger import debug` |
| `device_power` | `device_power.dis:146` | `from debugger import debug` |

Between them they reference the `debug` global five times, and every reference is behind
the same flag:

```python theme={null}
# compass.dis:2894-2913, inside Compass.backend_checks (compass.dis:1898)
if debug.is_generic:
    since = time.ticks_diff(time.ticks_ms(), debug.last_saved)
    if since >= 60000:
        debug.save()

# device_power.dis:259-265, inside shut_down_tasks (device_power.dis:203)
if debug.is_generic:
    debug.save()
```

`is_generic` is set `True` in exactly one place: `Debugger.generic` (`:334`). And
`Debugger.generic` is called from exactly one place: `Debugger.power_perf` (`:364`).
`Debugger.power_perf` is called from nowhere at all.

> [!WARNING]
>
> **Nothing in the image arms the recorder.** *(CONFIRMED)*
>
> * **No module calls either arming method.** There is no `LOAD_METHOD generic` or
>   `LOAD_METHOD power_perf` anywhere outside `debugger.dis` — the only one in the image is
>   `power_perf`'s own call to `generic` at `:364`. More broadly, `generic` as a whole word
>   occurs in no other disassembly (`grep -w`, all 94 files), and `power_perf` occurs in no
>   other disassembly at all. The flag name `is_generic` does appear in `compass` and
>   `device_power`, but only in the read-only guards above; neither module can set it.
> * `modes.evt_debugger_active` appears only in `project_data.dis` (where the `Event` is
>   created, `:1110`) and in `debugger.dis`. Nothing else sets, clears or waits on it.
> * `debug.start` is never referenced. The five `debug` loads in `compass` and
>   `device_power` are the only ones in the image, and none of them is `start`. The
>   coroutine is never created as a task, so even if the event were set, the loop would not
>   be running to observe it.
>
> The consequence chain is total: `is_generic` is `False` for the life of the device, so
> `compass` and `device_power` never call `save()`, `start()` never runs, `evt_debugger_active`
> is never set, and `debug.bin` is never created. The `NameError` above is therefore a
> latent bug, not an observed one — it would bite the first person to arm the recorder
> from the REPL.

That the intended arming route is the **REPL** is **inferred**, but the surrounding evidence
is consistent: `project_main` exposes a matching REPL reader (below), the two arming methods
are public and idempotent, and `start` opens by awaiting an event rather than by checking a
config field. A developer connected to the serial console would call
`from debugger import debug`, then `debug.power_perf()`, then arrange for `debug.start()` to
be scheduled. Nothing in the image does the third step for them.

## Where `debug.bin` would go

Even though it is never written in practice, `debug.bin` is wired into both the REPL and the
cloud upload path. **Confirmed**, two consumers:

**`project_main.show_debug_logs(f_name='debug.bin')`** (`project_main.dis:865`–`910`) — a
REPL helper with `debug.bin` as its default argument (`:381`–`385`). It opens the file in
`'rb'` mode and prints each newline-delimited chunk raw:

```python theme={null}
def show_debug_logs(f_name='debug.bin'):
    from f_lib.file_mgr import exists
    if not exists(f_name):
        print('No {} file on device'.format(f_name))
        return
    with open(f_name, 'rb') as f:
        for line in f:
            print(line)
```

It does no unpacking at all — the operator sees `bytes` repr, not decoded fields.

**`data_upload_v2.device_logs(root_log='debug.bin', max_files=99)`**
(`data_upload_v2.dis:912`–`919`) — `debug.bin` is one of four log families the WiFi uploader
ships, alongside `events.bin`, `commslog.bin` and `hotcold.bin`. Its endpoint is
`API_ENDPOINT + '/debug/{}'.format(mac_addr)` (`:619`–`629`), and the uploader globs
`get_files(prefix=root_log.split('.')[0])`, so it would collect `debug.bin` and any
`debug-*` siblings.

> [!NOTE]
>
> **The newline separator is not a reliable record delimiter.** `save` writes each packed
> record followed by `b'\n'` (`:744`), but the records are raw binary — a `0x0A` byte can
> and will occur inside a timestamp, a counter or a half-float. A parser must use the
> leading tag byte and the fixed record lengths (10 bytes for tag `0`, 25 for tag `2`) and
> skip one separator byte, rather than splitting on newlines. That the tag plus fixed
> length makes the stream unambiguously parseable is **inferred** from the two formats;
> no decoder for this file exists in the image.

## `WriteLog`: the second writer, never instantiated

The other half of the module is a general-purpose queued log writer. It is **confirmed**
dead: the qstr `WriteLog` appears in no other disassembly in the image, and nothing inside
`debugger.py` constructs one either.

### Construction *(CONFIRMED, `debugger.dis:822`–`854`)*

```python theme={null}
class WriteLog:
    def __init__(self, file_name=None, **kwargs):
        self._file_name = file_name
        self._queue_sz = kwargs.get('queue_sz', 5)
        self._max_logs = kwargs.get('max_logs', 10)
        self._is_err = False
        self._next_save = None
        self._queue = []
```

The `**kwargs` is the var-keyword scope flag `2` in the prelude `(7, 0, 2, 2, 0, 1)`; the
defaults `5` and `10` are at `:833` and `:840`.

### `add` *(CONFIRMED, `debugger.dis:856`–`982`)*

```python theme={null}
    def add(self, buff=None):
        if not buff or self._is_err:
            return
        self._queue.append(bytes(buff))
        if len(self._queue) >= self._queue_sz:
            try:
                wdt_mgr.set_block(WdtBlockers.VFS_WRITE, True)
                with open(self._file_name, 'ab') as f:
                    for b in self._queue:
                        f.write(b)
                        f.write(b'\n')
                self._queue.clear()
            except Exception as e:
                log.err('Error writing: {}'.format(self._file_name), exc=e, is_write=False)
                self._is_err = True
            finally:
                wdt_mgr.set_block(WdtBlockers.VFS_WRITE, False)
        if self._next_save is None:
            self._next_save = time.ticks_ms()
            tasks.schedule(self._scheduled_rotation, delay_ms=60000)
```

Two details worth pinning down, both **confirmed** by resolving the jump targets:

* `_is_err` is a **one-way latch**. Once a write fails it is set `True` (`:946`) and the
  early return at the top of `add` drops every subsequent record silently. Nothing in the
  module ever sets it back to `False`.
* The rotation scheduling is **outside** the flush block. The `POP_JUMP_IF_FALSE 124` that
  guards `len(self._queue) >= self._queue_sz` lands on byte 188 of the function, which is
  `LOAD_FAST 0 / LOAD_ATTR _next_save` (`:963`–`964`) — so the `_next_save` check runs on every
  accepted record, not only on flushes.

`tasks.schedule(cb, delay_ms=60000)` creates a task that sleeps 60 seconds and then launches
`cb()`; see the [`f_lib/task_mgr` decode](https://totem-cb8b3887.mintlify.site/reference/f-lib). `_scheduled_rotation`
(`:984`–`1000`) simply awaits `rotate_logs()`, resets `_next_save = None` and returns `True`,
so the next `add` after a rotation arms another 60-second timer. **Rotation is therefore at
most once per minute, and only while records keep arriving.**

### `rotate_logs` *(CONFIRMED, `debugger.dis:1002`–`1192`)*

```python theme={null}
    async def rotate_logs(self):
        log.debug('Saving logs for {}'.format(self._file_name))
        try:
            wdt_mgr.set_block(WdtBlockers.VFS_WRITE, True)
            if not exists(self._file_name):
                return True
            if statvfs('/')[3] < 39:
                log.warn('Not enough VFS free space too low to write log')
                self._is_err = True
                return False
            if stat(self._file_name)[6] < 7100:
                return True
            base = self._file_name.split('.')[-2]
            if self._max_logs > -1:
                count = 0
                for _ in get_files(prefix='{}-'.format(base)):
                    count += 1
                    if count % 10 == 0:
                        await asyncio.sleep_ms(1)
                if count > self._max_logs:
                    log.warn('Too many {} logs on VFS'.format(self._file_name))
                    self._is_err = True
                    return False
            ts = rtc.unix(precision=1)
            out = '{}-{}.gz'.format(base, ts)
            wdt_mgr.set_block(WdtBlockers.VFS_WRITE, True)
            await compress_file(file_path=self._file_name, output_path=out)
        except FileErr as e:
            log.exc(e.msg, exc=e, is_write=False)
            return False
        finally:
            wdt_mgr.set_block(WdtBlockers.VFS_WRITE, False)
        return True
```

Three things this shows:

* **The rotation threshold is the same `7100` bytes** as `Debugger.save` (`:1058`, and
  `:709` for the other), so the two writers were sized against the same budget.
* **It compresses rather than renames.** `compress_file` defaults to `is_delete=True`, per
  the [`f_lib/file_mgr` decode](https://totem-cb8b3887.mintlify.site/reference/f-lib), so the original is removed and only
  `<base>-<unix>.gz` survives. `Debugger.save` instead renames to `debug-<unix>.bin` and
  leaves it uncompressed. The two halves of this one module rotate incompatibly.
* **`self._max_logs > -1` is the disable switch.** A `WriteLog` constructed with
  `max_logs=-1` skips the counting loop entirely and rotates without any cap on how many
  archives accumulate. **Confirmed** from the `LOAD_CONST_SMALL_INT -1` / `__gt__` at
  `:1072`–`1074`.
* The `wdt_mgr.set_block(..., True)` at `:1138`–`1143` is **redundant** — the blocker was
  already set at the top of the same `try`, and `set_block` is not a counter. Harmless, but
  it means reading this function as "the blocker is taken just for the compression" would
  be wrong.

The warning string `'Not enough VFS free space too low to write log'` is a garbled merge of
two phrasings; the `Debugger.save` copy reads `'VFS free space too low to safely write log'`.
Both are in `obj_table` (`debugger.dis:131`), so the difference is in the source, not the
decode.

## How this relates to `f_lib/logger.py`

`debugger.py` is a **second, independent writer**, and understanding the split matters for
anything recovered off a device.

| | `f_lib/logger.py` | `debugger.py` |
| - | - | - |
| File | `errors.log` | `debug.bin` (`Debugger`), caller's choice (`WriteLog`) |
| Content | Text records, five pipe-separated fields | Fixed-width little-endian binary |
| What reaches flash | `ERR` and `EXC` only | Every sampled record |
| Size behaviour | Hard stop at 8000 bytes, no rotation | Rotate at 7100 bytes — rename (`Debugger`) or gzip (`WriteLog`) |
| Cleared by | A successful OTA, or `show_logs(True)` | Nothing automatic |
| Reachable in v5.0.3 | Yes, on every device | **No** |

`debugger.py` imports `log` from `f_lib.logger` (`:193`–`:197`) and uses it for its own
diagnostics, but every one of those calls is either a `debug`/`warn` level — which
[never reaches flash](https://totem-cb8b3887.mintlify.site/reference/f-lib.md), because `_write_lvl` is fixed at 4 — or an
`err`/`exc` with an explicit `is_write=False`. **Confirmed** at `:765`–`:770`, `:933`–`:947`
and `:1163`–`:1171`.

> [!WARNING]
>
> **`debugger.py` writes nothing to `errors.log`, ever.** Every path that could have —
> the three `log.err`/`log.exc` calls — passes `is_write=False`. So a post-mortem taken off
> a device gets no trace of the recorder having failed, and no trace of it having run.
> Combined with the fact that nothing arms it, the practical answer for field recovery is:
> **`debug.bin` will not be on the device, and `errors.log` will not mention why.**

## Dead code: `peer_auto_bond.py`

`peer_auto_bond.py` (190 lines of disassembly) is a single coroutine, `bond_to_group`. It is
**dead**, and it is an abandoned earlier draft of the Smart Group bonding that
[`peer_management.auto_bond_to_peers`](https://totem-cb8b3887.mintlify.site/protocols/espnow-mesh) performs today.

### Nothing imports it *(CONFIRMED)*

Grepping `IMPORT_NAME peer_auto_bond` across all 94 v5.0.3 disassemblies returns nothing.
Grepping the bare string `peer_auto_bond` across all 94 returns only `peer_auto_bond.dis`
itself — it appears in no other module's qstr table, which it would have to if any module
named it. The same is true in v5.0.2, and the two versions' disassemblies are byte-identical.

### It could not run if it were called *(CONFIRMED)*

```text theme={null}
peer_auto_bond.dis:162   12:1d       LOAD_GLOBAL get_next_peer_pixel
```

`get_next_peer_pixel` is loaded as a global inside `bond_to_group`, but the module never
binds that name. The module-level code performs exactly nine `STORE_NAME` operations
(`:44`, `:48`, `:54`, `:61`, `:71`, `:73`, `:75`, `:77`, `:84`) — `asyncio`, `time`, `log`,
`tasks`, `colors`, `config`, `modes`, `Peer`, `bond_to_group` — and `bond_to_group`'s own
two function-level imports bring in `delete_all_peers` and `bin_to_str` (`:93`–`:106`).
`get_next_peer_pixel` is in none of them.

The function is defined in `peer_helpers.py` (`peer_helpers.dis:166`) and imported explicitly
by the four modules that actually use it — `peer_management` (`:186`), `compass` (`:834`),
`ble_manager` (`:5218`) and `espnow_conn_v2` (`:9307`). `peer_auto_bond` is the one place
that reaches for it without importing it.

So the first loop iteration that got past the `mac != config.mac` test would raise
`NameError`, after having already awaited `delete_all_peers()` — meaning a call would wipe
the friend list and then fail before adding anything back. That is **inferred** only in the
sense that the code never runs; the missing binding is confirmed.

Four of its eight module-level imports are unused: `asyncio`, `time`, `log` and `tasks`
never appear as a `LOAD_GLOBAL` in the only function the module defines. The complete set of
globals `bond_to_group` loads is `colors`, `config`, `modes`, `Peer` and the undefined
`get_next_peer_pixel`.

### What it would have done *(CONFIRMED, reading the body)*

```python theme={null}
async def bond_to_group(bond_group=None, cb_add_peer=None):
    from peer_management import delete_all_peers
    from f_lib.bitwise import bin_to_str
    await delete_all_peers()
    for mac in bond_group:
        mac_str = bin_to_str(mac)
        if mac_str != config.mac:
            rgb = colors.rgb(bond_group[mac][3], brt=1)
            peer = Peer(mac_str)
            peer.lat = bond_group[mac][0]
            peer.lon = bond_group[mac][1]
            peer.p_acc = bond_group[mac][2]
            peer.pixel = get_next_peer_pixel()
            peer.rgb = rgb
            config.peers[mac_str] = peer
            if cb_add_peer:
                await cb_add_peer(mac=mac_str)
    modes.is_save_config = True
```

`bond_group` is a dict keyed by binary MAC, whose values are 4-element sequences of
latitude, longitude, position accuracy and a colour index.

### It is superseded, not merely unused *(CONFIRMED)*

`peer_management.auto_bond_to_peers(bond_group, pause_ms)` is the same algorithm, statement
for statement, over the same data shape — same `delete_all_peers()` preamble, same
`bin_to_str`/`config.mac` skip, same four attribute assignments in the same order, same
`config.peers[mac_str] = peer`, same `modes.is_save_config = True` at the end. The live
version differs in exactly the ways you would expect from a later revision:

| | `peer_auto_bond.bond_to_group` | `peer_management.auto_bond_to_peers` |
| - | - | - |
| Second parameter | `cb_add_peer`, an injected callback | `pause_ms`, a settle delay |
| Adding the ESP-NOW peer | `await cb_add_peer(mac=mac_str)` | `await enow_v2.add_peer(mac=mac_str)` directly |
| `get_next_peer_pixel` | **unbound** — `NameError` | imported at `peer_management.dis:186` |
| Cancels the join timeout | no | `tasks.stop('auto_bond_client_timeout')` first |
| Sets `config.is_bond_group` | no | yes, to `1` |
| Trailing settle | none | `await asyncio.sleep_ms(pause_ms)` |
| Clears group state | no | `smart_grp_ticks`, `smart_grp_last_nearby`, `auto_bond_timeout` all set to `None` |
| Logging | none | `'Autobonding to Peers'`, `'Smart Group completed'` |

The callback parameter is the tell: `bond_to_group` was written to be handed an
`add_peer` function so it would not have to import the ESP-NOW layer, and the surviving
version dropped that indirection and called `enow_v2` directly. **Confirmed** by reading
both function bodies; the conclusion that one is the ancestor of the other is **inferred**
from their structural identity.

> [!NOTE]
>
> The live Smart Group behaviour — including the fact that joining one wipes every existing
> peer — is documented on the [ESP-NOW mesh page](https://totem-cb8b3887.mintlify.site/protocols/espnow-mesh.md). Nothing on this
> page changes it. `peer_auto_bond.py` is recorded here only so the module is no longer
> undecoded; it has no effect on any device.

## Not recoverable from this image

* **Why `log_path` is undefined.** Whether it was a module-level constant, a parameter, or
  an attribute that an edit removed is gone with the source. Only the dangling
  `LOAD_GLOBAL` survives.
* **The block size behind `statvfs('/')[3] < 39`.** The threshold is in blocks; the block
  size is a property of the mounted filesystem, not of this module.
* **Whether `debug.bin` was ever populated in the field.** Nothing in this image arms the
  recorder, but a factory or development image built from the same source with a different
  `boot.py` or `main.py` could have. Those files are not frozen into the image this decode
  is taken from.
* **What consumes `/debug/{mac}` server-side.** The endpoint and the record layouts are
  confirmed; the decoder on the other end is not in the image.
* **Whether a record tag `1` ever existed.** The tags `0` and `2` are literals with no
  named constant behind them.
* **What `WriteLog` was built for.** It takes an arbitrary filename and has no caller, so
  the file family it was meant to manage is unknowable. Its `7100`-byte threshold matching
  `Debugger.save` is suggestive of `debug.bin`, but that is the only hint.


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