The readings your Bluetooth health device app never collected
By Shailendra Kumar Ram · · 10 min read
A heart rate strap and a glucose meter look like the same engineering problem and are not. A heart rate strap streams a number that is worthless the moment it is stale, so an app that only sees live values loses nothing. A glucose meter, a blood pressure cuff or a scale records a measurement whether or not a phone is in the room, and that stored record is the product. Treat the second like the first and you ship an app that quietly loses every reading a user took while their phone was on charge in another room.
The service you need already exists
The first decision on a health device is whether to use the profile the Bluetooth SIG already defined or to invent your own characteristics. Most hardware teams invent their own, usually because the firmware engineer started from a blank GATT table rather than from the specification, and the cost of that lands on the app.
| Service | UUID | What it carries |
|---|---|---|
| Glucose | 0x1808 | Discrete glucose readings, with stored history |
| Continuous Glucose Monitoring | 0x181F | CGM sensor output and session state |
| Blood Pressure | 0x1810 | Systolic, diastolic, mean arterial pressure |
| Health Thermometer | 0x1809 | Body temperature, with measurement site |
| Pulse Oximeter | 0x1822 | SpO2 and pulse rate, spot check and continuous |
| Weight Scale | 0x181D | Weight measurements |
| Body Composition | 0x181B | Fat percentage, muscle mass, water |
Using the standard profile is worth real money. The byte layouts, the unit flags, the timestamp format and the history mechanism are all specified, tested by other people, and already understood by every BLE debugging tool your team will touch. A custom characteristic means writing that specification yourself, defending it in review, and paying for the app work twice when the firmware team changes their mind about endianness.
There is one honest exception. If your device measures something the SIG never defined, you are writing a custom service and no amount of wishing changes that. Write it down properly when you do, in the protocol document rather than in a firmware header nobody outside the firmware team can read.
Stored records, and the characteristic that hands them over
Every health profile that stores history exposes a Record Access Control Point, usually written RACP, at characteristic 0x2A52. It is a control point rather than a data channel: you write a command to it, the device replies with an indication, and the actual records arrive as notifications on the measurement characteristic.
The command is a small binary structure. The first byte is the operation, the second is the operator that filters it, and anything after that is the operand:
| Op code | Operation | What you use it for |
|---|---|---|
| 0x01 | Report stored records | The actual sync |
| 0x02 | Delete stored records | Almost never; see below |
| 0x03 | Abort operation | Cancelling a long sync |
| 0x04 | Report number of stored records | Sizing a progress bar before you commit |
| 0x05 | Number of stored records response | Sent by the device, not by you |
| 0x06 | Response code | Sent by the device: this is your completion signal |
| Operator | Meaning | Operand |
|---|---|---|
| 0x00 | Null | None |
| 0x01 | All records | None |
| 0x02 | Less than or equal to | Filter type, then value |
| 0x03 | Greater than or equal to | Filter type, then value |
| 0x04 | Within range of, inclusive | Filter type, then two values |
| 0x05 | First record, oldest | None |
| 0x06 | Last record, most recent | None |
Fetch everything with operator 0x01 on the first sync only, then never again. A meter that has been in use for two years holds hundreds of records, and re-downloading all of them on every connection is slow, drains the device battery you do not control, and forces you to solve deduplication at the database layer instead of avoiding it.
Incremental sync, which is the whole game
Keep the highest sequence number you have successfully stored, and ask for everything above it. The device does the filtering, so the radio carries only what you are missing:
/// Report Stored Records (0x01), greater than or equal to (0x03),
/// filtered by sequence number (0x01), starting just past what we hold.
///
/// The operand is little-endian, like every multi-byte value in GATT.
func requestRecords(newerThan highWaterMark: UInt16) {
let next = highWaterMark &+ 1
var command = Data([0x01, 0x03, 0x01])
command.append(UInt8(next & 0x00FF))
command.append(UInt8(next >> 8))
// .withResponse: a control point write that is not acknowledged is a
// request you cannot prove the device ever received.
peripheral.writeValue(command, for: racp, type: .withResponse)
}That high water mark belongs in persistent storage, not in memory. The app will be killed between syncs, and a high water mark that resets to zero on launch turns an incremental sync back into a full one without anybody noticing until a support ticket arrives about a flat battery.
The bug almost everyone ships
Records and completion arrive on two different characteristics, and the sync is not finished when the notifications stop. It is finished when the indication arrives on the RACP. Teams that treat a gap in notifications as completion write a sync that succeeds on a desk and truncates on a slow connection, because a pause of two hundred milliseconds looks exactly like the end of the data:
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let data = characteristic.value else { return }
switch characteristic.uuid {
case glucoseMeasurementUUID:
// One notification per stored record. Buffer it; do not commit yet,
// because a procedure that fails halfway must not leave a partial
// history behind.
pending.append(GlucoseRecord(parsing: data))
case racpUUID:
// Response Code: [0x06, null operator, request op code, result].
guard data.count >= 4, data[0] == 0x06 else { return }
if data[3] == 0x01 { // Success
commit(pending)
highWaterMark = pending.last?.sequenceNumber ?? highWaterMark
} else if data[3] == 0x06 { // No records found
// Not an error. It means you are already up to date.
} else {
discard(pending) // Retry on the next connect.
}
pending.removeAll()
default:
break
}
}Two further rules come out of the specification rather than out of taste. Only one RACP procedure may be in flight at a time, so a user who pulls to refresh twice must not produce two overlapping syncs. And 0x06, no records found, is a successful outcome that means the device has nothing newer, not a failure to retry in a loop.
Bonding is mandatory here, not a preference
On a toy peripheral you can skip pairing. On a health device you cannot, and the specification is explicit about it. The Glucose Profile requires that the sensor and the collector bond, that every characteristic in the service sit at LE Security Mode 1 with Security Level 2 or 3, and that the collector start encryption after each connection to verify the bond is still valid.
That has consequences your app has to handle rather than assume away:
- A lost bond is a normal event, not a crash. Users factory reset meters and delete devices from iOS Settings. The app needs a path back through re-pairing that does not require reinstalling it.
- iOS does not let you initiate bonding directly. It happens when you touch a characteristic that requires it, which means your first read is what triggers the system dialog, and the dialog appears at a moment you did not choose unless you plan for it.
- A device bonded to a phone may refuse the next phone. Many meters hold a single bond. Household sharing and device replacement both hit this, and the answer is a documented reset procedure rather than an apology.
The pairing dialog is its own category of problem on iOS, and the reasons it fails to appear are covered separately in why the iOS Bluetooth pairing dialog never appears.
HealthKit does not collect anything for you
This surprises people who assume a standard profile means iOS handles the rest. Nothing on iOS reads your device's glucose or blood pressure into the Health app automatically. Your app connects, parses and writes every sample itself, and if the app is not running and no background delivery is arranged, nothing arrives.
The rules Apple applies once you do write are worth knowing before you design the onboarding rather than during review:
- Read and write are separate grants, per data type. Being allowed to write blood glucose does not let you read the user's weight, and requesting every type available invites reviewer questions you do not want.
- An app using HealthKit needs a privacy policy, and it must describe what health data is collected and what it is used for.
- Health data cannot be used for advertising or sold on, and it may only be shared with third parties to provide health or fitness services to that user.
There is also a deduplication trap specific to stored records. A re-sync after a reinstall replays history you already wrote, and HealthKit will happily store the same reading twice. Key each sample on something stable from the device, the sequence number combined with the device identifier, and check before writing. The sequence number is the device's own record identity, which is exactly why it is the right key and why building your sync on user facing time instead will eventually collide when a user changes the meter's clock.
Where the app becomes a regulated device
This is the question that changes a project's cost more than any technical decision in it, and it is not one to answer from a blog post. What is worth knowing is roughly where the line sits.
The FDA's position in guidance on device software functions and mobile medical applications is risk-based. Software that meets the definition of a device but poses low risk sits under enforcement discretion, which is where an app that collects, transfers, stores and displays readings from a regulated device without modifying them generally lands. Oversight is aimed at the software whose failure could hurt someone, which includes an app that is an accessory to a regulated device or that turns a phone into one.
In practice the boundary is crossed by what your product does with the number rather than by how it got there. Displaying a glucose reading is one thing. Interpreting it, alarming on it, trending it into a recommendation or dosing off it is another, and the moment a clinical claim appears in your marketing you are in a different regulatory conversation. Get that answer from regulatory counsel early, because it determines your documentation, your testing evidence and your release process, not just a label.
What this adds to a build
A companion app for a health device is not a connect-and-display app with a medical logo on it. The parts that are genuinely extra, relative to any other BLE product, are the history sync and its failure paths, bond loss and recovery, HealthKit with correct deduplication, and whatever documentation your regulatory position requires.
- Test with the phone deliberately absent. Take twenty readings with Bluetooth off, then connect. That single test finds the missing RACP implementation instantly, and no amount of desk testing will.
- Test a truncated sync. Walk out of range mid-transfer and confirm nothing partial was committed and the next sync resumes from the right sequence number.
- Test bond loss. Delete the device in iOS Settings and confirm the app recovers without a reinstall.
- Test a clock change. Move the meter's time backwards and confirm your sync still orders and deduplicates correctly.
None of that is exotic, but all of it is work, and it is the work that is missing from an estimate built by looking at the screens. How I put numbers on this kind of scope is set out in what a Bluetooth build actually costs, and the wider list of things worth settling before the first line of code is in the iOS BLE hardware integration checklist.
A live reading is a convenience. The stored record is the medical history, and losing it silently is the only unrecoverable bug in a health device app.
The primary sources here are the Bluetooth SIG service and profile specifications for the health profiles, Apple's HealthKit documentation and App Review Guidelines, and the FDA guidance linked above. If you are building the app for a health device and want a second opinion on the sync design before it ships, send over the GATT table and a packet capture of a stored-record fetch and we can work through it.