Facts checked on 16 October 2026

Short answer

One UWB accessory on Qorvo silicon can range with iPhones and Android phones: both run FiRa-style unicast DS-TWR over IEEE 802.15.4z. Only the out-of-band (OOB) set-up differs. iPhones use Apple’s Nearby Interaction accessory protocol and send the final configuration. Android 16’s Ranging module (android.ranging) uses Google’s published OOB specification or a custom OOB set by the app. The firmware needs one UWB core, a session manager, two OOB front ends and a phone test matrix. Qorvo ships the Apple side for DW3000-family kits but offers no Android SDK.

Two ways to agree on a session

Ranging needs both devices to share addresses, a session ID, a channel, a preamble, timing and security settings before the first UWB frame. Qorvo staff put it this way in June 2026: the exchange is “primarily a mechanism for two devices to agree on a common FiRa session configuration”. Once both sides match, “ranging proceeds the same way regardless of how that configuration was obtained”.

iPhone: Nearby InteractionAndroid: default OOBAndroid: custom OOB
Who sets the final configurationThe iPhone, after reading the accessory’s “Accessory Config Data”The initiator, in a Ranging Configuration messageYour app
SpecificationApple’s Nearby Interaction Accessory Protocol SpecificationGoogle’s “Ranging: Out-of-band specification”, now version 3Your own protocol
Phone APININearbyAccessoryConfiguration (iOS 15 and later)RangingManager with OobInitiatorRangingConfig (API 36)RawInitiatorRangingConfig and UwbRangingParams (API 36)
Several peersiOS opens multiple unicast sessionsOne-to-one session per peerDevices can be added dynamically
BackgroundNeeds a Bluetooth LE paired and connected accessory; Live Activity option from iOS 18.4UWB only, on supported devicesUWB only, on supported devices

Apple’s documentation describes the flow: the app establishes a data link with the accessory, receives its configuration data and creates an NINearbyAccessoryConfiguration. The framework then generates “shareable configuration data” that the app sends to the accessory. A Qorvo engineer explained the consequence for firmware in 2025: the accessory can set “only very few parameters”, such as the device role and update rate, and “the iPhone will send the final configuration to be used”.

Google’s specification is public and short. It defines capability request and response, configuration and stop messages, each with a two-byte header, and “doesn’t assume a specific connection-based communication channel”. The responder “starts ranging immediately after receiving the Ranging Configuration message”. Version 1 came with Android 16, version 2 with Android 16 QPR3 and version 3 with Android 17.

Google’s OOB messages at byte level

Each message starts with a version byte and a message ID; multi-byte numbers are little-endian. The phone sends Ranging Capability Request (0x0), the accessory answers with Ranging Capability Response (0x1), and the phone sends Ranging Configuration (0x2) and later Stop Ranging (0x6). The accessory must accept repeated and out-of-order requests, answer a newer phone in its own older version and skip unknown bytes using each block’s size field. Fragmentation “lies on the implementor of the OOB communication channel”, and capabilities are requested again before every session “because the UWB address can rotate”.

With a static STS key the UWB block is 27 bytes and a version 3 Ranging Configuration 34 bytes; a 16-byte provisioned key makes it 42. AOSP’s parser applies the received role locally, takes the received address as its peer and rejects intervals other than the config ID’s fast, normal or infrequent value. Illustrative bytes:

Capability Request, phone to accessory (version 3, UWB only)
  03 00  01 00
Capability Response, accessory to phone
  03 01                header: version 3, message ID 0x1
  01 00                supported technologies: UWB
  00 14                UWB block: technology 0x0, size 20
  3A 7C                accessory UWB address
  20 02 00 00          channels 5 and 9 (bit n = channel n)
  00 0F 00 00          preamble indexes 9-12 (bit 0 = index 1)
  02 00 00 00          config ID 1 only (bit n = config ID n)
  78 00                fastest interval 120 ms
  02                   minimum slot duration 2 ms
  02                   UWB roles: responder
  00                   transitioning: break-before-make
  03 00                device type: tag
