Skip to main content
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 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.
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:243255): 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:281310)

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

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:433445: rtc.unix(precision=1) returns whole integer seconds, per the f_lib/rtc_v2 decode. The battery value is chosen at :418432:
That is, while the charger is attached the record carries the learned maximum, not the live cell voltagemodes.batt_volts_max is the learned ceiling described on the power page. 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:570585: 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, :497569):
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.
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:378622)

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

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.
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. 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.
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:
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 ifs is not recoverable — both POP_JUMP_IF_FALSEs 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 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: Between them they reference the debug global five times, and every reference is behind the same flag:
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.
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:865910) — a REPL helper with debug.bin as its default argument (:381385). It opens the file in 'rb' mode and prints each newline-delimited chunk raw:
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:912919) — 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) (:619629), and the uploader globs get_files(prefix=root_log.split('.')[0]), so it would collect debug.bin and any debug-* siblings.
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:822854)

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

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 (:963964) — 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. _scheduled_rotation (:9841000) 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:10021192)

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, 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 :10721074.
  • The wdt_mgr.set_block(..., True) at :11381143 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. 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, 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.
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 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)

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)

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: 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.
The live Smart Group behaviour — including the fact that joining one wipes every existing peer — is documented on the ESP-NOW mesh page. 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.