Approach
The firmware findings come from static analysis of public artifacts: the v5.0.2 and v5.0.3 firmware images and the companion Android app. No network service was attacked; the releases API and S3 objects are served openly. The BLE protocol was additionally checked end to end with thetotemctl client (cmd/totemctl in this repository) against the
author’s own device on firmware 4.1.3 and 5.0.3. That test found the
transmit-mode behaviour on macOS, which static analysis
alone had missed.
Tools
Steps
1
Parse the image
Confirm ESP32, 7 segments, entry point, and read the app descriptor
(IDF v5.4, build date, ELF hash).
2
Rebuild an ELF
Map the 7 segments to their load addresses with correct R/W/X perms in an
EM_XTENSA ELF for Ghidra/IDA/rizin.3
Identify the runtime
Native strings reveal MicroPython v1.25.0 — the app is Python, frozen as bytecode.
4
Recover the symbol table
Extract the qstr pools and rodata strings: the application’s method, attribute and
class names and every log line. The first v5.0.2 pass counted 2,479 identifiers and
11,857 strings by string scanning. Walking the pools directly gives 4,086 qstrs for
v5.0.2 (228 core + 972 const + 2,886 frozen) and 4,162 for v5.0.3 (228 + 975 + 2,959).
5
Reconstruct behavior
Map modules, classes, protocol categories, and subsystem behavior from those symbols
and the
{}-style (Python str.format) log messages.Why not a full source decompile?
The application is frozen MicroPython bytecode. Unlike CPython (which has mature decompilers such asuncompyle6/decompyle3), MicroPython has no mature
bytecode-to-source decompiler. The best available is bytecode disassembly (opcodes
plus qstr references), not clean Python source.
However, the frozen format keeps every identifier as an interned string (qstr) and
retains all string literals. That symbol table plus the log messages reconstruct each
module’s behavior at close to source fidelity — which is how this documentation was
built.
Closing the remaining gaps
Three items were graded “needs bytecode disassembly” in the protocol completeness matrix:- The complete per-category
cmd_idenumeration (TOTEM_MSG_MAP). - The exact binding of each
structformat to a specific message. - The field order of
CHUNK_HDR_FMT.
struct-format string appears
verbatim in the DROM rodata — and the method that produced them is recorded afterward.
The
/protocols/overview completeness matrix now marks these three
as Recovered and reproduces the full decoded map; this page records how they were obtained.TOTEM_MSG_MAP — confirmed
Defined in espnow_conn_v2.py, built by a BUILD_MAP 13 / STORE_NAME TOTEM_MSG_MAP
opcode sequence (fully decoded). The map is flat, keyed by a 2-byte bytes value
(cat_id, cmd_id) (byte 0 = cat_id, byte 1 = cmd_id); every value is a payload
struct-format string.
struct format → message bindings — confirmed (for listed)
Each payload format begins with BB = the echoed cat_id/cmd_id header:
Transfer header — format & field order confirmed
CHUNK_HDR_FMT = <HBBBiHiB, CHUNK_HDR_SZ = 16 bytes — packed by f_ble/chunking.py’s
gen_transfer_buff via struct.pack_into at buffer offset 2. The format string, field order,
and field names are confirmed from the live pack_into opcode stream: gen_transfer_buff
calls struct.pack_into('<HBBBiHiB', buff, 2, self.file_id, self.status_id, self.action_id, self.file_type_id, self.byte_pos, self.chunk_no, self.file_size, 0)
(LOAD_ATTR order in the bytecode). The first pass also bound the name CHUNK_HDR_FMT to
this format, because f_ble/file_upload.py imports CHUNK_HDR_FMT and CHUNK_HDR_SZ from
chunking. That inference was wrong. v5.0.2’s chunking never defines those names, so
the import could not have succeeded. v5.0.3 defines them explicitly, as the separate 12-byte
chunk header: CHUNK_HDR_FMT = '<HHiH', CHUNK_HDR_SZ = 12, UPLOAD_TO_APP = 3. See
chunking. v5.0.3 also turns the last byte below into
a flags byte.
How it was done
1
Locate the frozen data
In the image (file offsets):
0xf3d3 points into the qstr identifier-string data,
but mid-pool — the sorted run of identifier strings starts much earlier (around
0xe000) and flows continuously through 0xf3d3 with no sort reset. The concrete
anchors are the frozen qstr pool header at seg0 0x5de0c (prev=0x3f4213d0,
total_prev_len=1200, len=2886) with its qstrs[] pointer array at 0x5de24. The
frozen module-name registry begins near 0x27b00; the DROM segment maps file 0x18 →
vaddr 0x3f400020.2
Parse the frozen module table
Reconstruct the MicroPython v1.25 (
.mpy version 6.3) mp_raw_code_t tree for the two
comms modules that actually carry these constants: espnow_conn_v2.py (home of
TOTEM_MSG_MAP and the per-message struct-format registry) and f_ble/chunking.py
(home of CHUNK_HDR_FMT).3
Disassemble the bytecode
Walk the bytecode with a v1.25-matched reader. Reuse the opcode tables from
py/bc0.h
/ tools/mpy-tool.py, but note mpy-tool.py -d itself only reads standalone .mpy
blobs; frozen firmware modules (emitted as proto_fun_* / fun_data_* /
const_qstr_table_data_* C structs, not .mpy blobs) require a custom frozen-aware
reader. Small integers -16..47 load inline as single-byte opcodes 0x80–0xBF;
larger ones via LOAD_CONST_SMALL_INT (0x22, signed var-int) — both embed the value
in the bytecode. The TOTEM_MSG_MAP dict build and the CHUNK_HDR_FMT string literal
are then readable.4
Cross-check
Validate recovered
(cat_id, cmd_id) values against the pairs already visible in the
logs. (0x01, 0x02) and (0x03, 0x01) validate directly against TOTEM_MSG_MAP.
(0x06, 0x07) is a Peer-Sync BLE control frame handled outside TOTEM_MSG_MAP, so
cat 6 is absent from the map — its absence is expected, not a failed cross-check.TOTEM_MSG_MAP / CHUNK_HDR_FMT from a REPL — would
yield the same values faster if a device or matching build is available.
Diffing firmware versions
The v5.0.3 image was processed with the same reconstruction and compared to v5.0.2 function by function:1
Carve and find anchors
Carve the 7 segments from the image header. Then locate the structures the
reconstructor needs by their shape rather than by fixed addresses:
- qstr pools: headers
{prev, total_prev_len, alloc, len, hashes*, lengths*, qstrs[]}, validated by the NUL terminator at each declared length. - Type objects:
mp_type_typeis the only type object whose base points to itself;str,bytes,tuple,floatandintpoint to it and carry their name qstr at +6. - Frozen module table: the longest run of pointers to
{qstr_table, obj_table, proto}records whose first qstr ends in.py.
0x5f59c (base 1203, 2,959 qstrs), the const pool at 0x22208 (975) and the module
table at 0x3f4287b0–0x3f428924 (94 modules).2
Reconstruct and disassemble
Re-serialize every frozen module as a standalone v6.3
.mpy and disassemble it with
MicroPython’s own mpy-tool.py -d: 96/96 modules for v5.0.2 and 94/94 for v5.0.3, with
no unknown opcodes, truncated functions or dangling children.3
Diff
Split each disassembly into functions, drop the byte-offset column, and compare
added, removed and changed functions plus the qstr and constant tables. 74 modules are
identical, 20 changed and 2 were removed. See
What changed in 5.0.3.
Artifacts
The reverse-engineering scratch (rebuilt ELF, extracted symbol/string tables, carved segments) is kept out of git via.gitignore. Regenerate it from the image with the
steps above; this docs/ tree is the durable record.