Ranging Configuration, phone to accessory
  03 02  01 00  01 00  header, technologies to configure and to start
  00 1B                UWB block: technology 0x0, size 27
  51 C2                phone UWB address (the accessory's peer)
  78 56 34 12          session ID 0x12345678
  01 09 0B             config ID 1, channel 9, preamble index 11
  F0 00                interval 240 ms
  02 08                slot 2 ms, key length 8
  vv vv ii ii ii ii ii ii   vendor ID, static STS IV
  44 45                country code "DE"
  02 02                role responder, mode controlee
  00                   motion data not requested

This C parser sketch is illustrative and follows the UwbConfiguration structure in Google’s PDL file:

/* Illustrative parser for the UWB block of Google's Ranging Configuration
 * (OOB specification v3, PDL struct UwbConfiguration). Little-endian. */
#include <stddef.h>
#include <stdint.h>
#include <string.h>

struct gro_uwb_cfg {
    uint8_t  peer_addr[2];   /* the phone's UWB address */
    uint32_t session_id;
    uint8_t  config_id;      /* android.ranging numbering */
    uint8_t  channel, preamble;
    uint16_t interval_ms;    /* 96, 120, 240 or 600 */
    uint8_t  slot_ms;        /* 1 or 2 */
    uint8_t  key_len;        /* 8 = vendor ID + static STS IV */
    uint8_t  key[32];
    char     country[2];     /* ISO 3166-1 alpha-2 */
    uint8_t  role, mode;     /* for this device */
};

int gro_parse_uwb(const uint8_t *p, size_t avail, struct gro_uwb_cfg *c)
{
    if (avail < 2 || p[0] != 0x00 || p[1] < 19 || p[1] > avail)
        return -1;                          /* not UWB, or truncated */
    size_t len = p[1], i = 2;
    memcpy(c->peer_addr, p + i, 2);                          i += 2;
    c->session_id = (uint32_t)p[i] | (uint32_t)p[i + 1] << 8 |
                    (uint32_t)p[i + 2] << 16 | (uint32_t)p[i + 3] << 24;
    i += 4;
    c->config_id   = p[i++];
    c->channel     = p[i++];
    c->preamble    = p[i++];
    c->interval_ms = (uint16_t)(p[i] | p[i + 1] << 8);       i += 2;
    c->slot_ms     = p[i++];
    c->key_len     = p[i++];
    if (c->key_len > sizeof c->key || i + c->key_len + 4 > len)
        return -1;
    memcpy(c->key, p + i, c->key_len);                       i += c->key_len;
    memcpy(c->country, p + i, 2);                            i += 2;
    c->role = p[i++];
    c->mode = p[i++];
    return (int)len;        /* bytes after i belong to newer versions */
}

FiRa sells its own Bluetooth LE OOB Channel and CSML specifications (1.0.0, from $200 per copy for non-members); they are separate from the Apple and Google formats described here.

The Apple exchange, seen from the app

Apple’s accessory specification defines both configuration blobs, and the app passes them through unchanged. Apple’s public sample shows the rest: a GATT service with UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E and a one-byte message ID, with initialize (0x0A) and configureAndStart (0x0B) from the phone. Apple calls it “an example messaging protocol”, so you may change it. This Swift sketch is illustrative:

// Illustrative: iOS 16+ accessory session; message IDs from Apple's sample.
import CoreBluetooth
import NearbyInteraction

enum MessageId: UInt8 {
    case accessoryConfigurationData = 0x1, accessoryUwbDidStart = 0x2
    case accessoryUwbDidStop = 0x3
    case initialize = 0xA, configureAndStart = 0xB, stop = 0xC
}

final class AccessoryLink: NSObject, NISessionDelegate {
    let session = NISession()
    var config: NINearbyAccessoryConfiguration?
    var peripheral: CBPeripheral!          // paired and connected
    var send: (Data) -> Void = { _ in }    // one write: must fit the MTU

    func start() {
        guard NISession.deviceCapabilities.supportsPreciseDistanceMeasurement
        else { return }
        session.delegate = self
        send(Data([MessageId.initialize.rawValue]))
    }

    func received(_ msg: Data) throws {
        guard msg.first == MessageId.accessoryConfigurationData.rawValue
        else { return }
        let c = try NINearbyAccessoryConfiguration(
            accessoryData: msg.dropFirst(),
            bluetoothPeerIdentifier: peripheral.identifier)
        config = c
        session.run(c)
    }

    func session(_ session: NISession,
                 didGenerateShareableConfigurationData data: Data,
                 for object: NINearbyObject) {
        guard object.discoveryToken == config?.accessoryDiscoveryToken
        else { return }
        send(Data([MessageId.configureAndStart.rawValue]) + data)
    }

    func session(_ session: NISession, didInvalidateWith error: Error) {
        switch error {
        case NIError.invalidConfiguration: break            // log accessory bytes
        case NIError.accessoryPeerDeviceUnavailable: break  // restore the link
        case NIError.userDidNotAllow: break                 // point to Settings
        default: send(Data([MessageId.stop.rawValue]))
        }
    }
}

Creating the configuration with init(accessoryData:bluetoothPeerIdentifier:) (iOS 16) enables background ranging but ties the session to the Bluetooth link. One developer reported accessoryPeerDeviceUnavailable after a link drop, and a Qorvo engineer confirmed that “BLE is required for NI background sessions”. Add NSNearbyInteractionUsageDescription to Info.plist and, for background use, the “Uses Nearby Interaction” background mode. A suspended session “won’t resume on its own”; Apple’s sample restarts the exchange from initialize.

What the two ecosystems share

The overlap is large enough for one UWB core:

  • Unicast DS-TWR. Android’s CONFIG_UNICAST_DS_TWR is “FiRa-defined unicast STATIC STS DS-TWR ranging, deferred mode”, with intervals of 120, 240 or 600 ms.
  • Static STS keys built the same way. Google’s OOB specification states: “If S-STS is used then the first two bytes are VENDOR ID and the following six bytes are STATIC STS IV.”
  • iOS compatibility on Android is documented. Google’s Ranging guide says that to range with an iOS device you use a custom OOB and set CONFIG_UNICAST_DS_TWR, a session key with “the Vendor ID and the Static STS IV”, and a channel and preamble index that match the iOS device’s configuration.
  • Motion hints. Google’s OOB version 3 adds a Motion Notification (0x8) for orientation changes of 5–7°, 7–10° and over 10°; iOS 27 adds updateMotionState(_:forObjectWithToken:). One accessory motion detector can feed both.

Android adds more on top: provisioned STS configurations (config IDs 3 to 6 in android.ranging numbering, with 16- or 32-byte keys), a 96 ms “very fast” interval and HPRF preamble codes. Decide early whether your accessory needs those, because each one widens the test matrix.

Session parameters side by side

UWB session parameters for iPhone Nearby Interaction, Android default OOB and Android custom OOB.
ParameteriPhone (Nearby Interaction)Android default OOBAndroid custom OOB
Ranging methodFiRa unicast DS-TWRConfig ID from the capability exchange, for example CONFIG_UNICAST_DS_TWRAny supported config ID
Channel and preambleChosen by the iPhoneNegotiated from capability bitfieldsSet by the app; Google advises “a random preamble index for each ranging session”
Ranging intervalAccessory states a preferred update rate96, 120, 240 or 600 msUpdate-rate constants: normal, infrequent, frequent
Slot durationSet by the iPhone1 or 2 ms1 or 2 ms (default 2 ms)
RolesAccessory states a preferred roleInitiator or responder; controller or controleeSet by the app
SecuritySet in the iPhone’s configurationStatic or provisioned STS, by config IDStatic or provisioned STS, by config ID

A Qorvo engineer’s warning applies to the iPhone column: “If you use a different configuration or modify it yourself, TWR is likely not to work.” The accessory must apply exactly what the iPhone sends.

Mapping onto the accessory’s FiRa session

The firmware always ends with FiRa session parameters: UCI application configuration on the QM35825 or fira_param in the DW3_QM33 SDK. Channel, preamble, addresses, interval and key come from the OOB; for the rest, AOSP’s Ranging module backend sets these for CONFIG_UNICAST_DS_TWR:

ParameterUCI tagConfig ID 1 value
Round usage0x01 RANGING_ROUND_USAGEDS-TWR, deferred
STS mode0x02 STS_CONFIGStatic: 0x27 VENDOR_ID, 0x28 STATIC_STS_IV
Multi-node mode0x03 MULTI_NODE_MODEUnicast
Slot duration0x08 SLOT_DURATION2,400 RSTU (2 ms)
Interval0x09 RANGING_DURATION120, 240 or 600 ms
Role0x00 DEVICE_TYPE, 0x11 DEVICE_ROLEController is initiator
Slots per round0x1B SLOTS_PER_RR6
Hopping0x2C HOPPING_MODEEnabled
Priority0x25 SESSION_PRIORITYNot exchanged; your choice

The backend also sets FiRa protocol version 1.1, 2-byte MAC addresses, three in-band termination attempts and a 10-second error streak timeout, and converts slots as ms × 499.2 × 1000 / 416. Qorvo’s QM35 script run_fira_twr defaults to 25 slots, 200 ms and hopping off, so configure a bench peer explicitly.

Config IDs differ between the two Android APIs

IDandroid.ranging and Google OOBJetpack and Android 16 CDD
1Unicast, static STSSame
2One-to-many, static STSSame
3Unicast, P-STSUnicast without AoA
4One-to-many, P-STSUnicast, P-STS
5One-to-many, individual keysOne-to-many, P-STS
6ID 3 with 96 ms fast intervalID 3 with P-STS
7Not definedOne-to-many, individual keys

Only IDs 1 and 2 agree, so define config numbers in your own protocol instead of passing a platform constant through. Jetpack’s reference also lists RANGING_UPDATE_RATE_FREQUENT as 600 ms and RANGING_UPDATE_RATE_INFREQUENT as 120 ms, the reverse of the Ranging module and AOSP, so measure intervals from timestamps.

Kotlin on both Android paths

On Android 16 and later, use OobInitiatorRangingConfig when the accessory implements Google’s OOB, and RawInitiatorRangingConfig for iPhone-style parameters or several peers in one session. The default OOB needs your transport, which carries whole messages in both directions; AOSP’s test app uses an LE L2CAP channel whose PSM sits in a GATT characteristic, so messages bypass GATT writes. Illustrative sketch:

// Illustrative: Android 16+ default OOB over an LE L2CAP channel.
// RANGING_MODE_* and SECURITY_LEVEL_* come from OobInitiatorRangingConfig.
class L2capOob(private val socket: BluetoothSocket) : TransportHandle {
    private lateinit var exec: Executor
    private lateinit var cb: TransportHandle.ReceiveCallback

    override fun sendData(data: ByteArray) {
        try {
            socket.outputStream.write(data)        // one OOB message per SDU
        } catch (e: IOException) {
            exec.execute { cb.onSendFailed() }
        }
    }

    override fun registerReceiveCallback(
        executor: Executor, callback: TransportHandle.ReceiveCallback
    ) {
        exec = executor
        cb = callback
        thread(name = "oob-rx") {
            val buf = ByteArray(1024)
            while (true) {
                val n = try {
                    socket.inputStream.read(buf)
                } catch (e: IOException) { -1 }
                if (n < 0) { executor.execute { callback.onClose() }; break }
                val msg = buf.copyOf(n)
                executor.execute { callback.onReceiveData(msg) }
            }
        }
    }

    override fun close() = socket.close()
}

// Check availability first; DISABLED_REGULATORY needs a different message.
fun whenUwbReady(ctx: Context, start: () -> Unit) {
    ctx.getSystemService(RangingManager::class.java)
        .registerCapabilitiesCallback(ctx.mainExecutor) { caps ->
            when (caps.technologyAvailability[RangingManager.UWB]) {
                RangingCapabilities.ENABLED -> start()
                RangingCapabilities.DISABLED_REGULATORY -> showRegionHint()
                else -> showUwbOffHint()
            }
        }
}

// Call off the main thread: connect() blocks.
fun startDefaultOob(ctx: Context, dev: BluetoothDevice, psm: Int,
                    callback: RangingSession.Callback): RangingSession {
    val socket = dev.createInsecureL2capChannel(psm).apply { connect() }
    val peer = RangingDevice.Builder()
        .setUuid(UUID.nameUUIDFromBytes(dev.address.toByteArray()))
        .build()
    val config = OobInitiatorRangingConfig.Builder()
        .setFastestRangingInterval(Duration.ofMillis(120))
        .setSlowestRangingInterval(Duration.ofMillis(600))
        .setRangingMode(RANGING_MODE_HIGH_ACCURACY_PREFERRED)
        .setSecurityLevel(SECURITY_LEVEL_BASIC)     // static STS for UWB
        .addDeviceHandle(DeviceHandle.Builder(peer, L2capOob(socket)).build())
        .build()
    val session = ctx.getSystemService(RangingManager::class.java)
        .createRangingSession(ctx.mainExecutor, callback)
    session.start(RangingPreference.Builder(DEVICE_ROLE_INITIATOR, config).build())
    return session
}

val callback = object : RangingSession.Callback {
    override fun onOpened() = Unit
    override fun onOpenFailed(reason: Int) = log("open failed: $reason")
    override fun onStarted(peer: RangingDevice, technology: Int) = Unit
    override fun onResults(peer: RangingDevice, data: RangingData) =
        log("${data.timestampMillis} ${data.distance.measurement} m " +
            "confidence=${data.distance.confidence}")
    override fun onStopped(peer: RangingDevice, technology: Int) = Unit
    override fun onClosed(reason: Int) = log("closed: $reason")  // 3 = UNSUPPORTED
}

The Jetpack path for Android 12 to 15 is illustrative too; AppOob is your own class:

// Illustrative: androidx.core.uwb 1.0.0, phone as controller.
suspend fun rangeWithJetpack(ctx: Context, oob: AppOob, accessoryAddr: ByteArray,
                             sessionId: Int, vendorIdAndStsIv: ByteArray) {
    val scope = UwbManager.createInstance(ctx).controllerSessionScope()
    // Address and channel may change after each session: send them every time.
    oob.sendSetup(scope.localAddress, scope.uwbComplexChannel, sessionId)
    val params = RangingParameters(
        uwbConfigType = RangingParameters.CONFIG_UNICAST_DS_TWR,
        sessionId = sessionId,
        subSessionId = 0,
        sessionKeyInfo = vendorIdAndStsIv,        // 2-byte vendor ID + 6-byte IV
        subSessionKeyInfo = null,
        complexChannel = scope.uwbComplexChannel,
        peerDevices = listOf(UwbDevice.createForAddress(accessoryAddr)),
        updateRateType = RangingParameters.RANGING_UPDATE_RATE_AUTOMATIC
    )
    scope.prepareSession(params).collect { r ->
        when (r) {
            is RangingResult.RangingResultPosition ->
                oob.log("${r.position.distance?.value} m")
            is RangingResult.RangingResultPeerDisconnected -> oob.restart()
            else -> Unit
        }
    }
}

Jetpack needs android.permission.UWB_RANGING and the Ranging module android.permission.RANGING. Jetpack’s localAddress “can only be used for a single ranging session”.

Firmware architecture on Qorvo silicon

One UWB core with two out-of-band paths: Apple Nearby Interaction and Android Ranging over Bluetooth LE feed a session manager that configures one FiRa session per phone.

A dual-ecosystem accessory has five layers:

  1. UWB core: the FiRa session in Qorvo’s stack. On the DWM3001C and QM33 it is the DW3_QM33 SDK’s FiRa library; on the QM35825 it is Qorvo’s firmware, controlled from a host over UCI.
  2. Session manager: maps whatever arrives over OOB onto FiRa session parameters and checks them against what the accessory supports.
  3. Apple front end: the Nearby Interaction accessory service over Bluetooth LE.
  4. Android front end: Google’s OOB messages over Bluetooth LE, or your own protocol.
  5. Security and storage: keys, pairing data and per-phone state.

Qorvo’s public material covers layer 3 and leaves layer 4 to you:

  • Apple: Qorvo publishes a “Qorvo Nearby Interaction” package for the DWM3001CDK, and the DW3_QM33 SDK contains a QANI project. In 2025 a Qorvo engineer pointed developers to “the BLE part of the QANI implementation inside the SDK”, where “Murata Type2AB” is one of the targets.
  • Android: Qorvo staff wrote in March 2025: “We don’t offer SDK for android”. In January 2026: “Qorvo does not have a sample application for this”, with advice to study the QANI firmware’s use of Bluetooth to set UWB parameters. In July 2026: “For Android, unfortunately, you will have to develop your own app.”
  • Qorvo’s Android app on Google Play “is designed to work with Qorvo RTLS tags only at the moment”, according to Qorvo staff in April 2026.
  • QM35825: “the NI would be implemented on the host processor and then control the QM35825 over UCI”, Qorvo staff wrote in September 2026.

Qorvo’s newer QPK3000 tag module lists last-metre finding “following Apple Nearby Interaction Accessory Protocol and Android equivalent”, so dual support is becoming a product expectation, not an extra.

Starting from QANI on the DWM3001C

In January 2025 a Qorvo engineer outlined the path to Android: reuse QANI’s Bluetooth LE code; the UWB configuration is built in fira_niq_helper(), which passes fira_param to construct_fira_param_from_config(); for Android only, remove the niq library and the “anis” service and adapt “qnis”. By default QANI calls niq_stop_uwb() when Bluetooth disconnects. On several phones, a Qorvo engineer wrote in March 2024 that the QNI 3.1.0 MAC could not run multiple sessions, and in December 2025, for SDK 1.1.1, that there is “no restriction” if the firmware keeps advertising and tracks each connection’s context. In the same thread a developer reported that, on SDK 1.1.1, a second iPhone’s start command froze ranging.

A session manager for more than one phone

Keep one entry per phone with its connection, session ID, timing, priority, state and round counter, and apply four rules:

  1. Admit by radio time. A config ID 1 round takes 6 slots of 2 ms: 5% of airtime at 240 ms, 10% at 120 ms. Qorvo staff put the QM35825 limit at “available radio time”; capability 0xE9 in Qorvo’s QM35 UCI specification gives the maximum session count. Refuse a phone during the OOB exchange.
  2. Make conflicts visible. Qorvo’s UCI specification says only one session runs at a time and, when several are due, “the scheduler will discard the others”. Set SESSION_PRIORITY (0x25, 1 to 100, default 50) and enable QORVO_SESSION_SCHEDULING_INFO_NTF to see which rounds ran.
  3. Watch for silence. Developers reported DW3_QM33 SDK 1.1.1 sessions that stopped without any SESSION_STATUS_NTF; one linked it to Bluetooth advertising and recovered by restarting all sessions after 2 seconds without a new round. These are user reports, but a watchdog is cheap.
  4. Separate Bluetooth and UWB timing. Qorvo staff confirmed that on the QM35825, where UWB runs on the chip, host Bluetooth activity cannot disturb round scheduling. On a single-MCU DWM3001C, slow or stop advertising during sessions unless measurements show otherwise.

This sketch is illustrative; the app_ functions are placeholders:

/* Illustrative session table for one UWB radio shared by several phones. */
#include <stdbool.h>
#include <stdint.h>

#define MAX_PEERS       4
#define AIRTIME_BUDGET  400    /* permille of radio time, example value */
#define STALL_MS        2000   /* no new round for 2 s = stalled */

enum eco   { ECO_APPLE, ECO_ANDROID };
enum state { S_FREE, S_OOB, S_CONFIGURED, S_ACTIVE, S_STOPPING };

struct peer {
    enum state state;
    enum eco   eco;
    uint16_t   conn;            /* Bluetooth LE connection handle */
    uint32_t   session_id;
    uint16_t   interval_ms;     /* 120, 240 or 600 for config ID 1 */
    uint8_t    slots, slot_ms;  /* 6 and 2 for config ID 1 */
    uint8_t    priority;        /* SESSION_PRIORITY, 1..100 */
    uint32_t   rounds;          /* +1 on every round, failed ones too */
    uint32_t   rounds_seen, last_change_ms;
};

static struct peer peers[MAX_PEERS];

static uint32_t airtime_permille(const struct peer *p)
{
    return 1000u * p->slots * p->slot_ms / p->interval_ms;  /* 6x2/240 = 50 */
}

bool sm_admit(const struct peer *cand)     /* call before the OOB reply */
{
    uint32_t used = airtime_permille(cand);
    for (int i = 0; i < MAX_PEERS; i++)
        if (peers[i].state >= S_CONFIGURED)
            used += airtime_permille(&peers[i]);
    return used <= AIRTIME_BUDGET;
}

void sm_watchdog(uint32_t now_ms)          /* call every 500 ms */
{
    for (int i = 0; i < MAX_PEERS; i++) {
        struct peer *p = &peers[i];
        if (p->state != S_ACTIVE)
            continue;
        if (p->rounds != p->rounds_seen) {
            p->rounds_seen = p->rounds;
            p->last_change_ms = now_ms;
        } else if (now_ms - p->last_change_ms > STALL_MS) {
            app_log_stall(p);
            app_restart_all_sessions();    /* the reported fix restarted all */
            p->last_change_ms = now_ms;
        }
    }
}

On a QM35825 with a Linux host, Qorvo’s Cherry library in the public QM35 SDK maps the same settings to C calls (illustrative):

/* Illustrative: QM35825 host, Qorvo Cherry API, accessory as controlee.
 * Check Cherry's defaults for slot duration and round usage in your SDK. */
struct cherry_fira_session *s = cherry_fira_session_create_twr_controlee(
    ctx, on_fira_event, peer, cfg.session_id, cfg.interval_ms,
    phone_short_addr, my_short_addr);       /* byte order: verify on air */
if (s == NULL)
    return -1;
cherry_fira_session_set_channel(s, cfg.channel);
cherry_fira_session_set_preamble_code_index(s, cfg.preamble);
cherry_fira_session_set_slots_per_ranging_round(s, 6);
cherry_fira_session_set_hopping_mode(s, 1);
cherry_fira_session_set_vendor_id(s, &cfg.key[0]);    /* 2 bytes, check order */
cherry_fira_session_set_static_sts(s, &cfg.key[2]);   /* 6 bytes */
cherry_fira_session_set_priority(s, peer->priority);
cherry_fira_session_start(s);        /* asynchronous: wait for the event */

Bluetooth LE rules both front ends depend on

  • MTU. Apple’s Accessory Design Guidelines (September 2026) say an accessory “should select an MTU equal to or greater than the device’s MTU request”. Apple’s sample writes each message once, cut to maximumWriteValueLength(for: .withResponse), so a small MTU truncates silently. Android 14 and later request 517 bytes on the first requestMtu() call.
  • Connection parameters. The guidelines ask for an interval minimum of at least 15 ms in 15 ms steps, a maximum at least 15 ms higher (or both 15 ms), latency up to 30 and a 6 to 18 s supervision timeout above interval maximum × (latency + 1) × 3; other requests “may be rejected”.

Pitfalls that cost weeks

  • Byte order on older Android. Google’s UWB guide warns that “on Android 13 and lower, the Android UWB stack incorrectly reverses the byte order” of the device MAC address, destination MAC address and static STS vendor ID. Handle it per Android version, or ranging fails silently on part of your users’ phones.
  • Two Android APIs. The Jetpack guide, last updated in February 2026, still says “UWB is currently only available using Jetpack”, while the Ranging module covers UWB on Android 16. Jetpack androidx.core.uwb reached 1.0.0 stable in May 2026. Plan which API your companion app uses on which Android version.
  • Accessory compliance on Android. Google’s Jetpack guide states: “If ranging against IoT devices, ensure that they are FiRa MAC 1.3 compliant.”
  • Background rules differ. On iPhone, background ranging needs a Bluetooth LE paired and connected accessory, or a Live Activity from iOS 18.4. On Android, “third-party apps are only allowed to perform background ranging with ultra-wideband, and only on supported devices”; Google lists the Pixel 6 Pro, Pixel 7 Pro and Pixel Fold as not supporting background UWB ranging.
  • Permissions. Android 16 introduced android.permission.RANGING, in the nearby-devices permission group. Missing permissions look like ranging failures in field reports.
  • Specification access. Apple’s accessory specification is available to registered developers under Apple’s terms. In 2024 a Qorvo engineer wrote that for the session details “you will need to apply to the NI MFI program with Apple”. Check Apple’s current requirements before you plan the schedule.

Failure patterns

SymptomLikely causeHow to confirmFix
iPhone link drops after about 90 sConnection parameters rejectedTime it; look for three update requestsApple’s section 58.6 values
invalidConfigurationTruncated or malformed accessory dataLog message length on both endsLarger MTU or framing
Fails on Android 13 and lower onlyReversed address and vendor ID bytesCompare addresses in both logsHandle per version
UCI status 0x21 or 0x24Timing, channel or STS mismatchDump both session configurationsApply the negotiated set
REASON_UNSUPPORTED on closeValue outside capabilitiesDecode the Capability ResponseAdvertise only what works
Rounds stop, no notificationSilent stall (user reports)Round counter frozenWatchdog restart

In AOSP’s FiRa UCI definitions, 0x21 is UCI_STATUS_RANGING_RX_TIMEOUT and 0x24 is UCI_STATUS_RANGING_RX_PHY_STS_FAILED: a timeout points at timing, channel or role, an STS failure at the vendor ID and IV and their byte order.

Bring-up order and pass/fail checks

Run the stages in order; each isolates one layer. Thresholds are suggestions, not platform requirements.

  1. UWB core: config ID 1 values against a QM35 board running run_fira_twr. Pass: 99% valid results over 1,000 rounds at 1 m.
  2. OOB parsers: replay recorded and fuzzed messages. Pass: no crash, unknown bytes skipped, replies accepted by a parser generated from Google’s PDL.
  3. One Android phone: pass when results arrive at the negotiated interval.
  4. One iPhone: pass when the link survives 90 seconds and the session runs for 30 minutes.
  5. Both phones: pass at 95% of expected rounds each for an hour, with no watchdog restarts.
  6. Lifecycle: background, lock, refused permissions and reboots. Pass: recovery or a defined reason code.

A test matrix that reflects real users

Test matrix for a dual-ecosystem UWB accessory: iPhones and Android phones across session flows, app states, several peers, radio conditions and updates.
DimensionWhat to cover
iPhonesModels with U1 and U2 chips from iPhone 11 onwards; current and previous iOS versions
Android phonesPhones that report UWB support, across makers; Android 14 to 17; both Jetpack and Ranging module paths
Session flowsFirst pairing, reconnection, accessory reboot, phone reboot, out-of-range and return
App statesForeground, background, locked screen, permissions refused
Several peersTwo or more phones with one accessory; one phone with several accessories
Radio conditionsDistance sweep, body blocking, busy 2.4 GHz environment
UpdatesAccessory firmware update, phone OS update between test runs

Real phones are slow to automate, and teams spend weeks re-running the same flows by hand. Automating the phone side, or simulating it for regression runs, keeps the matrix affordable. For field issues, a UWB sniffer with a Bluetooth LE companion captures the OOB exchange and the UWB session together.

Measurement method and pass criteria

Android’s CDD gives a phone-side reference: distances “within +/-15 cm for 95% of the measurements” at 1 m line of sight in a non-reflective chamber, and a median of 0.75 to 1.25 m measured from the phone’s top edge. Use that set-up, add 3, 5 and 10 m points and collect at least 1,000 rounds (4 minutes at 240 ms) per point, phone and orientation. Report median error, 95th-percentile error and result rate, and log each iPhone’s supportsDirectionMeasurement.

Automating the matrix

  • Phone as scripted peer: adb shell cmd uwb start-fira-ranging-session sets every session parameter explicitly, and get-ranging-session-reports prints the last 20 reports. AOSP marks it as needing no root and grants its Shell package UWB_PRIVILEGED; check your builds. simulate-app-state-change covers background handling.
  • QM35 board as phone emulator: run_fira_twr --range_dump writes each measurement to JSON.
  • Logs: Android’s Bluetooth HCI snoop log captures the OOB exchange; cmd uwb set-log-mode captures UCI but needs root. Apple offers Bluetooth and Nearby Interaction logging profiles behind an Apple ID sign-in.

These commands are illustrative and reproduce config ID 1 timing:

# Pixel as a config ID 1 style controller (AOSP cmd uwb).
adb shell cmd uwb start-fira-ranging-session -i 305419896 -c 9 -p 11 \
  -t controller -r initiator -a 1 -d 2 -m unicast -u ds-twr \
  -l 240 -s 6 -h 2400 -y enabled -o static -v 0807 -g 010203040506
adb shell cmd uwb get-ranging-session-reports 305419896
adb shell cmd uwb stop-ranging-session 305419896

# QM35 board with the same profile (QM35 SDK, UWB-Qorvo-Tools).
run_fira_twr -p COM24 -t 600 -c 9 --preamble-idx 11 --round ds-deferred \
  --node unicast --sts static --vendor-id "[0x08, 0x07]" \
  --static-sts "[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]" \
  --ranging-span 240 --slot-span 2400 --slots-per-rr 6 \
  --hopping-mode enabled --stats --range_dump

Beyond ranging: car keys, finder tags and Channel Sounding

Three adjacent topics come up in every dual-ecosystem project:

  • Digital car keys are a different product class. Google Wallet car keys follow the CCC Digital Key specification. Asked about a do-it-yourself UWB key fob in August 2026, a Qorvo engineer replied that the encryption, authentication and key rotation “follow the CCC (Car Connectivity Consortium) specification” and that it “is not possible”.
  • Finder tags now use UWB plus Channel Sounding. Google announced its Pixel Tag in August 2026, using “Ultra-Wideband with Bluetooth Channel Sounding” for precision finding.
  • Apple and Android both add Channel Sounding. NINearbyAccessoryConfiguration now supports accessories using “either Ultra Wideband (UWB) or Bluetooth Channel Sounding technology”, and Android’s Ranging module covers Bluetooth Channel Sounding alongside UWB. For some accessories, Channel Sounding will be the lower-cost option; for precise direction and distance, UWB remains the reference.

From our engineering work

For an automotive OEM (anonymised), needCode delivered an iOS and Android mobile SDK on time, starting without a specification or hardware, with 396 unit tests and zero failures. For the same OEM, in a separate project, we cut Bluetooth LE connection handover failures at maximum load from 71% to 8%. Inside Qorvo’s UWB programme our engineers built the MAC layer to the FiRa specification, and they build Qorvo’s public QM35 SDK.

How needCode helps

We build dual-ecosystem UWB accessories on Qorvo silicon: the UWB session layer, the Apple Nearby Interaction and Android OOB front ends over Bluetooth LE, and the companion apps for iOS and Android. We set up the phone test matrix with automation and sniffer captures, so new iOS and Android releases are checked before your users install them.

Frequently asked questions

Can one UWB accessory work with both iPhone and Android?

Yes. Both ecosystems use FiRa-style unicast DS-TWR over IEEE 802.15.4z, so one UWB core can serve both. The accessory needs two out-of-band paths: Apple’s Nearby Interaction accessory protocol and an Android OOB, either Google’s published specification or your own protocol.

How does the Android 16 Ranging API work with third-party UWB accessories?

The app creates a session through RangingManager. With the default OOB, the Ranging module exchanges capability and configuration messages defined in Google’s OOB specification over a transport the app provides, such as Bluetooth LE. With a custom OOB, the app negotiates the parameters itself and passes them in UwbRangingParams.

Does Qorvo provide an Android SDK for DWM3001C accessories?

No. Qorvo staff wrote that “we don’t offer SDK for android” and, in July 2026, “for Android, unfortunately, you will have to develop your own app”. They recommend reusing the Bluetooth LE part of the QANI firmware as a starting point.

What UWB parameters does an iPhone use with accessories?

The iPhone decides. The accessory states a few preferences, such as its role and update rate, in its configuration data, and the iPhone returns the final configuration, including the channel. Qorvo engineers warn that changing that configuration on the accessory side usually breaks ranging.

Can an iPhone range with several accessories at once?

Yes. According to Qorvo staff, iOS supports only unicast sessions, so an iPhone connecting to several accessories opens several unicast sessions. Plan the accessory’s scheduling and power budget for more than one phone if your use case needs it.

Can a UWB accessory work with Google Wallet digital car keys?

Not as a generic accessory. Digital car keys follow the CCC Digital Key specification, including its encryption, authentication and key rotation, and are implemented by carmakers and phone makers. A finder tag or lock can use UWB ranging with phones, but it does not become a car key.

How large are the Android OOB messages an accessory must handle?

A version 3 Ranging Configuration with a static STS key is 34 bytes, 42 with a 16-byte provisioned key, and a UWB-only Capability Response is 27 bytes. Each must fit one write at the negotiated MTU, or use an LE L2CAP channel or your own fragmentation.

Can the accessory serve an iPhone and an Android phone at the same time?

Yes, as two FiRa sessions if firmware and radio time allow; a config ID 1 session at 240 ms takes about 5% of airtime. On the DWM3001C, QANI serves one phone by default and developers report stalls after extending it.

Sources

  1. Android Developers: Range between devices (Ranging module)
  2. Android Developers: RangingManager and UwbRangingParams
  3. Android: Ranging out-of-band specification version 3
  4. Android Developers: Ultra-wideband (UWB) communication (Jetpack) and Core UWB release notes
  5. Apple: Nearby Interaction framework and NINearbyAccessoryConfiguration
  6. Apple: Nearby Interaction with UWB (specifications for accessory and chipset makers)
  7. Qorvo Tech Forum: Nearby Interaction configuration exchange (staff, June 2025)
  8. Qorvo Tech Forum: QANI and multiple FiRa sessions (staff, December 2025)
  9. Qorvo Tech Forum: value of the iOS and Android configuration exchange (staff, June 2026)
  10. Qorvo Tech Forum: Android ranging with DWM3000 (staff, January 2026), no Android SDK (March 2025) and Qorvo Nearby Interactions for Android (July 2026)
  11. Qorvo Tech Forum: reusing the QANI Bluetooth LE code (staff, December 2025) and Qorvo Android app scope (April 2026)
  12. Qorvo Tech Forum: QM35825 and Apple Nearby Interaction (staff, September 2026)
  13. Qorvo QPK3000 product page and DWM3001CDK product page
  14. Qorvo Tech Forum: UWB and Google Wallet digital key (staff, August 2026) and Android: Digital Car Key
  15. Google: Pixel Tag announcement (August 2026)
  16. Android: Ranging out-of-band specification version 2 and version 1
  17. Google PDL: Android Ranging OOB message definitions
  18. Android Developers: OobInitiatorRangingConfig, TransportHandle.ReceiveCallback, RawRangingDevice, RangingSession.Callback, RangingData and RangingCapabilities
  19. Android Developers: androidx.core.uwb RangingParameters, UwbClientSessionScope and RangingResult
  20. AOSP: Ranging module UWB backend, ConfigurationManager.java and Utils.java
  21. AOSP: Ranging service OOB parser, UwbOobConfig.java and Ranging test app
  22. AOSP: UwbShellCommand.java (adb shell cmd uwb) and Shell AndroidManifest.xml
  23. AOSP: FiRa UCI packet definitions, uci_packets.pdl
  24. Android 16 Compatibility Definition Document, sections 7.3.13 and 7.4.9
  25. Android Developers: Android 14 behaviour changes, MTU set to 517 and BluetoothGatt
  26. Android: Verify and debug Bluetooth
  27. Apple: NISessionDelegate, NIError.Code, NIDeviceCapability and Initiating and maintaining a session
  28. Apple: updateMotionState(_:forObjectWithToken:) and NIMotionActivityState
  29. Apple: Implementing spatial interactions with third-party accessories (sample code)
  30. Apple: Accessory Design Guidelines for Apple Devices (September 2026), sections 58.6 to 58.11
  31. Apple: Profiles and Logs
  32. FiRa Consortium: Specifications
  33. Qorvo QM35 SDK: run_fira_twr, UWB Command Interface Specification, R12.34, July 2025, Cherry API and Integration Guide, July 2025 and cherry_fira.h
  34. Qorvo Tech Forum: reusing the QANI example for Android (staff, January 2025)
  35. Qorvo Tech Forum: QANI ranging without a Bluetooth connection, posts 2, 4, 6, 8 and 10 (staff answers and one user report, 2023 to 2024)
  36. Qorvo Tech Forum: Android ranging session and the QNI session (staff, May 2024)
  37. Qorvo Tech Forum: second iPhone on the QANI project (user report, December 2025)
  38. Qorvo Tech Forum: FiRa sessions that stop silently (user reports, July and September 2026)

About needCode

needCode is an independent embedded engineering company from Kraków, Poland, and an official UWB business partner of Qorvo. Its engineers have worked inside Qorvo’s wireless programmes for more than eight years, including on the public QM35 SDK. needCode is certified to ISO 9001:2015 and ISO/IEC 27001:2022 and is a member of the UWB Alliance. This article does not represent Qorvo. All trademarks belong to their respective owners.