Claude Code and I Reverse-Engineered a USB-C Meter. I Mostly Plugged In Cables
Why a meter needs a new app
open-powerz has 648 commits and 19 tagged versions so far, and the latest release has 7,875 tests. More than 100 Claude Code sessions worked on it, each in its own visible terminal tab.
The one human on the project mostly plugged in cables, typed "yes" and said "don't brick it".
open-powerz is an open-source (MIT) app for the POWER-Z KM003C, a small USB-C power meter and USB Power Delivery analyzer. It's a Rust protocol library, a powerz CLI, a desktop app for macOS, Windows and Linux, and a web edition that talks to the meter straight from Chrome or Edge.
This post covers what it does, how we worked out the protocol without copying the vendor's code, how tests on the real meter decided between conflicting sources, and how a crowd of AI sessions worked near real hardware without bricking it.
The KM003C sits between a charger and a device and shows voltage, current, power and the PD negotiation between them. To use it from a computer, the vendor offers its PC app, POWER-Z Lab.
The vendor still updates it: V2.5.0 and V2.5.1 came out in spring 2026, and V2.5.2 later. It's a Qt6 program that ships as two executables, Mtools.exe (the main app) and PDM.exe (a separate trigger tool). The stable release is Windows-only.
Since March 2025 there has been a V3.0 beta for Mac, available only through a Telegram group. There's nothing for Linux or the browser.
The vendor also publishes an API description, the serial-port trigger commands and a small C++ HID demo. Together they describe the message header, a basic command set and one live reading: voltage, current, D+/Dā and CC.
Even that part has rough edges: we counted 15 inconsistencies in the docs, and the demo's device-open check is inverted, so it always opens the first meter it finds.
Most of the rest had to be reverse-engineered:
- authentication
- high-rate streaming
- the PD capture format
- the offline records stored on the meter
- the settings block
- the startup logo and firmware update
Asking a 140 W charger for 5, 9, 15 and 20 V, recorded on a real KM003C, then opened from its CSV.
What it does
The goal was to cover everything the vendor's app does, then make it nicer to use. Here's what shipped:
- Live meter: VBUS, IBUS, power, D+, Dā, CC1, CC2 and temperature, with light and dark themes, keyboard and screen-reader support, automatic reconnect after an unplug, and a switch that hides serial numbers for screenshots.
- Recorder and charts: CSV recording at up to 1000 samples per second in the vendor's layout, a video-editor-style timeline with zoom and pan, a ruler between two cursors, statistics, PNG/SVG/PDF export, and opening old CSV recordings or the vendor's
.dbfiles without a meter. - PD analyzer: every USB Power Delivery message decoded down to its fields and raw bytes, shown as markers on the power chart and linked both ways with the packet table. Captures save as
.pdcapfiles. - Protocol triggers: detect which fast-charge protocols a charger speaks, request any fixed, PPS or EPR AVS voltage a PD charger offers, or trigger QC 2.0/3.0, FCP, AFC, SCP, UFCS and others. There's a confirmation above 5 V and always a way back to 5 V.
- Cable e-markers: a USB-C cable's vendor, current and voltage limits, speed and type, read from its e-marker chip.
- Firmware update: inspect a firmware file, then flash it, with recovery steps if something goes wrong.
- Offline records and settings: download and delete the recordings the meter made on its own (after a CSV backup), and change its language, brightness, theme, rotation and sample rate, each checked by reading it back.
- Startup logo and several meters: write your own boot logo (or the built-in mascot) to the meter, and watch several meters at once in a grid of tiles.
- A scriptable CLI:
list,info,read,watch,stream,pd,records,settings,logo,firmwareand more, with JSON output, raw frame traces and stable exit codes. - A demo device with named scenarios and fault injection, so you can try everything without a meter.
One Rust core, four targets
Seven Rust crates make up the core: a protocol library (three transports, framing, decoders, authentication) that also builds for WebAssembly, a pure USB PD 3.2 decoder with no I/O, a trigger engine, a CSV recorder, a WebAssembly binding, the powerz CLI, and powerz-sim, a simulated KM003C.
On top of that core sit four ways to use the meter:
- macOS: a universal
.dmg(Apple Silicon and Intel). - Windows: an
.msiand a setup.exe. - Linux: an AppImage and a
.deb. - The browser: the same Rust core compiled to WebAssembly and hosted on GitHub Pages, with nothing to install.
WebUSB gives the full feature set, WebHID gives basic readings (and works while another program holds the meter), and Web Serial handles settings writes and triggering. It runs in Chrome and Edge; Safari and Firefox get the demo meter.
The desktop app is Tauri 2 with Svelte 5 and uPlot charts. The powerz CLI ships for all three desktop OSes too.
The web edition connected to a real meter over WebUSB in Chrome, paused on a 5 ā 20 V staircase.
The clean-room rule
If you reverse-engineer someone's app and publish a replacement, you need a line between the two. The classic line is clean-room design: in 1984 Phoenix Technologies cloned the IBM PC BIOS by having engineers who had read IBM's code write a specification, and a separate engineer (some accounts say a second team) who had never seen it implement from that specification. Phoenix kept an audit trail of the interactions between the two sides.
We didn't run a strict two-team clean room. What we kept was a clean-room rule: analyzing the vendor's binaries and writing our code are separate steps, connected only by notes written in our own words.
- Static analysis only. Agents studied the vendor's two executables in a decompiler. No vendor executable was ever run. For firmware, we read only the update file's header (model, version, date, CRC, size) and didn't try to decrypt the image.
- Notes with evidence. Findings went into protocol notes, about 6,200 lines across 14 files. Every claim carries a tag:
[doc](the vendor's document says so),[static](seen in the binary),[device](seen on our meter) or[inferred](plausible, unproven). When the meter proved a claim wrong, the claim was marked wrong and the correction was recorded next to it. - Code from notes. The code was written from those notes plus the vendor's public docs. Only one document derived from the notes enters the repo: a rewritten spec,
docs/protocol.md, 1,265 lines with the same tags. The notes cite hundreds of addresses in the binaries; the repo spec cites none. - Separate folders. The vendor downloads and the raw notes live outside the
open-powerzgit repo. No vendor code, decompiled snippets, binaries or assets go into the repo, and the project'sCLAUDE.mdrepeats this to every agent that opens it.
To be clear about the limits: this was a rule, not a sandbox. Implementation workers could read the notes, and nothing technical stopped them from opening the vendor folder next door.
Most analysis was done up front, in a big research pass on day 1 and a feature inventory on day 2. Later gaps got their own research passes: the startup logo had a dedicated reverse-engineering worker that wrote only notes.
Twice, for the meter-data milestones (reading records and settings, then the first writes), the implementer was allowed to fill gaps itself. It worked statically, wrote what it found into the notes and the spec in our own words, and then implemented from them.
Reverse engineering with agents: pseudocode can be wrong
The tool was REA (Reverse Engineer Anything), an MCP server and skill for reverse engineering with agents. We used it with the Hopper decompiler, so agents could work with pseudocode, cross-references, names and comments.
Analyst agents worked through both executables, and every day-1 area note then got a second, "adversarial" pass that re-read the assembly behind each claim.
Early on, the analysis corrected our own premise: we thought PDM.exe was the main app, and it turned out to be Mtools.exe. More lessons followed:
- The shared decompiler session switched binaries. The decompiler server keeps one active document, and several agents shared it. During one stretch it switched between the two executables, and one decompile came back from the wrong binary. That result was thrown away. From then on, each analyst opened its own session and checked the binary's sha256 on every result.
- Pseudocode can invert a condition. In the reply parser, the pseudocode showed a bit test as
!(BIT_TEST(...)), which reads as the opposite of what the code does. The assembly (btfollowed byjae) said otherwise. The notes now treat the assembly as the authority. - Contradictions get a table. Each conflict between area notes went into a single table (14 entries in the end) and was settled there, instead of being left to whichever note an implementer happened to read.
- Some findings made the safety code simpler. The startup-logo write targets one flash address that appears exactly once in the binary, and only the logo widget calls that write path. So
open-powerz's logo guard lets through only writes to that flash window and the logo's own flag, and refuses everything else.
The device is the final judge
Static analysis gives you hypotheses. The meter tells you which ones are true, and more than once it overturned both the docs and the decompiled app:
- Payload size. The vendor doc says the measurement payload is 40 bytes. On firmware 2.0.6 it's 44.
- Two vendor tools, two answers.
PDM.exeandMtools.exeread the sample-rate byte differently. The meter proved the trigger tool right and the main app wrong. - Who needs authentication. Static analysis predicted that the streaming queue needed it. On the meter, polling the queue worked without it; only rate control and the PD sniffer needed it.
- The HID reply that rebooted the meter. On macOS, control commands over HID got no answer, and a reply spanning several HID reports rebooted the meter. HID is now a guarded fallback that sends only the single-report reading request.
- Settings writes only over serial. The meter accepts settings writes only on its virtual serial port. The menu then shows the new value at once, but the meter applies it only after you change that setting on the meter itself, one step away and back.
By the last release, docs/protocol.md had 273 claims confirmed on the device and 97 still inferred. The untested parts are written down too: the KM002C (it should work the same way, but we haven't tried it), HID behavior on Linux and Windows, importing the vendor's PD log (tested on synthetic files only so far), and a few edge cases.
A USB PD negotiation, decoded by a dependency-free Rust crate (demo device shown).
One orchestrator, a crowd of fresh workers
I didn't talk to most of the agents. One orchestrator session did. It used the spawn-agent skill from my agent-toolkit to open worker sessions in visible cmux tabs, each in the right folder. It typed slash commands into them, answered their gate questions, reviewed diffs, merged once CI had passed twice in a row (to catch flaky tests) and tagged releases. Every worker was a tab I could click into.
The first release, v0.1.0, was merged late on day 1. Shortly after midnight I typed "please drive it, I have to go to sleep :)" , and the next day was the busiest of the project, with 79 commits.
Two rules shaped that setup:
- A fresh worker per task. The first long-lived worker ran setup, a milestone and a dozen specs, and kept compacting its context. After that, every milestone, fix round, review, hardware run and research question got a new session, and hand-off went through files: specs, notes, QA write-ups and PRs. Even the orchestrator, the one session that had to last the whole project, compacted its context 14 times.
- Briefs as files. Later workers got a one-line kickoff: read this brief file and do it. The brief held the goal, the rules, the safety limits and the report format. Workers reported back only to the orchestrator.
Every change is a spec, bugs included
The process is dev-process-toolkit (DPT), my Claude Code plugin for spec-driven development with deterministic gates. A feature goes /brainstorm ā /spec-write ā /implement ā /tdd ā /gate-check ā /docs ā /ship-milestone ā /spec-review ā /pr. A bug goes /report-issue ā /debug ā /implement ā /tdd ā /gate-check ā /spec-review ā /pr.
Hooks refuse a commit or a PR unless the gate check actually ran in that repository: builds, lint, type checks, every test suite, cargo fmt and cargo clippy -D warnings.
On day 2 the orchestrator told a worker to just commit fixes for some chart bugs I had reported. I stopped that: bugs go through DPT too. So the archive holds 195 functional requirements (FRs), and 101 of them are bugs, each with steps to reproduce, a root cause and a regression acceptance criterion. Across all of them there are 1,193 acceptance criteria.
The test count went from 370 at v0.1.0 to 7,875 at v0.13.1, and every release's CHANGELOG entry records the total.
CI/CD: three OSes on every pull request
Shipping for four targets means testing on all of them, so CI runs these on every pull request:
- The same gate DPT runs locally, on Linux, macOS and Windows, plus the WebAssembly build, release-mode timing tests and the Rust docs.
- An MSRV job that builds and tests with the oldest Rust version Cargo.toml promises.
- Real-app end-to-end tests: the actual Tauri app, driven by WebdriverIO through
tauri-driver, on Linux (underxvfb) and Windows. - UI end-to-end tests: Playwright, four shards per OS on all three OSes, against Chromium and WebKit (Windows runs Chromium only, because its webview is Chromium).
- Performance tests: chart frame rate on each OS and a WebKit memory test, in their own job so a slow runner doesn't make the main suite flaky.
- Docs checks: a test fails when a feature is missing from the README or a README link is broken.
The release is just a tag. Pushing v*
Comments
No comments yet. Start the discussion.