Reading a USB power meter over Bluetooth

Development draft. Not tested against our stocked units. This note describes the linked project and source documents. Check the exact board, firmware and hardware before trying it; it does not establish compatibility of a product we sell.

An inline USB power meter sits between a supply and whatever it powers. The ATORCH UD18 and its DL24 relatives also send their readings over Bluetooth LE: voltage, current, accumulated charge and energy, temperature and run time, and on a USB meter the voltages on the USB data lines.1 That’s what makes them useful to a script, and it’s what Twiddle, the service written up on bricoleur.ai, is built on.

Here’s how the readings come off the meter, from Twiddle’s protocol notes.2

The connection

  • Service: 0000ffe0-0000-1000-8000-00805f9b34fb
  • Characteristic: 0000ffe1-0000-1000-8000-00805f9b34fb, for notifications and writes

Subscribe to notifications on the characteristic, and the meter streams a report frame about once a second. There’s no start command and no pairing.2

The frame

Every frame starts FF 55, then a message type (0x01 for a report from the meter, 0x11 for a command to it) and a device type (0x01 AC, 0x02 DC, 0x03 USB). Reports are 36 bytes, and multi-byte values are big-endian and unsigned. The last byte is a checksum: add up every byte after the FF 55 header up to the checksum, keep the low 8 bits, and XOR with 0x44.2

For a USB meter (device type 0x03):2

Field Offset Bytes Scale
Voltage 0x04 3 ÷100 → V
Current 0x07 3 ÷100 → A
Charge 0x0A 3 ÷1000 → Ah
Energy 0x0D 4 ÷100 → Wh
USB D− 0x11 2 ÷100 → V
USB D+ 0x13 2 ÷100 → V
Temperature 0x15 2 °C
Run time 0x17 / 0x19 / 0x1A 2 / 1 / 1 hours / minutes / seconds

Watch the current scale. On a USB meter it’s hundredths of an amp; on a DC meter it’s thousandths, which is why Twiddle’s decoder branches on the device type.2

A worked example

This frame was captured from a real meter and lives in Twiddle’s tests:2

ff 55 01 03 00 03 04 00 00 52 00 15 c0 00 00 0c eb 01 0c 01 0e 00 00 00 08 2d 3b 3c 00 00 00 00 00 00 00 b5

Voltage is 00 03 04, 772, so 7.72 V. Current is 00 00 52, 82, so 0.82 A. Then 5.568 Ah, 33.07 Wh, 2.68 V and 2.70 V on D− and D+, and a run time of 8 h 45 min 59 s. The checksum works out to 0xB5, which matches the last byte.2

In Python:

def decode_usb_report(frame: bytes) -> dict | None:
    """Decode one 36-byte ATORCH USB-meter report frame, or return None."""
    if len(frame) != 36 or frame[:2] != b"\xff\x55":
        return None
    if frame[2] != 0x01 or frame[3] != 0x03:  # a report, from a USB meter
        return None
    if (sum(frame[2:-1]) & 0xFF) ^ 0x44 != frame[-1]:  # checksum
        return None

    def u(offset: int, size: int) -> int:  # big-endian unsigned
        return int.from_bytes(frame[offset:offset + size], "big")

    return {
        "volts": u(0x04, 3) / 100,
        "amps": u(0x07, 3) / 100,  # hundredths on a USB meter
        "amp_hours": u(0x0A, 3) / 1000,
        "watt_hours": u(0x0D, 4) / 100,
        "temp_c": u(0x15, 2),
        "runtime_s": u(0x17, 2) * 3600 + frame[0x19] * 60 + frame[0x1A],
    }

Frames arrive in pieces

Don’t assume one notification is one frame. Twiddle appends each notification to a rolling buffer, looks for FF 55, and cuts complete 36-byte frames out of it, keeping a partial frame until the rest arrives.3 Feed decode_usb_report from a buffer like that, not straight from the notification callback.

One connection, owned once

The meter’s Bluetooth module allows one connection at a time, the Linux Bluetooth stack (BlueZ) often drops it, and the module needs a few seconds to advertise again after each disconnect.1 The vendor’s phone app only feels solid because it keeps one long-lived link and retries.

Twiddle does the same. A small service owns one persistent connection per meter, reconnects when the link drops, gives each meter a stable name, and answers the command line, and any agent, over a local socket, so nothing fights over the radio.14

twiddle scan                  # find meters advertising FFE0, map names
twiddle service               # run the service
twiddle list                  # every meter and its latest reading
twiddle get twdlDEE --json    # one meter, as JSON

Set http_port in its config, and it also serves read-only JSON on localhost. It needs Python 3.11 or later and BlueZ on Linux.1

Resetting the counters

Commands are written to the same characteristic as FF 55 11, the device type, a command code, a 4-byte value and the checksum. Code 0x05 clears every counter; for a USB meter that’s FF 55 11 03 05 00 00 00 00 5D.2 A reset can’t be undone, so twiddle reset asks before it sends one.1

Before you buy a meter for this

Twiddle works with ATORCH USB and DC testers of the UD18 and DL24 class: the ones that expose the FFE0 service.1 If a meter isn’t sold as one of those, that’s the check to make: scan it with any Bluetooth LE scanner app and look for service FFE0. This describes the project’s supported protocol, not a confirmed test of our stocked UD18 units.

Twiddle’s README credits earlier reverse engineering by syssi/esphome-atorch-dl24 and NiceLabs/atorch-console.1

Sources

Read 10 October 2026.

  1. twiddle README (shawnrancatore/twiddle on GitHub)
  2. ATORCH meter BLE protocol, docs/PROTOCOL.md (shawnrancatore/twiddle)
  3. src/twiddle/protocol.py (shawnrancatore/twiddle)
  4. Twiddle: making flaky bench meters boring (bricoleur.ai)
Back to blog