The readings your Bluetooth health device app never collected

By · · 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.

ServiceUUIDWhat it carries
Glucose0x1808Discrete glucose readings, with stored history
Continuous Glucose Monitoring0x181FCGM sensor output and session state
Blood Pressure0x1810Systolic, diastolic, mean arterial pressure
Health Thermometer0x1809Body temperature, with measurement site
Pulse Oximeter0x1822SpO2 and pulse rate, spot check and continuous
Weight Scale0x181DWeight measurements
Body Composition0x181BFat percentage, muscle mass, water
UUIDs are assigned by the Bluetooth SIG. Each has a published service specification, for example the Blood Pressure Service and the Pulse Oximeter Service.

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 codeOperationWhat you use it for
0x01Report stored recordsThe actual sync
0x02Delete stored recordsAlmost never; see below
0x03Abort operationCancelling a long sync
0x04Report number of stored recordsSizing a progress bar before you commit
0x05Number of stored records responseSent by the device, not by you
0x06Response codeSent by the device: this is your completion signal
Op codes and operators are defined for the Record Access Control Point characteristic in the Bluetooth SIG's GATT Specification Supplement, and reused unchanged across the health profiles.
OperatorMeaningOperand
0x00NullNone
0x01All recordsNone
0x02Less than or equal toFilter type, then value
0x03Greater than or equal toFilter type, then value
0x04Within range of, inclusiveFilter type, then two values
0x05First record, oldestNone
0x06Last record, most recentNone
Filter type 0x01 is the sequence number and 0x02 is user facing time. Sequence number is the one to build on.

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.

Related writing

All writing · Get in touch about a Bluetooth project