Evolving a BLE GATT Interface: UUIDs, Data Formats and Older App Compatibility
A firmware update can preserve every UUID and still break an older app: a measurement changes units, a command acquires a new meaning, or the client keeps an obsolete handle. A durable BLE interface needs an explicit compatibility contract before the update ships. The design below separates wire format, discovery state and product behavior. Examples are proposed designs, not measured customer results.
Define the Contract Between Firmware and App
Start with the operations a user must complete: identify the device, read a measurement, change a setting and observe the result. For each operation, record the service and characteristic UUIDs, instance-selection rule, properties, security permissions, payload length, byte order, unit, valid range and failure response. Include whether a successful write means “accepted” or “completed.” A UUID list alone cannot explain these distinctions.
Keep three identifiers separate: app release, firmware build and protocol revision. A firmware maintenance release may leave the contract untouched; two product variants may expose different capabilities under the same release family. Give the app an explicit capability query, with defined behavior when that query is absent. Select the oldest supported contract deliberately rather than extracting compatibility from a marketing version string.
Classify Compatible and Breaking Changes
Review a change against existing behavior before deciding how to implement it. Adding an optional measurement is a candidate extension only when old clients tolerate unknown characteristics and still find their required instances. Reusing an old field for a new unit creates a different risk: discovery may succeed while the displayed value is wrong. The following criteria are an engineering review aid.
| Proposed change | Compatibility criterion | Design decision |
|---|---|---|
| Add optional characteristic | Old app ignores unknown entries; required operations unchanged | Retain old path; verify rediscovery |
| Insert attributes before an existing value | Old handles may resolve differently | Review database diff and cache handling |
| Change bytes from Celsius to Fahrenheit | Existing parser silently gives wrong meaning | Preserve old format or use a new contract |
| Require stronger security for a write | Old app can establish required security and explain refusal | Define migration or explicit rejection |
| Remove a command | Supported clients still need that operation | Keep a deprecated interface or end support explicitly |
Make the decision per supported client. “Additive” describes a schema edit; it does not prove every historical parser can consume it.
Separate UUID Identity from Handle Allocation
UUIDs identify attribute types; handles address attributes in a particular database. See Bluetooth Core 6.3, GATT §2.5.1. An app should select the expected service instance, discover its characteristics and use the resulting references. Audit constants that look like attribute addresses, index-based characteristic selection and persistence of objects from an earlier connection.
Export a database manifest from both builds. Include service boundaries, declaration handles, value handles, descriptor handles, UUIDs, properties and permissions from the server definition. An illustrative diff might show a telemetry value moving from 0x0025 to 0x0028 after an insertion; those addresses are invented examples, not a proposed layout. Preserve an unchanged allocation where practical, but never use “same UUID” as the entire migration argument. Review optional build flags too, because the shipped database can differ from the developer configuration.
| Attribute | Old build | New build |
|---|---|---|
| Telemetry declaration | 0x0024 | 0x0027 |
| Telemetry value, same UUID | 0x0025 | 0x0028 |
| Telemetry CCCD | 0x0026 | 0x0029 |
Design Data Formats for Version Changes
For a custom telemetry contract, define a stable major revision, flags, total length and typed extensions. In the illustration, a signed temperature uses hundredths of a degree Celsius. A reserved flag must not silently change that interpretation. An unknown optional extension may be skipped only because its length is explicit; a truncated field or unsupported major revision is rejected.

The following language-neutral pseudocode shows the required decision points. It is stack-independent and untested; implement checked arithmetic, buffer ownership and application-specific error reporting in the target language.
if received_bytes < 8: reject("short header")
if major != 1: reject("unsupported contract")
if total_length != received_bytes: reject("bad length")
position = 8
while position < total_length:
require(total_length - position >= 2)
type, length = header_at(position)
require(length <= total_length - position - 2)
if known(type): parse_exact_length(type, length)
else if critical(type): reject("unknown required field")
position += 2 + length
For this example, type bit 7 marks a critical extension; test unknown enum values separately. A length field creates a parsing boundary; it does not make arbitrary future semantics compatible.
Coordinate Subscriptions with Database Changes
Track database validity, server-side CCCD configuration and app-side notification handlers as separate state. Service Changed and Database Hash support cache coherence; neither defines your application payload migration. Their conditions are specified in Core 6.3, GATT §§2.5.2, 7.1 and 7.3. Do not treat Service Changed as a universal repair for missing data.
Define who invalidates old references, who rediscovers, and when business subscriptions become usable again. For a Zephyr 3.7 client, subscription parameters must remain alive while subscribed; the fixed-version GATT API also distinguishes subscribe and resubscribe operations. This is relevant to gateway clients as well as phones, but it does not describe Android or iOS lifecycle behavior. Use the OTA cache diagnostic procedure when an actual failure needs evidence.
Define Safe Behavior for Unsupported Versions
Define separate outcomes for reads, writes and notifications. An unsupported read can show “feature unavailable”; malformed telemetry should be discarded with a diagnostic reason. For an unsupported control command, keep the actuator unchanged and return the agreed rejection path. Never reinterpret an unknown command as the closest supported action. Where a write has no ATT response, design an application result channel if the operation requires completion evidence.
Choose a deprecation period that the product can actually support. Document which app releases may read legacy telemetry, which may control the device, and when an app upgrade becomes mandatory. A recovery screen should explain the required action without recommending repeated pairing resets. Capability negotiation must gate the operation, not merely log a version mismatch while continuing.
Create a Reviewable Compatibility Record
Make the compatibility review reproducible. Store the following beside the release candidate so a different engineer can challenge the decision without rebuilding its history.
- Freeze the supported app and firmware list, then export both database manifests.
- Compare handles, permissions and payload schemas; classify each difference using the criteria above.
- Write the expected legacy-client behavior for every changed operation, including refusal paths.
- Attach parser vectors for short packets, unknown optional fields, unknown critical fields and unsupported revisions.
- Assign each support claim to a real-device case in the BLE OTA acceptance matrix.
Keep test status separate from design approval. No hardware compatibility result is asserted here. For implementation review, use the existing BLE service and client review guide. To scope a product change, bring the before-and-after GATT tables, supported app releases and control-command semantics to Obeita’s connectivity assessment.