I recently added a UPS to my server rack to keep my internet and home network running during a power outage. After unpacking it, I investigated its USB port and discovered it wasn’t for powering other devices. Instead, it connects to a host computer to provide information like battery charge status, remaining runtime, and current load.
I wanted to access this data without relying on third-party software, so I decided to see if I could reverse-engineer the protocol using Linux.
Table of contents
Open Table of contents
Setup
Ideal scenario: An existing Linux driver
- HID descriptors
- [Unpack…
I recently added a UPS to my server rack to keep my internet and home network running during a power outage. After unpacking it, I investigated its USB port and discovered it wasn’t for powering other devices. Instead, it connects to a host computer to provide information like battery charge status, remaining runtime, and current load.
I wanted to access this data without relying on third-party software, so I decided to see if I could reverse-engineer the protocol using Linux.
Table of contents
Open Table of contents
Setup
Ideal scenario: An existing Linux driver
Setup
The UPS I’m using is a CyberPower OR500LCDRM1U.
My goal is to set up 24/7 monitoring for the UPS, and for this task, I’ve dedicated a 10+ year-old Raspberry Pi 2B. It will be great to bring this old piece of hardware back to life!
For the software, I opted for a mainline Linux kernel. I’ve previously written about using mainline Linux on a Raspberry Pi, which you can check out for more details. Pay special attention to the USB configuration, as the Raspberry Pi requires a device tree overlay to recognize USB devices when running a mainline kernel.
For the userspace, I built a minimal image using Buildroot. This tool would later prove invaluable for developing the tools needed for this project.
Ideal scenario: An existing Linux driver
Ideally, I would build the kernel with a driver that supports this specific UPS. When I began, I wasn’t sure if UPS devices used a standard USB protocol.
I eventually discovered that the device uses the USB HID (Human Interface Device) protocol. If you want to learn more about how USB devices work, I’ve written an article on making USB devices that provides a good overview of USB protocol layering.
I rebuilt my kernel with USB HID support, enabling every option that seemed relevant. Unfortunately, no off-the-shelf driver could communicate with my UPS.
Raw HID device
Things felt a bit hopeless at this point, but then I realized that Linux offers a “raw” view of HID devices. This seemed like a brilliant concept, as it exposes the raw, unfiltered USB reports from the device as a character file.
You can literally cat /dev/hidraw0 and see the raw binary data stream.
HID descriptors
Linux doesn’t stop there, though. When a USB HID device performs its initial handshake with the Linux host, it provides a descriptor of the protocol it uses on top of HID. This essentially means it gives a blueprint for what kind of data flows back and forth on the USB line.
On my Raspberry Pi, this descriptor was located at:
/sys/class/hidraw/hidraw0/device/report_descriptor
Unfortunately, this file is in binary format and is not directly human-readable. If someone knows how to get a human-readable report directly from the kernel, please let me know in the comments on social media.
Me: Just wants to read two numbers from a USB device.
The Device: “Welcome to the world of Human Interface Devices. Before you read these two numbers, you must first read my 500-line Report Descriptor
The descriptor specifies that the first number is a 4-bit ’Telephony… https://t.co/YVIlYEz13b pic.twitter.com/lsmqyjYrpD
— Uros Popovic (@popovicu94) November 6, 2025
Unpacking the HID descriptors
As you can see from the file name, HID is based on ‘reports’. A HID report is essentially just a packet of data, similar to how IP packets are used in networking. Their binary structure is straightforward: the first byte is the report ID, and the remaining bytes are defined by the protocol descriptor we’re trying to decipher. The length of each packet depends on the descriptor.
Descriptors in this context can be understood as the USB equivalent of Protocol Buffers.
First, I copied this file onto the memory card my Raspberry Pi was using, then mounted that card on my primary workstation for analysis.
There seemed to be some decent pure Python libraries for processing USB HID descriptors, but I wasn’t in the mood to write code just to parse a descriptor. I was concerned about going down a rabbit hole.
The best off-the-shelf tool I could find was hidrd-convert. Since I primarily run Debian on my main Linux machine, and couldn’t simply apt install it, I ended up building this tool from source. Fortunately, it was easy to obtain from GitHub.
You can easily find online instructions for converting the binary HID descriptor file into a human-readable format.
Readable… 🤔
Interpreting the HID descriptor file
I expected hidrd-convert to give me readable text saying something like ‘report ABC has field XYZ which contains foo bar baz’. It wasn’t even close to that. The “human-readable” version of the HID descriptor file is more like a script for a tiny virtual machine rather than a readable summary. Below is what hidrd-convert generated:
Usage Page (Power Device), ; Power device (84h, power page)
Usage (04h),
Collection (Application),
Usage (24h),
Collection (Physical),
Report ID (1),
Usage (FEh),
Report Size (8),
Report Count (1),
Logical Minimum (0),
Logical Maximum (255),
Feature (Constant, Variable, No Preferred),
Report ID (2),
Usage (FFh),
Feature (Constant, Variable, No Preferred),
Report ID (27),
Usage Page (FF01h), ; FF01h, vendor-defined
Usage (D0h),
Feature (Variable, No Preferred),
Report ID (3),
Usage Page (Power Batsys), ; Power battery system (85h, power page)
Usage (89h),
Feature (Constant, Variable, No Preferred),
Report ID (4),
Usage (8Fh),
Feature (Constant, Variable, No Preferred),
Report ID (5),
Usage (8Bh),
Feature (Constant, Variable, No Preferred),
Report ID (6),
Usage (2Ch),
Feature (Constant, Variable, No Preferred),
Report ID (7),
Report Size (8),
Report Count (6),
Logical Maximum (100),
Usage (83h),
Usage (8Dh),
Usage (8Eh),
Usage (8Ch),
Usage (29h),
Usage (67h),
Feature (Constant, Variable, No Preferred),
Report ID (8),
Report Size (8),
Report Count (1),
Unit,
Usage (66h),
Input (Constant, Variable, No Preferred),
Usage (66h),
Feature (Constant, Variable, No Preferred, Volatile),
Usage (68h),
Report Size (16),
Logical Maximum (65535),
Unit (Seconds),
Input (Constant, Variable, No Preferred),
Usage (68h),
Feature (Constant, Variable, No Preferred, Volatile),
Usage (2Ah),
Logical Maximum (480),
Input (Constant, Variable, No Preferred),
Usage (2Ah),
Feature (Variable, No Preferred, Volatile),
Report ID (9),
Report Size (16),
Logical Maximum (480),
Usage Page (Power Device), ; Power device (84h, power page)
Usage (40h),
Unit (Centimeter^2 * Gram * Seconds^-3 * Ampere^-1),
Unit Exponent (6),
Feature (Constant, Variable, No Preferred),
Report ID (10),
Usage (30h),
Feature (Constant, Variable, No Preferred, Volatile),
Usage (02h),
Collection (Logical),
Unit,
Unit Exponent (0),
Report ID (11),
Report Size (1),
Report Count (6),
Logical Maximum (1),
Usage Page (Power Batsys), ; Power battery system (85h, power page)
Usage (D0h),
Usage (44h),
Usage (45h),
Usage (42h),
Usage (46h),
Usage (43h),
Input (Constant, Variable, No Preferred),
Usage (D0h),
Usage (44h),
Usage (45h),
Usage (42h),
Usage (46h),
Usage (43h),
Feature (Constant, Variable, No Preferred, Volatile),
Report Size (2),
Report Count (1),
Input (Constant),
Feature (Constant),
End Collection,
Report ID (12),
Usage Page (Power Device), ; Power device (84h, power page)
Usage (5Ah),
Report Size (8),
Logical Minimum (1),
Logical Maximum (3),
Feature (Variable, No Preferred, Volatile),
Usage (5Ah),
Input (Variable, No Preferred),
Report ID (13),
Usage (FDh),
Logical Minimum (0),
Logical Maximum (255),
Feature (Constant, Variable, No Preferred),
End Collection,
Usage Page (Power Device), ; Power device (84h, power page)
Usage (1Ah),
Collection (Physical),
Report ID (14),
Usage Page (Power Device), ; Power device (84h, power page)
Usage (40h),
Report Size (8),
Unit (Centimeter^2 * Gram * Seconds^-3 * Ampere^-1),
Unit Exponent (7),
Feature (Constant, Variable, No Preferred),
Report ID (15),
Report Size (16),
Usage (30h),
Feature (Constant, Variable, No Preferred, Volatile),
End Collection,
Usage (1Ch),
Collection (Physical),
Report ID (16),
Usage (53h),
Logical Minimum (97),
Logical Maximum (103),
Feature (Variable, No Preferred, Volatile),
Usage (53h),
Input (Constant, Variable, No Preferred),
Usage (54h),
Logical Minimum (135),
Logical Maximum (144),
Feature (Variable, No Preferred, Volatile),
Usage (54h),
Input (Constant, Variable, No Preferred),
Logical Minimum (0),
Logical Maximum (511),
Report ID (18),
Usage (30h),
Feature (Constant, Variable, No Preferred, Volatile),
Report Size (8),
Logical Minimum (0),
Logical Maximum (255),
Unit,
Unit Exponent (0),
Report ID (19),
Usage (35h),
Feature (Constant, Variable, No Preferred, Volatile),
Report ID (20),
Usage (58h),
Logical Maximum (6),
Feature (Variable, No Preferred, Volatile),
Usage (58h),
Input (Variable, No Preferred),
Report ID (21),
Usage (57h),
Report Size (16),
Logical Minimum (-1),
Logical Maximum (32767),
Physical Minimum (-60),
Physical Maximum (1966020),
Unit (Seconds),
Feature (Variable, No Preferred, Volatile),
Report ID (22),
Usage (56h),
Feature (Variable, No Preferred, Volatile),
Report ID (23),
Usage (6Eh),
Report Size (1),
Logical Minimum (0),
Logical Maximum (1),
Physical Minimum (0),
Physical Maximum (0),
Unit,
Feature (Constant, Variable, No Preferred, Volatile),
Usage (65h),
Feature (Constant, Variable, No Preferred, Volatile),
Report Size (6),
Feature (Constant),
Report ID (24),
Report Count (2),
Report Size (16),
Usage (44h),
Logical Maximum (2000),
Unit (Centimeter^2 * Gram * Seconds^-3),
Unit Exponent (7),
Usage (43h),
Feature (Constant, Variable, No Preferred, Volatile),
Report ID (25),
Report Count (1),
Usage (34h),
Logical Maximum (2000),
Feature (Constant, Variable, No Preferred, Volatile),
Usage (34h),
Input (Constant, Variable, No Preferred),
Report ID (29),
Usage (33h),
Logical Maximum (2000),
Feature (Constant, Variable, No Preferred, Volatile),
Usage (33h),
Input (Constant, Variable, No Preferred),
Report ID (26),
Usage Page (FF01h), ; FF01h, vendor-defined
Unit,
Unit Exponent (0),
Report Size (8),
Logical Minimum (0),
Logical Maximum (2),
Usage (43h),
Feature (Variable, No Preferred, Volatile),
Usage (43h),
Input (Constant, Variable, No Preferred),
End Collection,
Report ID (28),
Usage (BAh),
Collection (Logical),
Report Size (16),
Logical Maximum (65535),
Usage (BBh),
Feature (Variable, No Preferred),
Report Size (8),
Logical Maximum (255),
Usage (BCh),
Feature (Variable, No Preferred),
Report Size (16),
Logical Maximum (65535),
Usage (BDh),
Feature (Variable, No Preferred, Volatile, Buffered Bytes),
End Collection,
Usage Page (FF01h), ; FF01h, vendor-defined
Usage (1Dh),
Collection (Physical),
Usage (19h),
Collection (Logical),
Report ID (37),
Usage (20h),
Unit,
Unit Exponent (0),
Logical Minimum (0),
Logical Maximum (1),
Report Count (1),
Report Size (1),
Feature (Variable, Volatile),
Report Size (7),
Feature (Constant, Variable),
Report ID (44),
Usage (21h),
Report Size (1),
Feature (Constant, Variable, Volatile),
Usage (21h),
Input (Constant, Variable),
Report Size (7),
Feature (Constant, Variable, Volatile),
Input (Constant, Variable),
End Collection,
Usage (1Ah),
Collection (Logical),
Report ID (38),
Usage (01h),
Report Size (1),
Feature (Variable, Volatile),
Report Size (7),
Feature (Constant, Variable, Volatile),
Report ID (39),
Usage (02h),
Logical Maximum (3),
Report Size (8),
Feature (Constant, Variable),
End Collection,
Usage (1Bh),
Collection (Logical),
Report ID (40),
Usage (16h),
Unit,
Unit Exponent (0),
Logical Minimum (0),
Logical Maximum (62),
Report Size (8),
Report Count (1),
Feature (Constant, Variable, Volatile),
Usage (16h),
Input (Constant, Variable),
Usage (18h),
Logical Maximum (255),
Report Count (62),
Feature (Constant, Variable, Volatile, Buffered Bytes),
Usage (18h),
Input (Constant, Variable, Buffered Bytes),
Report ID (41),
Usage (15h),
Logical Minimum (1),
Logical Maximum (62),
Report Count (1),
Feature (Variable),
Usage (15h),
Output (Variable),
Usage (17h),
Logical Minimum (0),
Logical Maximum (255),
Report Count (62),
Feature (Variable, Buffered Bytes),
Usage (17h),
Output (Variable, Buffered Bytes),
Report ID (45),
Report Size (1),
Report Count (3),
Logical Maximum (1),
Usage (10h),
Usage (1Eh),
Usage (1Fh),
Feature (Constant, Variable, Volatile),
Usage (10h),
Usage (1Eh),
Usage (1Fh),
Input (Constant, Variable),
Report Size (5),
Report Count (1),
Feature (Constant, Variable, Volatile),
Input (Constant, Variable),
Report ID (42),
Usage (13h),
Report Size (1),
Feature (Variable, Volatile),
Report Size (7),
Feature (Constant, Variable, Volatile),
Report ID (43),
Usage (14h),
Report Size (1),
Feature (Variable, Volatile),
Report Size (7),
Feature (Constant, Variable, Volatile),
End Collection,
End Collection,
End Collection
It started promisingly and was somewhat intuitive, mentioning power devices, but that’s about it. To be honest, it’s not too difficult to read through this report and find what you need, but it takes some getting used to.
Some of the main items of interest are the Report IDs. As mentioned earlier, these are the identifiers that define what each packet contains.
The biggest problem was these cryptic constants. While it makes sense that hidrd-convert doesn’t have a comprehensive database to cover them all, this was still a significant obstacle.
AI to the rescue
It’s 2025 and everything revolves around LLMs - AI should be able to process this text.
If you simply ask AI to make sense of this text blob, it will likely fabricate what all these cryptic constants mean. The chatbots seem to have a decent understanding of the structure and can help you parse it, but on their own, that’s about as far as they’ll go.
Things are even worse if you ask AI to explain the protocol this UPS uses, without any context. It will confidently hallucinate some 14-byte report format and other details.
Providing the context
The breakthrough moment came when I found this page on GitHub. This is part of the source code for one of the Python packages I mentioned earlier, and it contains many human-readable descriptions for those cryptic numbers in the HID descriptor.
I quickly crafted an LLM prompt asking the AI to load this Python data and match it to the descriptor I extracted above. I expected this to work well with mainstream LLMs, and it did!
AI helped me reverse engineer a USB device with Linux 🤯
I found the “instruction manual” (the HID Report Descriptor), but it’s not a simple struct. It’s a script that needs to be interpreted.
Doing it by hand is tedious. So, I tried the “lazy” route: I asked an AI to parse it.… pic.twitter.com/7SBMPdYpDw
— Uros Popovic (@popovicu94) November 8, 2025
Verifying the findings
Back on my Raspberry Pi, I ran the following command:
cat /dev/hidraw0 | hexdump -C
This produced the following output:
00000000 08 64 ad 11 2c 01 0b 11 19 23 00 1d 36 00 08 64 |.d..,....#..6..d|
00000010 ad 11 2c 01 0b 11 19 24 00 1d 36 00 08 64 8e 12 |..,....$..6..d..|
00000020 2c 01 0b 11 19 23 00 1d 37 00 08 64 ad 11 2c 01 |,....#..7..d..,.|
00000030 0b 11 19 23 00 1d 36 00 08 64 8e 12 2c 01 0b 11 |...#..6..d..,...|
00000040 19 23 00 1d 37 00 08 64 ad 11 2c 01 0b 11 19 23 |.#..7..d..,....#|
We can definitely see some HID automatic reports, but cat seems to splice all these reports together into one big binary blob. I wanted direct control over my read() system calls, so I created this little Go program with AI’s help:
package main
import (
"encoding/hex"
"flag"
"fmt"
"os"
)
func main() {
devicePath := flag.String("device", "", "Path to the hidraw device (e.g., /dev/hidraw0)")
flag.Parse()
if *devicePath == "" {
fmt.Fprintf(os.Stderr, "Error: device path is required\n\n")
flag.Usage()
os.Exit(1)
}
// Open the HID device
device, err := os.Open(*devicePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening device %s: %v\n", *devicePath, err)
os.Exit(1)
}
defer device.Close()
fmt.Printf("Reading from device: %s\n", *devicePath)
fmt.Println("Press Ctrl+C to stop")
fmt.Println()
// Buffer for 8-byte reports
buffer := make([]byte, 64)
for {
// Read 8 bytes from the device
n, err := device.Read(buffer)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading from device: %v\n", err)
os.Exit(1)
}
if n > 0 {
// Print the hex dump of the report
fmt.Printf("Report (%d bytes): %s\n", n, hex.EncodeToString(buffer[:n]))
}
}
}
By the way, I post about Linux system calls daily on X! Here’s my recent post about open(), which is related to the code above.
You probably think the `open()` syscall just opens files.
In reality, it’s a Swiss Army knife for concurrency and security in Linux, all thanks to its `flags` parameter.
A simple `open(“file.txt”, O_RDONLY)` is just scratching the surface. Let’s unlock its real power. 🧵👇 pic.twitter.com/gB15gYbqby
— Uros Popovic (@popovicu94) November 7, 2025
This Go program produced the following output:
# /opt/ups-reader --device=/dev/hidraw0
Reading from device: /dev/hidraw0
Press Ctrl+C to stop
Report (6 bytes): 0864ad112c01
Report (2 bytes): 0b11
Report (3 bytes): 192400
Report (3 bytes): 1d3700
Report (6 bytes): 08648e122c01
Report (2 bytes): 0b11
Report (3 bytes): 192400
Report (3 bytes): 1d3700
Report (6 bytes): 0864ad112c01
Report (2 bytes): 0b11
Report (3 bytes): 192400
Report (3 bytes): 1d3600
^C
Now we can clearly see the different reports. The USB HID infrastructure in the kernel ensures “synchronization,” meaning each read() call aligns itself with the beginning of a USB report. It won’t start streaming from the middle of a report. These system calls are beautiful!
Using features
If you study the report, you’ll see there are input packets (which the device emits automatically) and ‘features’. Features work on a request/response basis—the Linux host requests this data and the device returns it.
The mechanism for making requests is the ioctl system call.
I generally enjoy working with Go, but ioctl is something I haven’t had great success with in Go. However, I didn’t want to dive into C programming; I wanted something quick and straightforward.
Fortunately, Python has a hid library that was available in Buildroot, so I updated my image.
Next, I tested these features to get the UPS load percentage and input voltage.
First, let me show the lsusb output so you can see the device IDs:
...
Bus 001 Device 004: ID 0764:0601 CPS OR500LCDRM1Ua
...
Now I was ready to implement this in Python!
python3 -c "import hid; d=hid.Device(0x0764,0x0601); print('Load:', d.get_feature_report(19,2)[1], '%')"
This produced:
Load: 11 %
Perfect! That’s exactly what I saw on the UPS LCD display. This was an incredibly satisfying moment.
Next, I wanted to check the input voltage:
python3 -c "import hid; d=hid.Device(0x0764,0x0601); r=d.get_feature_report(18,3); v=int.from_bytes(r[1:3],'little'); print('Input voltage:', v, 'V')"
This produced:
Input voltage: 120 V
Excellent! I live in the US, so 120V is exactly what’s expected here.
Crafting the C program
While I could easily make everything work with a simple Python script, I wanted to go completely low-level with C to understand every system call that Python would otherwise abstract away.
AI generated the following C code:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/hidraw.h>
#include <errno.h>
#include <sys/select.h>
#define HIDRAW_DEVICE "/dev/hidraw0"
#define HIDIOCGFEATURE(len) _IOC(_IOC_WRITE|_IOC_READ, 'H', 0x07, len)
void drain_input_reports(int fd, int show_battery) {
unsigned char buf[64];
fd_set readfds;
struct timeval tv;
// Drain all pending input reports
while (1) {
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
tv.tv_sec = 0;
tv.tv_usec = 50000; // 50ms timeout
int ret = select(fd + 1, &readfds, NULL, NULL, &tv);
if (ret <= 0) break; // No more data
int n = read(fd, buf, sizeof(buf));
if (n > 0) {
unsigned char report_id = buf[0];
if (report_id == 0x08 && n >= 6 && show_battery) {
// Report 8: Battery status
unsigned char capacity = buf[1];
unsigned short runtime = buf[2] | (buf[3] << 8);
unsigned short voltage_raw = buf[4] | (buf[5] << 8);
float voltage = voltage_raw / 10.0f;
printf(" Battery: %d%%, Runtime: %d min, Voltage: %.1fV\n",
capacity, runtime / 60, voltage);
} else {
printf(" [Ignoring Input Report %d (%d bytes)]\n", report_id, n);
}
}
}
}
int get_feature_report(int fd, unsigned char report_id, unsigned char *buf, int len) {
memset(buf, 0, len);
buf[0] = report_id;
int res = ioctl(fd, HIDIOCGFEATURE(len), buf);
if (res < 0) {
perror("HIDIOCGFEATURE");
return -1;
}
return res;
}
int main(int argc, char **argv) {
const char *device = HIDRAW_DEVICE;
if (argc > 1) {
device = argv[1];
}
printf("Opening %s...\n", device);
int fd = open(device, O_RDWR | O_NONBLOCK);
if (fd < 0) {
perror("Unable to open device");
printf("Usage: %s [/dev/hidrawN]\n", argv[0]);
return 1;
}
printf("Monitoring UPS... (Ctrl+C to stop)\n\n");
while (1) {
printf("=== Status Update ===\n");
// Drain any pending input reports (especially Report 8)
drain_input_reports(fd, 1); // show_battery = 1
// Poll Report 19: Load percentage
unsigned char buf[8];
if (get_feature_report(fd, 19, buf, 2) >= 0) {
printf(" Load: %d%%\n", buf[1]);
}
// Poll Report 18: Input voltage
if (get_feature_report(fd, 18, buf, 3) >= 0) {
unsigned short voltage = buf[1] | (buf[2] << 8);
printf(" Input Voltage: %dV AC\n", voltage);
}
// Drain any reports that arrived during feature requests
drain_input_reports(fd, 0); // show_battery = 0 (already showed it)
printf("\n");
sleep(3);
}
close(fd);
return 0;
}
That was significantly more code, but it was very satisfying to understand how to use kernel services directly to interact with hardware in a portable way.
If you’re unfamiliar with kernel services, check out this post on X.
Your Linux kernel is like a server for your apps.
A regular program can’t just access hardware directly to do things like write to a file or the screen. That would be chaos.
Instead, it makes a “system call” to the kernel, formally requesting a service.
On modern x86-64 Linux,… pic.twitter.com/Lu8tgy0DWE
— Uros Popovic (@popovicu94) October 29, 2025
The output of this program on the Raspberry Pi was:
# /opt/reader
Opening /dev/hidraw0...
Monitoring UPS... (Ctrl+C to stop)
=== Status Update ===
Load: 11%
Input Voltage: 120V AC
=== Status Update ===
Battery: 100%, Runtime: 79 min, Voltage: 30.0V
[Ignoring Input Report 11 (2 bytes)]
[Ignoring Input Report 25 (3 bytes)]
[Ignoring Input Report 29 (3 bytes)]
Load: 11%
Input Voltage: 120V AC
=== Status Update ===
Battery: 100%, Runtime: 79 min, Voltage: 30.0V
[Ignoring Input Report 11 (2 bytes)]
[Ignoring Input Report 25 (3 bytes)]
[Ignoring Input Report 29 (3 bytes)]
Load: 11%
Input Voltage: 120V AC
=== Status Update ===
Battery: 100%, Runtime: 79 min, Voltage: 30.0V
[Ignoring Input Report 11 (2 bytes)]
[Ignoring Input Report 25 (3 bytes)]
[Ignoring Input Report 29 (3 bytes)]
Load: 12%
Input Voltage: 120V AC
^C
Everything checks out perfectly — the experiment was successful! The battery is definitely fully charged and the runtime matches what I see on the LCD.
A note on cross-compilation
The challenge with writing this in C isn’t just getting the system calls right; it’s also getting all the libraries correct. I couldn’t simply use any ARM cross-compiler to make this work properly.
Back in my Buildroot folder, I ran:
make sdk
This provides a C cross-compilation toolchain exactly matched to the Linux version my RPi would be running — complete with the C standard library, kernel headers, and everything else. Therefore, my C program was actually built like this:
/tmp/rpi/buildroot-2025.08.1/output/host/bin/arm-linux-gcc ./reader.c -o reader
make sdk puts the toolchain in the output/host/bin tree. Use that when you want to build additional code for your Buildroot project!
Going the Python route is definitely much easier here. I can drop in a .py file, without any worries of cross-compilation or anything. However, without Buildroot to conveniently drop in both the Python interpreter and the fully ready-to-use hid library, there would be so many steps required to make that work.
Conclusion
This was a very fun experiment and a great way to learn some things about Linux and USB!
One great thing about Linux, as always, is the fact that it abstracts away the low-level hardware interaction, as a good OS should. This means that it doesn’t really matter whether I’m deploying on Raspberry Pi or my full blown Linux workstation — the interaction with this USB device is the same. The C code should also be portable and only a matter of using the right toolchain to build the final binary, but from the code perspective: nothing changes.
I hope this was fun and useful to you as well.
Please consider following me on X and LinkedIn for further updates.