Reverse-engineering a $30 USB power meter, and writing it a new brain
Table of Contents
- Act I — The protocol, and the word that wasn’t there
- Act II — Getting inside: the key I couldn’t compute, and the one I stole
- Act III — Reading the firmware in Ghidra
- Act IV — The physical board (a parallel thread)
- Act V — Bending the stock firmware
- Act VI — A new brain: the from-scratch firmware
- Act VII — The crown jewels: making it do the thing I came for
- What it adds up to
How I went from "why won't this meter do what I want over USB" to a from-scratch replacement firmware that triggers USB-PD, QuickCharge, and Samsung AFC on command. How I went from “why won’t this meter do what I want over USB” to a from-scratch replacement firmware that triggers USB-PD, QuickCharge, and Samsung AFC on command — by way of a failed side-channel attack, a live SRAM heist, a full Ghidra teardown of the vendor firmware, and a microscope. The RuiDeng TC66C is a lovely little thing: a USB-C inline power meter with a colour screen that reports voltage, current, power, and cumulative energy for whatever you plug through it. It costs about the price of a large pizza. I bought one to profile the idle-vs-active power draw of a recorder board I was working on, and for that it’s perfect. Then I wanted to do something slightly more ambitious — I wanted to script it. Poll it fast, log cleanly, and (this is the part that turned into a rabbit hole eleven days deep) make it trigger a charger up to 9 V or 12 V on command from my PC, the way it can from its own buttons. The meter’s box literally calls it a “Type-C USB PD Trigger.” Surely that’s reachable over the wire. It is not. And chasing why not took me through essentially every layer of the device — its host software, its wire protocol, its encrypted firmware, the readout-protected chip that firmware runs on, the physical PCB under a microscope, and finally a complete clean-room replacement firmware written in Rust. This is the whole story, dead ends included. Especially the dead ends. The firmware is open source (MIT): github.com/FletcherD/tc66-custom-fw — prebuilt releases, a one-command flashing tool, and a path back to stock. A note on the timeline before we start: this happened fast and dense — roughly August 15–26, 2026. The reverse-engineering and the attacks on the chip filled the first week; the from-scratch firmware then landed in a burst (13 commits one day, 20 the next, 32 the day after that). I’ve kept things chronological, but a few threads ran in parallel and I’ll flag it when they did. The firmware kept growing afterwards, in shorter bursts through September and mid-October; where that later work belongs to a topic below, it’s folded into that topic’s section. The TC66C shows up on Linux as a plain USB CDC serial port ( There are two pieces of official software to learn from — a Windows PC app and an Android app — and neither hands you the answer politely. The Windows app, The move is to decompile with ILSpy ( The Android app ( The way through, on a rooted phone with the app running: Then Between the two apps, the entire vocabulary of the TC66C is seven bare, 5-byte, unterminated ASCII commands: Over BLE it’s the same verbs with a That negative result is the hinge of this whole project. If the trigger had been one A few things I learned characterising the protocol properly, because they shaped everything later: The AES key gotcha. The The parser is dumber than you’d think, in a useful way. I wrote a probe harness ( Polling faster makes it worse. This one is genuinely counterintuitive. The measurement engine only refreshes at ~2–3 Hz, and every The datalogger is plaintext. The BLE radio is just weak. Our unit advertises under the name of its Bluetooth module ( To do anything real I needed the firmware, and the firmware ships AES-256-ECB encrypted, decrypted on-device by the bootloader as it flashes. This is real encryption, not obfuscation: the images have ~7.85 bits/byte of entropy, a single fixed block repeats ~465× (the ECB fingerprint of a constant plaintext block under one static key), and consecutive firmware versions differ in ~15.95 of every 16 bytes — full avalanche, no weak custom cipher. The key isn’t in any host software (the PC app streams the The chip is a GigaDevice GD32F150G8 — an ARM Cortex-M3, 64 KB flash, 8 KB SRAM. So I had two independent ways at the key: pull it out of the chip’s power consumption while it decrypts (a side-channel attack), or read it off the chip directly over the debug port. I went at both in parallel. One of them worked. It wasn’t the one I spent five days on. The idea behind Correlation Power Analysis (CPA) is beautiful: when the chip decrypts a block, the tiny data-dependent variations in its power draw leak the intermediate values, and from enough traces you can statistically recover the key one byte at a time. I fully control the ciphertext (I’m the one streaming chunks to the bootloader), the decrypt happens a deterministic ~9.1 ms after each chunk, so it’s a textbook setup. I threw a lot of hardware at it, in escalating desperation: I learned real things. The AES leak on the supply rail is broadband but low-frequency — the board’s decoupling capacitors low-pass the switching current — so an LNA with a 10 MHz high-pass corner puts the signal out of band and you see nothing; a 100 kHz LNA restores it. I confirmed a 16.9 MHz clock line was a real fundamental (not an alias) with sidebands that grow during the decrypt, and recovering its phase to resample onto a clock-synchronous grid measurably de-jittered the traces. And none of it converged. Not with hardware power models, not with learned linear regression, not with EM-minus-supply common-mode subtraction, not with chosen-ciphertext isolation. For a while I had a “lead” — a machine-learning attack that named byte 0 as There was a measurable wall — at 25 MHz analog bandwidth the whole 16-byte block is handled in ~0.6 µs, so no single time sample isolates one key byte’s contribution; every key guess correlates about equally. But here’s the part that stings, and I only learned it after I had the key by other means: the entire attack was built on two wrong assumptions. I’d assumed AES-128 (it’s AES-256, which needs the last two round keys to invert the schedule, not one), and my ground-truth oracle checked that the fill block decrypts to The other route was to read flash directly over SWD (the ARM debug port). To even attach a debugger I first had to find the pads — which is where the physical teardown began (more on that in Act IV), but the short version is I soldered wires to the 5-pad header and NRST pin, ran them to a Raspberry Pi Pico running a bit-banged SWD engine, and started probing. The chip is locked at RDP level 2 — the strongest GigaDevice readout protection — and I confirmed it empirically. I could catch the debug port alive in the power-on window and read the CPUID (that’s how I confirmed it’s a Cortex-M3, correcting an earlier guess of M0). But: Every direct path to flash was closed. The chip’s readout protection was doing exactly its job. The breakthrough was a reframe. RDP2 gates flash data reads. It does not gate SRAM. And SWD lets you read SRAM in the background while the firmware runs, non-intrusively, without halting anything. And where does the plaintext firmware — and the AES key schedule — necessarily exist in the clear? In SRAM, during the bootloader’s decrypt. I didn’t need to defeat flash protection at all. I needed to catch the bootloader in the act. So (no power-cycle — that would wedge the USB of a half-updated app; I attach to the already-running target): The post-decrypt dump had ~294 words of new nonzero state versus the idle bootloader — the decrypted block plus the AES key material. Diffing against a clean idle dump isolated it. At offset 0x2c8 sat a 32-byte value: It decrypts every firmware version I have to a valid Cortex-M3 image — stack pointer in The lesson I keep from this: a clean architectural read of the target (“the plaintext must pass through SRAM, and SRAM isn’t protected”) beat a week of increasingly sophisticated signal processing aimed slightly wrong. Sometimes the move is to stop attacking the hard thing and notice the soft thing next to it. With the key, I could decrypt all four firmware versions and open them in Ghidra. This is where the project turned from “attack” into “understand,” and there’s one discovery that everything hinged on. The app is linked to run at I drove this with a mix of the Ghidra GUI (bridged to a live model via GhidraMCP on port 8080) and headless PyGhidra scripts for reproducibility. The command dispatcher ( I found three AES-256 keys sitting back-to-back at The bootloader (v1.06) is small and, from a security standpoint, remarkably trusting. It reads the K1 button on pin PF0 at power-on: released → validate the app and jump to it; held → enter serial update mode. It exposes six verbs (not the two the PC app uses — there are also The consequences of that design are the keys to everything I did next: The net security posture: anyone who can hold a button and knows the (now-recovered) image key can flash arbitrary firmware onto this device, provided it’s a valid ≤52 KB Cortex-M image and runs on the signed chip. Which, conveniently, described me. I’ve been referring to the teardown; let me back up, because it ran alongside the firmware work (roughly Aug 19–22) and it’s a satisfying little sub-project of its own. I needed two things from the physical board: the debug pads (to attach SWD for the key heist) and the full pin map (to eventually write firmware that drives the right GPIOs). Getting them meant identifying every component and reconstructing the netlist of a four-switch, densely-routed two-sided board about 22.6 × 40.2 mm. Component inventory. Reading package markings off microscope frames and confirming them by live bus-probing later: the GD32F150G8 MCU (QFN-28), a DX-BT24-M BLE module (the source of that The stitching problem. My primary imagery was a freehand microscope video pan, snapshotted into ~34 frames at wildly varying magnification. Automatic stitchers (OpenCV, Hugin) fail badly here, because a flat plane covered in repeated silkscreen produces high-confidence false matches — RANSAC happily locks onto a look-alike region a centimetre away with hundreds of inliers. One frame matched with 439 inliers and was wrong by a full centimetre; another doubled the “2020-11-04” date stamp. So I built a little drag-to-place tool: a browser canvas where I seed each frame’s rough position by hand, and a CV backend then refines each seam with SIFT, but rejects any match that wants to move a frame farther than half a frame-width — which is exactly what stops the repeated-silk false locks. The recurring lesson, which I wrote at the top of the notes: inlier count is not evidence on a repetitive board; every placement needs an independent photometric or visual check. The upgrade that made it rigorous. Eventually I depopulated the board with hot air and scanned the bare PCB on a flatbed at 2400 dpi. Evenly lit, perfectly flat, distortion-free. I registered the two sides to each other to ~2.4 µm RMS using the mounting holes as fiducials — good enough to follow any via straight through the board — and traced the whole thing into a to-scale KiCad project on top of the scans. And the beauty of a depopulated board is that ambiguous routing stops being a guess: with no parts in the way, you just beep it out with a multimeter, and continuity means “same copper net,” no exceptions. The output of all this — the pin map — is what fed the firmware: I also mined the stock firmware for the behaviour behind those pins: the INA226 is used as a raw ΔΣ ADC (config Before writing anything from scratch, I built a toolchain to patch the vendor firmware, as a lower-risk way to prove I understood it. Then two real patches: Killing the “faster is slower” coupling. Remember that pathology from Act I? I found its cause in the disassembly: everything runs in one cooperative loop, and I also investigated a deeper fix — moving the sampler into a timer interrupt to fully decouple measurement from polling — and decided against it, which I think is worth mentioning because knowing when to stop is part of the craft. The only spare-enough timer already runs the 1 Hz energy integration (change its rate and you break the energy math and the day clock); the samplers aren’t re-entrant and get called from nine places inside the charge-protocol state machine; running a full bit-banged I²C read in an ISR risks blowing the 1 ms USB service budget; and a fault in interrupt context is an unrecoverable lockup with no equivalent of the bounded-spin safety net. The Reflashing with no button. The most fun stock-firmware patch: a new At this point I could measure, patch, and reflash the meter at will. But patching someone else’s firmware only goes so far. I wanted the whole thing. The custom firmware is written in Rust ( The first goal was simply to boot my own code, enumerate as the stock CDC device, answer The boot contract has four hard-won rules, each of which cost real debugging: I moved to the Rust embedded ecosystem USB stack ( That gate earned its keep immediately, via one of my favourite bugs. I’d placed the flasher’s linker section with I brought up each peripheral behind a host-triggered “dev verb” first — nothing runs at boot, every wait is bounded, faults return staged diagnostics — so a mistake is always recoverable by power-cycle and never bricks the boot path. Only once proven does a feature graduate to the always-on path. The I²C bus scan found the INA226 (confirmed genuine via its manufacturer-ID The on-chip ADC (for temperature and D±) had a great gotcha: its clock comes from a dedicated 14 MHz oscillator that’s off at reset, and the ADC’s self-calibration hangs forever without it. The staged dev verb caught this cleanly (it returned “stage 4: oscillator never stabilised”). My first pass at the ADC channel map was empirical, and partly wrong: the stock code reads its three analog inputs as “inserted-group ranks” 1/2/3, and I’d mistaken those rank indices for channel numbers. Going back to the stock ADC init settled it — ch0 (PA0) is the NTC, ch1 (PA1) is D+, ch9 (PB1) is D− — and the pin I’d been reading as D+ (PB0) turned out to be the Bluetooth module’s UART output, idling at 3.3 V. One board design, one channel map. My first temperature model (a fitted one) reported an impossible 11.6 °C on a cool board; adopting the stock β-model gave a sane 27 °C, and it now runs in integer fixed point, proven bit-identical to the stock floating-point formula over every possible input by an exhaustive host-side test. Details matter. A dense middle stretch: A hardware-paced sampler: a timer interrupt reads the INA226, so measurements no longer depend on how fast (or whether) a host polls. It started at 250 Hz; it now ticks every 2.24 ms and reads both the shunt and bus voltage on each tick, which catches about 98% of the conversions the INA226 actually makes. The averaging window and the screen refresh rate are settings, and the energy counters integrate every sample. A plaintext The device became a composite USB device that emulates a ChargerLAB POWER-Z KM003C on a vendor interface, so the mainline Linux The ST7735S display, driven by a non-blocking DMA pixel engine (16-bit SPI frames on a DMA channel, pumped from the main loop so it never blocks USB or the sampler), with a 1-bpp font generated from Inconsolata Bold and a three-line V/I/P screen. The first frame draws before USB enumeration, so the screen is live from power-on. An overnight incident — a static screen caused real LCD image persistence — drove a 15-second screen-off timeout, with the wake condition later refined from “no input” to “no change in input” (a steady load parked on the port is as static as no load). Six screens, stepped with K1: V/A/W, energy and run time, temperature and D±, a full-screen scrolling V/I plot, a USB-PD info screen, and a charger-detection screen (both in Act VII). The plot uses the ST7735S’s hardware vertical scroll, so each new sample costs one column of pixels rather than a redraw. Holding K2 resets the energy counters. Persistence (M2) in the stock firmware’s own EEPROM layout: settings, the wear-levelled energy ring, and the datalogger that A hardware watchdog (~13 s), fed once per main-loop pass and inside the self-flasher. And underneath it all, the architecture had to change from “poll everything in one loop” to interrupt-driven, which is where the chip fought back hardest. I keep a file called There’s also a permanent debug reality behind all of it, imposed by RDP2: SWD One more safety mechanism, because it embodies the central fear of this whole project. A stack overflow on the boot path hardfaults before the D+ pull-up — producing an invisible dead board that needs physical K1 recovery. That shipped once, as build 35, and it’s a miserable way to find out. So every build runs a Everything so far was infrastructure. This is the payoff — exposing over USB the fast-charge triggers the stock unit keeps locked behind its buttons. The triggers landed in one intense day (the M4 milestone), with the PD sniffer and charger detection following in October, all building on the FUSB302 PD PHY, the D+/D− drive GPIOs, and the pin map from the teardown. Every one of these was verified against real chargers on the bench. The FUSB302 (I²C address 0x22) does the Type-C physical layer — the line coding, the CRC, the automatic GoodCRC reply — while my It worked, eventually, against an Apple 96 W charger at 5/9/15/20 V — after two instructive bench bugs. First, a source you plug in before arming has already given up trying to talk PD (it exhausted its capability-message retries against the chip’s silent default), so PD 3.0 and PPS (the fine-grained adjustable voltage mode) came next, with a rule learned on the bench: lead with revision 2.0 and only upgrade to 3.0 after the source declares 3.0 in its capabilities, because a 3.0-stamped reply to a 2.0 message made the Apple charger reset the link. PPS needs a keepalive (re-request within ~10 s or the contract dies), so the engine re-sends the standing request every 8 s. Verified: PPS fine-stepping across 3.3 / 5 / 9 / 11 V, each tracked within ~50 mV on the meter’s own sampler, and a 9 V PPS contract held steady for 24 seconds — past the deadline, confirming the keepalive. I also exposed the CC-pin voltages (via a binary search on the FUSB302’s built-in comparator) and re-exported every PD message the meter sends or receives — in the real KM003C wire format — over the POWER-Z vendor endpoint, so a KM003C protocol tool can watch the meter’s own live PD traffic. That last feature was gated not by protocol difficulty but by 336 bytes of RAM, which is what forced the The FUSB302 can also listen without taking part. In sniffer mode the meter stops presenting itself as a sink and records whatever a charger and a device say to each other through it: source capabilities, requests, vendor messages, the cable’s e-marker answering on SOP′/SOP″, attaches and detaches, hard resets, and even packets that fail their CRC. It works in either plug orientation and can make the pair renegotiate without replugging. The capture goes out in the same KM003C PD format, and a host tool ( Two bench lessons. First, the FUSB302’s BMC slicer threshold matters: at the default, one VDM in every capture went missing, and nudging the threshold brought it back. Second, my unit has a board fault: the OUT port’s CC2 contact is nearly open, so sink attach and hard reset only work with the line on CC1. Both CC nodes are unloaded at DC and no GPIO or ADC pin touches CC, so there’s nothing firmware can do about it. The sink side learned the same trick. At attach it listens to the source probing the cable’s e-marker, and it can ask a PD 3.x source for its extended capabilities, status, and identity. QuickCharge is a different animal — not a CC/PD protocol at all, but fixed DC voltages presented on the pass-through D+/D− data lines. So it’s a separate, GPIO-only engine driving the resistor network on those four pins (recovered pin-for-pin from the stock firmware). QC2.0’s discrete levels verified at 5.11 / 9.13 / 12.14 V against a Class-A charger. QC3.0’s continuous mode — where you pulse a line to step ±200 mV — verified bidirectionally: base 5.31 V, +10 steps to exactly 7.30 V (200 mV × 10), and back. Two gotchas: back-to-back pulses merge into one edge unless you leave a ~20 ms dwell between them, and my initial down-pulse had two pins swapped so it did nothing. (A standing hazard: the D+ drive pins are also SWDIO/SWCLK, so SWD is dead while a QC trigger is active.) This is the richest debugging arc in the project, and the honest version is better than the tidy one. Samsung’s Adaptive Fast Charging is a single-wire serial protocol on D−. I implemented it straight from my reverse-engineering notes as 152 µs NRZ bits, MSB-first, odd parity, with a start pattern — and it didn’t trigger the charger. Six iterations followed: The thing that actually made it work was never the encoding at all. It was three things: the protocol is closed-loop (the master drives a “ping,” then reads the pin back to follow the charger’s response ping before each byte — an open-loop replay engages the charger but never completes the handshake); the entry sequence releases the lines to high-impedance and lets the charger source the D− level rather than driving it; and the bit timing has to run with interrupts masked so the sampler interrupt doesn’t stretch a bit cell mid-byte. With all three, The lesson I took: when the low-level output is provably correct and it still doesn’t work, stop re-examining the low level. The bug is in the protocol state, not the bits. Huawei’s FCP and SCP ride the same physical layer as AFC, so they were scaffolding on the same engine — multi-byte register packets with a Huawei-specific CRC-8 (verified bit-exact against all my reverse-engineered examples) and three leading sync pings. But I have no Huawei charger on the bench, so these are explicitly untested, with three load-bearing assumptions marked in the code (most critically, whether the parity bit is per-byte or runs across the whole packet). Shipping untested code labelled as untested, with the specific unknowns called out, is the honest thing to do — but it’s not “done.” The detection screen answers “what does this charger support?” without ever raising the voltage: it never goes above 5 V. One K2 press reads the Type-C current advertisement, checks for Apple/Samsung D± dividers, probes QuickCharge and AFC, and lists every PD offer. It runs as a state machine stepped from the main loop, so USB stays responsive while it works, and it refuses to start if something is plugged into the other port, because a probe that accidentally raised the voltage must never reach a device. Finally, the BLE link: I reimplemented the stock transport for the on-board BT24 module over its UART, with DMA for both receive (a ring) and transmit, so it costs zero interrupts — I hold a strict two-interrupt budget: only USB and the sampler. I recovered from the stock firmware that the module is on USART1 with TX on PA2, RX on PB0 (the old notes said USART0 — wrong). The bench corrected the other half of my notes: the stock firmware never provisions this unit’s module, so it runs at its power-on default of 9600 baud. The module answers every AT query but rejects every persistent write with Eleven days to get here, a few more bursts to finish it, one $30 meter, and it now does everything I originally wanted and a lot I didn’t know I wanted: A few things I’ll carry forward. First, the cheapest win in the whole project was a reframe (SRAM, not flash), and it beat a week of signal processing pointed slightly wrong — the CPA campaign was aimed at AES-128 with a broken oracle and could never have succeeded, which I only learned once I’d won by other means. Second, “the low-level output is provably correct and it still doesn’t work” almost always means the bug is one layer up — the AFC handshake, not the AFC bits. Third, on a locked, hard-to-debug chip where a boot-path fault means physical recovery, the boring safety infrastructure — the build-time gates, the recovery path that can’t be bricked, the dev-verb-first bring-up — isn’t overhead. It’s the only reason the fun parts were ever affordable to attempt. Recovery, incidentally, still always works. The bootloader is never touched by any flash. Hold K1, reflash stock ( Everything here was done on hardware I own, against my own devices. The firmware images, vendor apps, and datasheets aren’t redistributed. The custom firmware, its host tools, and its documentation are at github.com/FletcherD/tc66-custom-fw. Act I — The protocol, and the word that wasn’t there
/dev/ttyACM*). No driver needed. So the first question is simply: what do you say to it? Cracking open the PC app
RuiDengUSBMeter.exe v1.0.0.6, is a .NET application. Two obstacles: its dependencies are bundled inside the executable with Costura (deflate-compressed into resources), and it’s lightly obfuscated with ConfuserEx, which encrypts constant strings. That second detail matters more than it sounds: run strings on the binary and you find nothing — no command verbs, no protocol. A naive look would conclude the protocol was hidden. It isn’t; it’s just encrypted at rest and decrypted at runtime.ilspycmd -p -o out RuiDengUSBMeter.exe), which resolves ConfuserEx’s string encryption automatically and gives you clean, decrypted C#. And the entire serial surface of the app collapses to a single class — MainWindow.cs — which holds the one and only SerialPort in the whole program. Every serial.Write in the app either sends one of a handful of fixed 5-character strings, or streams a firmware image in 64-byte chunks. I extracted all 13 Costura-embedded assemblies to be thorough; they’re stock open-source libraries (JSON, Excel export, WPF charting) and none of them touch the serial port. There was even a trap along the way: substring hits for “clear” and “record” that turned out to be ordinary English inside XML strings, not commands. The Android app fought back harder
com.rdtc.blesample) is packed with 360 Jiagu. The classes.dex in the APK is a six-class stub; the real code is decrypted into memory at runtime, so static analysis of the file gives you nothing at all. Worse, 360 Jiagu wipes the 8-byte DEX magic in the live memory mapping after the runtime has loaded it — an explicit anti-dump measure — so you can’t even find the real DEX by scanning for its header./proc/<pid>/maps as root.file_size (here ~4.5 MB, 3480 classes).dd that region out of /proc/<pid>/mem.dex\n035\0 back into bytes 0–7. The stored Adler-32 checksum (which covers bytes 12+, not the magic) already matched — proof the body was intact — then recompute the SHA-1 signature to make it canonical.baksmali and jadx on the repaired DEX. The payoff: the Android app is BLE-only — it has no USB code path whatsoever — and its command set is a thin mirror of the USB one. The complete command set
Command Reply Purpose getva192 bytes, encrypted poll all measurements rotat(none) rotate the screen 180° nextp / lastp(none) next / previous on-device screen gtrec8-byte records + reckdump the internal ~1 Hz datalogger queryfirm… or bootask whether the app or bootloader is running updateuprdy, then OK/chunk(in the bootloader) begin flashing b prefix and a CRLF (bgetva\r\n, etc.). That’s it. And crucially, note what is not there: no command to start/stop/clear recording, no group reset, no calibration, and — the one I came for — no PD/PPS trigger, no QuickCharge, no Samsung AFC, no Huawei FCP/SCP. I swept both apps exhaustively for any byte sequence, hex literal, or base64 blob that could be a hidden command. Nothing. The fast-charge trigger is real in the device — the vendor changelog even dates it to firmware v1.11 in 2019 — but it’s driven entirely from the physical buttons. The wire protocol simply does not expose it.serial.Write away, I’d have written a 20-line script and gone to bed. Because it wasn’t, the only way down was through — into the firmware and the silicon. The details worth keeping
getva reply is 192 bytes = three 64-byte blocks (pac1/pac2/pac3), AES-256-ECB encrypted with a static factory key, with measurements at fixed little-endian offsets after you decrypt (voltage /10000 → V at offset 48, current /100000 → A at 52, and so on). Two keys for this circulate online, and only one of them works on my unit — the so-called “rdserialtool” key (5821fa56 01b2f026 …). The other, widely-posted “decimal” key decrypts to pure garbage. If you’d started from the wrong reference you’d swear the format was undocumented. (This exact key later turned up sitting in the firmware image, confirming it.)protocol_probe.py) that drives both of my meters at once and fuzzes the framing. One rule explains all the behaviour: the firmware reads one USB packet at a time and compares only the first 5 bytes against its command table. There’s no ACK/NAK (an unknown command produces total silence, indistinguishable from a valid silent command like rotat), no cross-packet accumulation (send getva as five 1-byte writes and you get nothing, even at zero delay), and matching is exact and case-sensitive. Baud rate is a no-op because it’s USB CDC, not a real UART; DTR/RTS and BREAK are ignored.getva reply costs the single-core chip ~60 ms of AES + streaming that starves its own measurement task. So the harder you poll, the fewer distinct values you get back. Distinct-data capture actually peaks at a poll interval of about 0.22 s and degrades on either side. My driver ends up flooring the interval at 0.2 s and offering a --dedupe flag. (This “faster is slower” coupling became a design target I got to delete later, in the custom firmware.)gtrec dumps the meter’s rolling ~1 Hz log as 8-byte records — and unlike getva, they’re not encrypted, just little-endian voltage and current, terminated by the ASCII reck. There’s no command to start, stop, or clear it; logging is automatic whenever there’s voltage on the port.BT24-M), not “TC66C”, so you match on the service UUID ffe0, not the name. And the antenna is genuinely feeble — about −76 dBm from inches away. Connecting from a PC is a coin-flip unless the meter is right next to the antenna. That’s hardware, not software, as I’d confirm much later. Act II — Getting inside: the key I couldn’t compute, and the one I stole
.bin to the device verbatim). It lives only on the chip. The side-channel campaign (a five-day dead end)
OK ack, and I moved the window there.0x72 with a plausible p-value — until I did the honest thing and ran a permutation null: shuffle the labels and see if the “signal” survives. It did. The winner was noise that my 256-way search was overfitting. The killing blow was recapturing byte 0 fresh: it named a different value (0xaa), and 0x72 didn’t even make the top six. A real fixed-key leak names the same byte every time. Mine didn’t.0xff×16 when it actually decrypts to 0x00×16 — so even a perfect byte recovery would have been the wrong quantity, checked against an oracle that could never pass. Five days of careful signal processing, aimed at the wrong target with a broken verifier. The write-up in cpa/RESULTS.md is more honest with me than I was with myself at the time. The readout-protection wall
The heist
update, get uprdy.OK.381255645a6b7aaabc6d5877f5c1fbff9805d1ac3c8335661a610c7ff73a638c0x2000xxxx, reset vector in 0x0800xxxx with the Thumb bit set. That’s the image key. And of course, only now did I learn it was 256-bit and that the fill block decrypts to 0x00×16 — the two facts that, had I known them a week earlier, would have told me my CPA attack was doomed from the first trace. Act III — Reading the firmware in Ghidra
The load address that makes or breaks everything
0x08002c00, not 0x08000000. It sits immediately after the 11 KB bootloader. If you load the raw decrypted image at 0x08000000 — the obvious thing — the Thumb code still disassembles fine (it’s mostly PC-relative), so it looks like it’s working. But every absolute pointer lands 0x2c00 too low: the vector table, string tables, the AES-key pointer, the jump tables. Almost no cross-references form, and the analysis is quietly, thoroughly wrong. Rebase at 0x08002c00 and the whole thing snaps into focus. I confirmed the base three independent ways (the app’s own reset vector, the bootloader’s jump target, and the first instruction of main writing 0x08002c00 to the vector-table-offset register). The bootloader, separately, loads correctly at 0x08000000. What the app is
usb_command_dispatcher) is a first-byte compare chain — which is exactly why string searches never found the commands: there are no verb strings and no immediate compares to grep for, just if (buf[0] == 'g') …. It confirmed the seven-command set from the firmware side, and confirmed the negative result too: there is no PD-trigger verb in either the USB or BLE dispatcher. The fast-charge engine exists in the binary, but nothing in the command path reaches it.0x080098c3: two “UID signature” keys and the getva telemetry key (the rdserialtool key, confirming the online value). And I found the thing that makes this chip dangerous to modify carelessly: an anti-clone check. Before its main loop, the app AES-encrypts the chip’s factory UID and compares it against a stored signature; on mismatch, it bricks into an infinite loop. It only runs on a chip whose UID has been factory-signed. The saving grace, which I verified: a modified app on the same chip still passes, because the signature lives in a config page the app-flash never touches. You only brick if you try to relocate the app or wipe that page. What the bootloader is
uplogo, recali, writsn, and a gtsub that leaks the chip UID). The update flow erases the 52 KB app region and writes AES-decrypted chunks at a monotonically increasing offset from 0x08002c00.CHECK FIRMWARE... on screen is just a status string. The image is written blind.0x08002a8c (matching my SRAM-recovered value exactly) and a UID-signature key at 0x08002a6c. I reproduced the per-device signature in Python from the chip UID and that key, byte for byte. Act IV — The physical board (a parallel thread)
BT24-M advertising name), a BL24C256 32 KB I²C EEPROM, a TI INA226 current/voltage monitor, a 15 mΩ R015 sense shunt as the entire main current path, an M5633B 3.3 V LDO (an early misread called it a power-path switch — it isn’t; there is no controllable load switch on the board, which is why VBUS can’t be toggled in firmware), a FUSB302 USB-PD PHY (invisible in the photos, found only when the I²C bus scan ACKed address 0x22 with device-ID 0x81), and a 0.96“ ST7735S TFT.Function Pins USB D+ soft-connect PA9 (also the backlight enable — this dual use bit me later) I²C0 (INA226 0x4A, EEPROM 0x50, FUSB302 0x22) SCL=PB6, SDA=PB7 On-chip ADC ch0=PA0 NTC, ch1=PA1 D+, ch9=PB1 D− TFT (ST7735S over SPI0) SCK=PA5, MOSI=PA7, DC=PA4, RST=PA3; backlight PWM on PB3 Buttons K1=PF0 (bootloader), K2=PF1 (factory reset) Charge trigger (D+/D− drive) D+ via PA13/PA14, D− via PB4/PB5 BLE module USART1: TX=PA2, RX=PB0 0x41A7, no on-chip calibration) with all averaging and calibration done in software from per-unit constants in the EEPROM; the NTC is a 10 kΩ / β=3450 thermistor on the bottom of an 11.5 kΩ divider; the EEPROM’s 32 KB is fully mapped (a wear-levelled energy ring at 0x0000, calibration at 0x2000/0x2200, the datalogger ring at 0x2400); and the display init sequence, gamma tables, and memory offsets were all recoverable verbatim. This is the reference that made a clean-room firmware possible rather than a guessing game. Act V — Bending the stock firmware
fwtool.py decrypts, patches, re-encrypts, and — the important part — verifies. It proves the cipher is bit-exact by checking that encrypt(decrypt(vendor.bin)) == vendor.bin for every firmware version. Patches are declared as (offset, expected_old_bytes, new_bytes) tuples so the tool refuses to apply a patch if the bytes aren’t what it expects (wrong image, wrong offset). And because ECB blocks are independent, a build self-check asserts that a two-byte plaintext change alters exactly the corresponding two cipher blocks and nothing else. I proved the whole flash→observe→recover loop end-to-end first with a trivial “smoke” patch that flips the version string V1.18→V1.19 on screen.getva sends its reply as 3×64 bytes with a delay_ms(10) busy-wait between blocks — ~20 ms per poll that steals loop iterations from the sampler. The first patch just trimmed the delays (10 ms → 1 ms). The better successor, getva-txpoll, replaces the fixed delay with a bounded spin on the USB endpoint’s transmit-status flag — it waits exactly until the host has drained each block and no longer, and can never hang (a capped iteration count means worst case it behaves like a short delay). On hardware: 40/40 clean decodes, ~99% full replies under a back-to-back hammer.getva-txpoll patch already delivered “poll as fast as the ADC allows,” and the ADC’s ~2–3 Hz refresh is a hard physical ceiling regardless. So I left it.updat command that lets the running app reflash itself over USB with no bootloader dance. This is tricky because the app runs from the region it needs to erase, so the flasher has to run from SRAM and stream, and once the app flash is gone there’s no USB driver left — so the SRAM stub polls the USB peripheral registers directly and reuses the bootloader’s flash routines by absolute address (the bootloader is never erased). I injected it as a small flash-resident hook into a verified-empty hole in the image, with a branch planted over the dispatcher’s prologue. The chunks are sent as plaintext (the on-device image is decrypted anyway, so this avoids baking an AES inverse into the stub). It self-updates in about a second, ~9× faster than the bootloader path, and it’s self-hosting — the updat path survives updating itself. Recovery, as always, stays K1 + reflash. Act VI — A new brain: the from-scratch firmware
no_std), flashes into the 52 KB app slot, and keeps the stock bootloader so recovery always works. It’s wire-compatible — it enumerates as the same USB device and answers the stock commands — but everything underneath is mine and fully understood. I labelled milestones M0 through M4. The core of them landed over three days; the persistence, the extra screens, and the Bluetooth link were finished in later sessions. M0 — self-hosting
query, and reflash itself via updat. I wrote a C reference first to validate the boot contract on hardware, then ported it to Rust line-for-line. Once a Rust image could flash the next Rust image over USB, the C reference was retired and the iterate loop became make flash — no button pressing, ever. That self-hosting property is what the project is named around.0x20000E40, not top-of-RAM — the exact top faults the bootloader’s hand-off, for reasons I never fully pinned down. 0x20000E40 is proven-good, so that’s what ships.0x08002c00 early — the bootloader jumps to the app without setting it.usb-device + usbd-serial + stm32-usbd, since the GD32’s USB peripheral is the STM32F0/F1-style IP) and wrote a pure-Rust SRAM flasher. That flasher is a masterclass in constraints, because it erases the very flash it was loaded from: raw pointers only, no operation that might emit a hidden memclr call into flash, its own inline assembly for the barriers, reply strings materialised as integer literals, and the bootloader’s flash functions called through absolute-address function pointers. A build-time gate disassembles the flasher and fails the build if any instruction references app flash.INSERT AFTER .data. The Rust runtime deliberately extends its startup memory-copy around sections after .data, assuming a contiguous address — but my flasher’s run address is elsewhere, so the runtime smeared ~6 KB of flash across all of low RAM on every boot. It was completely harmless while there were zero static variables — and then instantly fatal the day I added the peripheral-access crate, whose one flag static got clobbered into a panic. The SRAM debug markers pointed straight at the line. Fix: place it after .uninit, pin the RAM boundaries, and add another build gate asserting the memory sections all land in real RAM. M1 — real, calibrated measurement
0x5449 = “TI” and die-ID 0x2260) and the EEPROM. I dumped the whole 32 KB EEPROM twice (identical hashes) before ever contemplating a write, as a permanent calibration backup. Then I implemented the calibration model recovered from the stock firmware — integer, multiply-before-divide, with the per-unit gain/offset/zero-reference constants read from the EEPROM — and reproduced a known voltage (5.2084 V) exactly against the raw registers. The getva builder embeds a known-good decrypted template, patches the live fields at their offsets, recomputes the three per-block CRC16 checksums, and AES-encrypts. Existing host tools couldn’t tell it from a real meter. M1.5 / M2 / M3 — streaming, a display, persistence, and an architecture
rawva streaming verb with a window sequence number, because a full encrypted getva costs ~20 ms of software AES — the entire reply budget — while rawva is microseconds. It streams at 700 polls/second with zero missed sampler windows.powerz hwmon driver binds it and sensors just works. This is actually the priority protocol now; the stock TC66 CDC commands ride along for compatibility. It goes well past what powerz needs: the KM003C’s graph mode streams at 2, 10, 50, or 1000 samples per second, and the open-source km003c-rs CLI and GUI work against it unmodified — live V/I plots with the PD traffic in the same timeline.gtrec reads, so switching between stock and custom firmware keeps them. All writes go through one driver that verifies every page and hard-refuses the factory calibration words. QUIRKS.md — where the silicon misbehaves
QUIRKS.md of measured, reproducible GD32F150 misbehaviours, using the stock firmware (production code on this exact silicon) as the discriminator for “is this the chip or is this me.” The greatest hits:fast.x) that pins the hot path — interrupts, USB, AES, main loop — below 32 KB took getva from 48.6 ms to 2.3 ms, and incidentally explained away most of the confounds in the two quirks above. To find what to pin, I built a poor-man’s PC profiler: the chip’s debug cycle counter is a non-functional stub, so I sample the interrupted program counter off the exception stack frame on every sampler tick into an SRAM ring and dump it over serial.halt/reset/step lock the core, and even a background memory read can leave it halted, so probe-rs/RTT/defmt are all unusable. My entire debug channel is the SRAM marker map plus the dev verbs, and liveness is confirmed over serial with no debugger attached. Debugging embedded code with essentially printf-to-SRAM and a multimeter is a throwback, but it works. The stack-gate
stack_gate.py that computes a conservative worst-case stack depth from the disassembled call graph — discovering every interrupt handler from the vector table, charging the deepest handler chain, treating recursion as a hard error — and checks it against the RAM budget. When the budget got tight, the release valve was a .hibss section that parks large statics (the AES key schedule, a PD-capture ring) in the 1 KB of RAM above the stack fence, which raises the budget byte-for-byte. Switching to size-optimised builds with fat LTO later dropped the flash from ~49.5 KB to ~35.6 KB and gave real headroom back — which the later features have since used up. The flash is full again (about 150 bytes spare in the release build); the last big savings came from replacing the AES crate with a compact hand-written AES-256 (−4.3 KB, and getva got three times faster) and dropping linker thunks and floating-point math. The rule for the next feature: room gets made on purpose, never by quietly changing code that already works. Act VII — The crown jewels: making it do the thing I came for
USB-PD sink and PPS
pd.rs implements the protocol/policy half of a minimal PD 2.0/3.0 sink: detect attach and cable orientation, bring up the BMC link on the active CC pin, collect the source’s capabilities, and negotiate a contract. Four host verbs (pdctl/pdsta/pdcap/pdreq) arm it, report state, list the source’s power profiles, and request one.arm() now drops the pulldowns for ~600 ms to force the source to see a fresh attach. Second, a timeout was comparing against a stale timestamp captured before several milliseconds of I²C, so the subtraction wrapped negative and fired the Accept timeout instantly — discarding a perfectly good exchange. The instrumentation I added to catch it (logging the last few received message headers) is what found it..hibss high-RAM statics into existence. Listening in: a PD sniffer
pdcap.py) decodes every message type field by field, using layouts taken from the USB PD R3.2 spec. The PD info screen shows the same thing on the meter itself. QuickCharge 2.0 and 3.0
Samsung AFC — the debugging saga
afctr drives a real Samsung charger from 5 V to a measured 8.92 V, reliably across power-cycles. The D+/D− drive hardware had been provably correct the entire time (QuickCharge used the same pins) — which is exactly why the encoding looked like the culprit for so long when the real gap was the handshake protocol. Huawei FCP/SCP
Charger detection, on the device
BLE
ERROR=104, so there’s no changing it. The stock b-prefixed verbs are always on, so the vendor’s Bluetooth protocol works as it did on stock. What it adds up to
tc66flash.py.tc66flash.py stock downloads RuiDeng’s own V1.18 from their server, so I never redistribute it), and it’s a boring, perfect little power meter again — until the next idea.