Claude Code and I Reverse-Engineered a USB-C Meter. I Mostly Plugged In Cables

2026-11-11来源 dev.toEnglish
Claude Code and I Reverse-Engineered a USB-C Meter. I Mostly Plugged In Cables

Cartoon bench split by a glass clean-room wall: a duck reads assembly beside evidence tag cards, a NOTES ONLY slot passes a card through, a second duck sits at a laptop next to a smug DEMO decoy, and an electron mascot tops a 9, 15 and 20 V staircase above a USB-C meter under a DON'T BRICK IT sign

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.

Why a meter needs a new app

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.

open-powerz desktop app showing a voltage chart that climbs from 5 V to 9, 15 and 20 V and steps back down, with an overview strip and min, max and average stats below 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 .db files 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 .pdcap files.
  • 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, firmware and 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 .msi and 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.

open-powerz web edition in dark mode, connected to a real meter: big voltage, current, power and temperature readouts above a stepped voltage chart, with a meter-settings panel on the right
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.

  1. 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.
  2. 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.
  3. 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.
  4. Separate folders. The vendor downloads and the raw notes live outside the open-powerz git repo. No vendor code, decompiled snippets, binaries or assets go into the repo, and the project's CLAUDE.md repeats 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 (bt followed by jae) 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.exe and Mtools.exe read 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.

PD analyzer table listing USB Power Delivery messages (Source_Capabilities, Request, Accept, PS_RDY) with their voltages, plus a details pane decoding the PDOs from raw bytes
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 (under xvfb) 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* starts a workflow that checks the versions match everywhere and pulls the release notes from the CHANGELOG. It builds the desktop installers with tauri-action and the CLI for all three OSes, writes SHA256SUMS.txt (and verifies it again before uploading), and publishes a GitHub Release. The same tag deploys the web edition to GitHub Pages.

The release pipeline needed one fix of its own: in v0.6.0, the first release it built, the publish job lost track of its draft release, so v0.6.1 was a one-spec milestone that fixed it.

Don't brick the meter

There's one real meter on my desk, and the agents can send it firmware. So hardware-in-the-loop (HIL) testing had rules, written into the project's instructions:

  • Never touch calibration, the one-time-programmable area, the reset command, or firmware outside the approved firmware runs.
  • Trigger charger protocols only with nothing on the meter's output, step up from low voltage, stay at 20 V or below in routine runs, and always end back at 5 V.
  • Every write (a record delete, a settings change, the startup logo) needs a backup or a read-back first, a typed confirmation and a read-back check afterwards.

From the firmware milestone on, testing was split. The implementer writes a runbook with exact powerz commands and expected results. A fresh test agent runs only the runbook, can't edit code, and reports back. The reasoning: the agent that edits the safety checks shouldn't be the one pressing "request 20 V". The test agents took the rules seriously. One refused to flash firmware on an approval relayed by the orchestrator, even though it quoted me word for word, because the project rules require the user's own OK. So I typed the risky approvals into the tester's tab myself, until later I dropped per-write approvals and let the agents change anything on the meter, as long as they didn't brick it.

The firmware update that took three runs. In run 1, the software jump to the bootloader was rejected and nothing was flashed. Run 2 entered the bootloader but failed at the erase step; as the rules require, it wasn't retried. The runbook expected a firmware-details screen, the meter showed a blank screen with a blinking green bar, so the tester aborted until the orchestrator confirmed that was the bootloader. In run 3 the flash took about 11 seconds, and the meter restarted into its app on its own. My report: "It works -- I see measurement screen and 2.0.7 in settings->about!"

Keystrokes needed rules too. A docs worker retaking README screenshots once connected the app to the real meter by accident, when a stray Return key reached the window's default Connect button. It only read, nothing was written, and since then desktop captures are mouse-only.

The demo device that verified a logo upload to itself

The simulator came from a question I asked on day 1: how do workers write end-to-end tests without the device? It became the demo device in the apps, the test double for the e2e suites and the dry-run target for firmware and logo writes. It was the most useful test tool in the project, and it also caused the best bug.

The day before the final release, a HIL run tested startup-logo uploads. After a replug, the app quietly fell back to the demo device. Serial numbers were hidden, so the device picker read just "KM003C", and the demo device is also a KM003C. The tester uploaded a logo, and the app said "read-back verified". That was true: the simulator had verified its own copy perfectly. The real meter got nothing. The tester caught it only from the CLI and the app log.

Because bugs are specs, this went the normal way: an FR, a fix, a release commit and a fresh HIL run on the real meter before the merge. Now, after an unplug, the picker says "KM003C — not connected" with a Refresh button, and Connect is disabled. A replug reconnects to the real meter, never to the demo. Every write confirmation names its target, and testers check the app log for a real USB connection before every write. From the finding to the published v0.13.1 release took about 12 hours, including the re-test on the real meter.

Confirmation dialog 'Upload this startup logo?' stating 'Writes to Demo device — idle (no meter is written)' and asking the user to type 'upload' to confirm
After a logo "uploaded" successfully to the simulator, every write now names its target.

What the human did

I set the goals, plugged in cables, answered the questions only someone at the bench can answer, picked a mascot and typed the riskiest approvals. Now and then I also ran a command myself when Claude Code's permission checks blocked an agent from running it. Everything else ran through specs, gates and visible tabs. The day before the release I typed "drive everything to release pls", and it did.

Four cartoon mascot concepts side by side: a plug character, a flexing battery, an electron surfing a wave and a rainbow voltmeter gauge
Four concepts, one surfer: I picked #3, and it became the brand.

Try it

  • Desktop app and CLI: GitHub Releases for macOS, Windows and Linux, with SHA256SUMS.txt. The builds aren't signed yet; the README shows how to get past the macOS and Windows warnings.
  • Web edition: open nesquikm.github.io/open-powerz in Chrome or Edge on desktop. No meter? Pick the demo meter.
  • CLI: powerz list, then powerz watch for a live view, powerz pd to sniff PD traffic or powerz stream --rate 1000 --csv run.csv to record. Add --transport sim to play with the simulator, no meter needed.

It's tested on a real KM003C. The KM002C should work the same way, but it's untested, so if you have one, I'd love a report. open-powerz is unofficial and isn't made, endorsed or supported by the vendor.

Part of a series on engineering discipline for AI agents. Previously: I Built a Claude Code Plugin That Stops It from Shipping Broken Code (why deterministic gates exist) and Four Commands My AI Agent Can't Skip (the day-to-day workflow). This one shows what that workflow looks like on a hardware project.

原文:https://www.a8q9.cn/en/article/claude-code-and-i-reverse-engineered-a-usb-c-meter-i-mostly-plugged-in-cables-1b8990.html

相关文章