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:243–255):
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)
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)
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 preallocatedmemoryview reused every cycle.
Generic record — format '<BiHbe', 10 bytes, packed at debugger.dis:433–445:
rtc.unix(precision=1) returns whole integer seconds, per the
f_lib/rtc_v2 decode. The battery value is chosen at :418–432:
modes.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:570–585:
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):
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:378–622)
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.
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.
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 importdebugger, 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.
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:
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.
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)
**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)
_is_erris a one-way latch. Once a write fails it is setTrue(:946) and the early return at the top ofadddrops every subsequent record silently. Nothing in the module ever sets it back toFalse.- The rotation scheduling is outside the flush block. The
POP_JUMP_IF_FALSE 124that guardslen(self._queue) >= self._queue_szlands on byte 188 of the function, which isLOAD_FAST 0 / LOAD_ATTR _next_save(:963–964) — so the_next_savecheck 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
(: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)
- The rotation threshold is the same
7100bytes asDebugger.save(:1058, and:709for the other), so the two writers were sized against the same budget. - It compresses rather than renames.
compress_filedefaults tois_delete=True, per thef_lib/file_mgrdecode, so the original is removed and only<base>-<unix>.gzsurvives.Debugger.saveinstead renames todebug-<unix>.binand leaves it uncompressed. The two halves of this one module rotate incompatibly. self._max_logs > -1is the disable switch. AWriteLogconstructed withmax_logs=-1skips the counting loop entirely and rotates without any cap on how many archives accumulate. Confirmed from theLOAD_CONST_SMALL_INT -1/__gt__at:1072–1074.- The
wdt_mgr.set_block(..., True)at:1138–1143is redundant — the blocker was already set at the top of the sametry, andset_blockis not a counter. Harmless, but it means reading this function as “the blocker is taken just for the compression” would be wrong.
'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.
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)
GreppingIMPORT_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_pathis undefined. Whether it was a module-level constant, a parameter, or an attribute that an edit removed is gone with the source. Only the danglingLOAD_GLOBALsurvives. - 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.binwas 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 differentboot.pyormain.pycould 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
1ever existed. The tags0and2are literals with no named constant behind them. - What
WriteLogwas built for. It takes an arbitrary filename and has no caller, so the file family it was meant to manage is unknowable. Its7100-byte threshold matchingDebugger.saveis suggestive ofdebug.bin, but that is the only hint.