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.