- Note: This project was completed in October 2024, and newer, more advanced smart rings have since been released. This article isn't a product recommendation, but rather a walkthrough of how I learned to reverse engineer Bluetooth protocols and build a custom app.
The gadget I couldn't resist: A €12 Smart Ring
Let me tell you about the qring from AliExpress—a €12 piece of hardware that claims to measure your heart rate, blood oxygen, and steps. For the price of a fancy sandwich, you get surprisingly decent hardware wrapped in the digital equivalent of a sketchy back-alley deal.
The ring itself? Actually pretty impressive. The vendor app that comes with it? Let's just say it makes Facebook's privacy policy look like a love letter to user rights.
But, the offer was too good to pass up, especially considering that western alternatives (like the samsung smart ring) are priced at €300+, and locks you into their ecosystem (he complains, as avid Apple user).
But here's the thing—I want to own my data. And I'd never really written a mobile app before, so this felt like the perfect excuse to dive headfirst into the beautiful chaos of reverse engineering.
The Vendor App: A Masterclass in "Please Don't"
First stop: grabbing the APK and diving into the decompiled Java code. What I found was a maze of obfuscated classes, UI components built in web views with Angular (because apparently native mobile development is too mainstream), and enough questionable architectural decisions to make any developer weep.
But buried in all that chaos was gold: the Bluetooth protocol implementation. With enough patience (and beer), I started piecing together how this little ring actually talks to the outside world.
The Protocol: 16 Bytes of Pure Chaos
The ring uses Bluetooth Low Energy (BLE) with custom GATT services—basically, it's speaking its own private language that only the vendor app understood. Until now.
Every conversation with the ring happens through 16-byte packets. Think of it like sending telegrams, but with more math and less poetry:
| Byte 0 | Bytes 1-14 | Byte 15 |
|--------|------------|---------|
| Command| Data |Checksum |
The first byte tells the ring what you want (read heart rate, set time, etc.). The last byte is a checksum to make sure nothing got corrupted in transit. Everything in between is where the real party happens.
Here's what a typical packet looks like in JavaScript:
// Build a packet to read heart rate log const packet = new Uint8Array(16); packet[0] = 0x15; // Command: "give me heart rate data" packet[1] = 0x01; // Sub-command: "from today" // ... fill in the middle with timestamp data ... packet[15] = calculateChecksum(packet); // "did this arrive intact?"
Each feature gets its own packet builder and parser. Want to set the time? Here's how:
export function setTimePacket(target = new Date()) { const data = new Uint8Array(14); // Convert date to BCD format because apparently // normal timestamps are too mainstream data[0] = toBCD(target.getFullYear() % 100); data[1] = toBCD(target.getMonth() + 1); data[2] = toBCD(target.getDate()); data[3] = toBCD(target.getHours()); data[4] = toBCD(target.getMinutes()); data[5] = toBCD(target.getSeconds()); return makePacket(CMD_SET_TIME, data); }
And parsing the response:
export function parseSetTimePacket(packet) { // Extract capability flags from the response const capabilities = { hasHeartRate: !!(packet[1] & 0x01), hasSteps: !!(packet[1] & 0x02), hasBloodOxygen: !!(packet[1] & 0x04), // ... more flags because hardware is complicated }; return { success: packet[0] === 0x01, capabilities }; }
The Heart Rate Nightmare: When 16 Bytes Aren't Enough
Here's where things get spicy. The ring stores up to 7 days of heart rate data—288 points per day, one measurement every 5 minutes. That's 2,016 data points that somehow need to squeeze through our 16-byte packet pipeline.
Obviously, that's not happening in a single packet. So the ring does something clever (and mildly infuriating): it chunks the data across multiple packets, each containing a piece of the puzzle.
The conversation looks like this:
App: "Hey, give me heart rate data for today"
Ring: "Sure! I have 22 chunks of data coming your way"
Ring: "Chunk 1: Here's a timestamp + 9 heart rate values"
Ring: "Chunk 2: Here's 13 more values"
Ring: "Chunk 3: Here's 13 more values"
... (repeat 19 more times) ...
App: "Cool, let me stitch these 288 values together"
This chunked approach means you have to carefully track state between packets, handle out-of-order data, and only process results once everything arrives. It's like assembling IKEA furniture, but the instructions are in binary and some of the pieces might be missing.
The Packet Dance: A Technical Ballet
Each heart rate chunk has its own structure:
Request: [0x15][day_offset][...padding...][checksum] Response (header): [0x15][0x00][chunk_count][day_range][...] Response (first): [0x15][0x01][timestamp][9 values][...] Response (others): [0x15][chunk_id][13 values][...]
The first chunk is special—it includes a timestamp so you know when the measurements started. Every subsequent chunk is just pure data, 13 heart rate values packed into the remaining bytes.
But here's the kicker: sometimes chunks arrive out of order. Sometimes they don't arrive at all. Welcome to the wonderful world of Bluetooth Low Energy, where "reliable" is more of a suggestion than a guarantee.
BLE: The Unreliable Narrator
If you've ever worked with Bluetooth Low Energy, you know it's like that friend who's great at parties but terrible at keeping promises. Packets get lost. Responses arrive late. Connections drop at the worst possible moments.
That's why the RequestQueue became the unsung hero of this entire project. It's a simple but crucial piece of infrastructure that ensures:
- Only one request is in flight at a time
- Responses are matched to the correct request
- Timeouts and retries happen automatically
- The app doesn't get stuck waiting for responses that never come
Without this queue, BLE communication would quickly devolve into chaos. With it, you get something that almost resembles reliability.
class RequestQueue { constructor() { this.queue = []; this.currentRequest = null; this.timeout = null; } enqueue(request) { this.queue.push(request); this.processNext(); } processNext() { if (this.currentRequest || this.queue.length === 0) return; this.currentRequest = this.queue.shift(); this.timeout = setTimeout(() => { this.handleTimeout(); }, 5000); // 5 second timeout this.sendRequest(this.currentRequest); } handleResponse(response) { if (!this.currentRequest) return; clearTimeout(this.timeout); this.currentRequest.resolve(response); this.currentRequest = null; this.processNext(); } }
Building the App: React Native to the Rescue
For the mobile app, I went with React Native because life's too short to write everything twice. The architecture is beautifully modular:
- UI Components live in
src/components/
- Bluetooth Logic is isolated in
src/services/bluetooth/
- State Management handled by Redux (because I hate myself)
- Native Bridges handle the low-level BLE work on iOS and Android
The home screen shows your ring's current status and latest measurements:
App Home Screen
Tap through to see the week overview, where you can browse the last 7 days of data stored on the ring:
Week Overview
There's also a settings modal for the important stuff—renaming your ring and tweaking preferences:

And of course, a main page to manage all your rings (because apparently I'm optimistic about my future ring collection):
Rings Management
The Detective Work: Decoding Binary Blobs
The hardest part wasn't the Bluetooth protocol or the mobile app—it was figuring out what the hell the ring was actually telling me. Binary data doesn't come with documentation, and the vendor certainly wasn't about to explain their proprietary format.
It was pure detective work. I'd send a command, get back a blob of bytes, then try to figure out what they meant:
// What does this response mean? // [0x15, 0x01, 0x42, 0x68, 0x72, 0x75, 0x82, ...] // ^ ^ ^ ^ ^ ^ ^ // | | | | | | | // cmd ok ??? ??? ??? ??? ???
Sometimes it was timestamps in BCD format. Sometimes it was sensor readings packed into 12-bit values. Sometimes it was status flags where each bit meant something different. The only way to figure it out was trial and error, lots of logging, and the occasional educated guess.
The Modular Approach: Packets All the Way Down
Every feature in the ring gets its own packet builder and parser. Want to read battery status? There's a packet for that. Want to set the time? Different packet. Want to configure sleep tracking? You guessed it—another packet.
// Battery status export function getBatteryPacket() { return makePacket(CMD_GET_BATTERY, new Uint8Array(14)); } export function parseBatteryPacket(packet) { return { level: packet[1], charging: !!(packet[2] & 0x01), voltage: (packet[3] << 8) | packet[4] }; } // Sleep settings export function setSleepTrackingPacket(enabled, startHour, endHour) { const data = new Uint8Array(14); data[0] = enabled ? 0x01 : 0x00; data[1] = startHour; data[2] = endHour; return makePacket(CMD_SET_SLEEP, data); }
This modular approach makes the codebase maintainable and makes it easy to add new features as I discover them. It's like having a Swiss Army knife, but for ring communication.
Redux: Because State Management is Hard
All the parsed data flows into Redux slices, making it easy for the UI to stay in sync with the ring's state:
const ringSlice = createSlice({ name: 'ring', initialState: { connected: false, battery: null, heartRate: [], steps: 0, bloodOxygen: null }, reducers: { updateBattery: (state, action) => { state.battery = action.payload; }, updateHeartRate: (state, action) => { state.heartRate = action.payload; }, // ... more reducers for other data types } });
The Redux store becomes the single source of truth for all ring data, and the UI components can just subscribe to the pieces they care about.
The Privacy Win: My Data, My Rules
With my custom app, I finally have what I wanted from the beginning: complete control over my data. No sketchy servers. No mysterious data collection. No ads. Just my health information, synced straight to Apple HealthKit where it belongs.
The ring still does its job—measuring heart rate, tracking steps, monitoring blood oxygen. But now all that data stays on my device, under my control, integrated into the ecosystem I actually use.
It's like having your cake and eating it too, except the cake is a €12 smart ring and eating it means not having your data harvested by unknown third parties.
What's Next
This project opened up a whole world of possibilities:
- More Health Metrics: The ring probably supports sleep tracking, temperature monitoring, and other features I haven't discovered yet
- UI Polish: The app works, but it could be prettier (and more importantly, more functional)
- Protocol Documentation: I should probably write down everything I learned for the next poor soul who wants to reverse engineer this thing
- Open Source Considerations: Parts of this could be useful to other developers, though the commercial implications are... complex
But, lets be real—this was never just about the ring. It was about taking back control of my technology, understanding how it works, and building something that respects my privacy. The chances of me following through on all that are... well, 0.
The Real Victory: Understanding vs. Consuming
Reverse engineering isn't just about breaking things apart—it's about understanding how they work, improving them, and taking control of your own technology. It's the difference between being a consumer and being a creator.
Sure, I could have just used the vendor app and accepted the privacy trade-offs. But where's the fun in that? By diving into the protocol, building my own app, and understanding exactly how this little ring communicates, I turned a €12 piece of hardware into something way cooler than the manufacturer ever intended.
And sometimes, that's exactly what technology needs—someone who refuses to accept that "good enough" is actually good enough.
The €12 qring is still chugging along, faithfully reporting my heart rate to an app that actually respects my privacy. Sometimes the best solutions come from the most unexpected places—like a sketchy AliExpress purchase and a weekend of reverse engineering.
Freebie: Protocol Deep Dive
For the curious (or masochistic), here's a peek at the actual packet structure for heart rate data:
// Heart rate log request structure const CMD_HEART_RATE_LOG = 0x15; function buildHeartRateLogPacket(dayOffset = 0) { const packet = new Uint8Array(16); packet[0] = CMD_HEART_RATE_LOG; packet[1] = dayOffset; // 0 = today, 1 = yesterday, etc. // ... fill remaining bytes with padding ... packet[15] = calculateChecksum(packet.slice(0, 15)); return packet; } // Response parsing - this is where it gets interesting function parseHeartRateResponse(packet) { const subCommand = packet[1]; if (subCommand === 0x00) { // Header packet - tells us how many chunks to expect return { type: 'header', chunkCount: packet[2], dayRange: packet[3], totalPoints: packet[2] * 13 - 4 // magic math }; } else if (subCommand === 0x01) { // First data chunk - includes timestamp const timestamp = parseTimestamp(packet.slice(2, 8)); const values = parseHeartRateValues(packet.slice(8, 17)); return { type: 'data', chunk: 1, timestamp, values }; } else { // Subsequent data chunks - just values const values = parseHeartRateValues(packet.slice(2, 15)); return { type: 'data', chunk: subCommand, values }; } } function parseHeartRateValues(data) { const values = []; for (let i = 0; i < data.length; i++) { if (data[i] !== 0) { // 0 = no measurement values.push(data[i]); } } return values; }
The checksum calculation is a simple XOR of all bytes:
function calculateChecksum(data) { return data.reduce((sum, byte) => sum ^ byte, 0); }
And the timestamp parsing involves converting BCD format back to a proper date:
function parseTimestamp(data) { const year = fromBCD(data[0]) + 2000; const month = fromBCD(data[1]) - 1; // JS months are 0-indexed const day = fromBCD(data[2]); const hour = fromBCD(data[3]); const minute = fromBCD(data[4]); const second = fromBCD(data[5]); return new Date(year, month, day, hour, minute, second); } function fromBCD(bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
There you have it—the secret sauce that turns binary gibberish into meaningful health data. Use it wisely.