Create Free APK

JavaScript bridge API

Web Bluetooth

The navigator.bluetooth shape, answered natively so a page written for Chrome works unchanged.

navigator.bluetooth web standard#

navigator.bluetooth — Web Bluetooth

Example

Standard Web Bluetooth: the same navigator.bluetooth code you would write for Chrome works in the installed app, for any Bluetooth Low Energy device.

Returns: Promises, as in the web standard. Values are DataViews. Needs: Bluetooth. There is no switch: when you build from a ZIP or an HTML page, the build turns it on by itself if your code mentions navigator.bluetooth (or BluetoothUUID). A website (URL) build cannot turn it on. Without it, navigator.bluetooth does not exist. The first requestDevice() asks for the Nearby devices permission (Location on Android 11 and older). If Bluetooth is switched off, the app asks the user to turn it on and then opens the device list by itself; on Android 6 to 11 it does the same for the Location setting, because those versions find no Bluetooth device while Location is off.

Heart-rate strap, from a button tap. navigator.bluetooth exists before your first script runs, so feature detection at the top of the page works; checking inside the handler, as here, is still the safest habit:

var hrDevice = null;

document.getElementById('connect').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    hrDevice = await navigator.bluetooth.requestDevice({
      filters: [{ services: ['heart_rate'] }],        // or { name: 'Polar H10' } / { namePrefix: 'Polar' }
      optionalServices: ['battery_service']
    });
    hrDevice.addEventListener('gattserverdisconnected', function () {
      showStatus('Strap disconnected');
    });
    var server = await hrDevice.gatt.connect();
    var hr = await server.getPrimaryService('heart_rate');
    var chr = await hr.getCharacteristic('heart_rate_measurement');
    chr.addEventListener('characteristicvaluechanged', function (e) {
      var v = e.target.value;                                   // DataView
      var bpm = (v.getUint8(0) & 1) ? v.getUint16(1, true) : v.getUint8(1);
      document.getElementById('bpm').textContent = bpm;
    });
    await chr.startNotifications();

    var battery = await server.getPrimaryService('battery_service');
    var level = await (await battery.getCharacteristic('battery_level')).readValue();
    document.getElementById('battery').textContent = level.getUint8(0) + '%';
  } catch (err) {
    // NotFoundError: user cancelled the chooser, Bluetooth is off, or no such service.
    // SecurityError: permission denied.  NetworkError: connection lost.
    showStatus(err.name + ': ' + err.message);
  }
});

document.getElementById('disconnect').addEventListener('click', function () {
  if (hrDevice && hrDevice.gatt.connected) hrDevice.gatt.disconnect();
});

Writing. writeValue and writeValueWithResponse wait for the device to confirm; writeValueWithoutResponse does not:

async function sendCommands(server) {
  // Heart Rate Control Point: 0x01 = reset energy expended
  var hr = await server.getPrimaryService('heart_rate');
  var ctrl = await hr.getCharacteristic('heart_rate_control_point');
  await ctrl.writeValueWithResponse(new Uint8Array([0x01]));

  // A custom UART-style service (full 128-bit UUIDs work everywhere)
  var uart = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');
  var rx = await uart.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e');
  await rx.writeValueWithoutResponse(new TextEncoder().encode('LED ON\n'));
}

Device explorer. Pick any nearby device and list every service and characteristic it has, with what each one can do:

document.getElementById('explore').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    var device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true });
    var server = await device.gatt.connect();
    var lines = [];
    for (var service of await server.getPrimaryServices()) {
      lines.push(service.uuid === BluetoothUUID.getService('battery_service') ? 'Battery' : service.uuid);
      var chars = await service.getCharacteristics().catch(function () { return []; });
      chars.forEach(function (c) {
        var can = Object.keys(c.properties).filter(function (k) { return c.properties[k]; });
        lines.push('  ' + c.uuid + '  ' + can.join(', '));
      });
    }
    document.getElementById('tree').textContent = lines.join('\n');
    device.gatt.disconnect();
  } catch (err) {
    showStatus(err.name + ': ' + err.message);
  }
});

Cycling speed and cadence sensor. One sensor (or a separate speed and cadence pair) on the standard CSC service. The values are running totals, so speed and cadence come from the difference between two notifications:

var WHEEL_MM = 2105;                       // 700x25c; set your own wheel circumference
var last = null;

async function connectCsc() {
  var device = await navigator.bluetooth.requestDevice({
    filters: [{ services: ['cycling_speed_and_cadence'] }]      // same as { services: [0x1816] }
  });
  device.addEventListener('gattserverdisconnected', function () { showStatus('Sensor disconnected'); });
  var server = await device.gatt.connect();
  var csc = await server.getPrimaryService('cycling_speed_and_cadence');
  var chr = await csc.getCharacteristic('csc_measurement');
  chr.addEventListener('characteristicvaluechanged', function (e) {
    var v = e.target.value, flags = v.getUint8(0), i = 1, now = {};
    if (flags & 1) { now.wheels = v.getUint32(i, true); now.wheelTime = v.getUint16(i + 4, true); i += 6; }
    if (flags & 2) { now.cranks = v.getUint16(i, true); now.crankTime = v.getUint16(i + 2, true); }
    if (last) {
      if (now.wheels !== undefined && now.wheelTime !== last.wheelTime) {
        var dt = ((now.wheelTime - last.wheelTime) & 0xffff) / 1024;          // seconds
        var kmh = (now.wheels - last.wheels) * WHEEL_MM / 1e6 / (dt / 3600);
        document.getElementById('speed').textContent = kmh.toFixed(1) + ' km/h';
      }
      if (now.cranks !== undefined && now.crankTime !== last.crankTime) {
        var ct = ((now.crankTime - last.crankTime) & 0xffff) / 1024;
        var rpm = ((now.cranks - last.cranks) & 0xffff) / ct * 60;
        document.getElementById('cadence').textContent = Math.round(rpm) + ' rpm';
      }
    }
    last = Object.assign({}, last, now);
  });
  await chr.startNotifications();
}

Showing every failure on screen. Each step rejects with a named error, so the page can tell the user what to do instead of staying silent:

async function connectWithMessage(connect) {
  try {
    await connect();
    showStatus('Connected');
  } catch (err) {
    var msg = {
      NotFoundError: 'No sensor chosen, Bluetooth was left off, or the sensor lacks that service.',
      SecurityError: 'Bluetooth permission was refused. Allow "Nearby devices" in the app settings.',
      NetworkError: 'The sensor went out of range or is connected to another app or watch. Try again.',
      TypeError: 'Programming error: ' + err.message
    }[err.name] || err.message;
    showStatus(err.name + ': ' + msg);
  }
}
document.getElementById('scan-hr').addEventListener('click', function () { connectWithMessage(connectHeartRate); });
document.getElementById('scan-csc').addEventListener('click', function () { connectWithMessage(connectCsc); });

Notes: The device chooser is drawn by the app and scans for 30 seconds, then offers "Scan again"; a page can only reach a device the user picked. getAvailability() answers whether the phone has Bluetooth at all (as in Chrome), not whether it is switched on: requestDevice() asks to turn it on. A sensor that sends its name only in its scan response is still listed by name and matches namePrefix. A first connection that Android drops at once is retried before connect() rejects. A misspelt service name in filters or optionalServices rejects with TypeError. A services filter matches only services the device advertises. After connect() every service the device has can be used, even ones not in optionalServices (keep them anyway, Chrome needs them). Also available: getAvailability(), getPrimaryServices(), getCharacteristics(), stopNotifications(), characteristic.properties, device.forget(), BluetoothUUID.getService/getCharacteristic/canonicalUUID. getDevices(), requestLEScan(), watchAdvertisements() and descriptors reject with NotSupportedError. For the standard fitness sensors with the numbers already parsed, see bleStartScan.

wbConnect bridge#

window.WebToApk.wbConnect(requestId: String, deviceId: String)

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind device.gatt.connect(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through gatt.connect()) a Promise of the BluetoothRemoteGATTServer, after the device's services are discovered. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

async function connectAndList(device) {
  try {
    var server = await device.gatt.connect();
    var services = await server.getPrimaryServices();
    services.forEach(function (s) { console.log('service', s.uuid); });
    return server;
  } catch (err) {
    showStatus('Could not connect: ' + err.message);    // NetworkError, NotFoundError
    return null;
  }
}

Reconnect when the device drops. Calling connect() again on the same device works:

device.addEventListener('gattserverdisconnected', async function () {
  showStatus('Reconnecting...');
  try { await connectAndList(device); showStatus('Connected'); } catch (e) {}
});

Connect to two sensors at the same time, for example a heart-rate strap and a power meter:

async function connectBoth(hrDevice, powerDevice) {
  var servers = await Promise.all([hrDevice.gatt.connect(), powerDevice.gatt.connect()]);
  var hr = await servers[0].getPrimaryService('heart_rate');
  var power = await servers[1].getPrimaryService('cycling_power');
  return {
    heartRate: await hr.getCharacteristic('heart_rate_measurement'),
    power: await power.getCharacteristic('cycling_power_measurement')
  };
}

Notes: Raw signature wbConnect(requestId, deviceId). Connecting to a device that is already connected resolves again at once. getPrimaryService(s) rejects with NetworkError before connect() has finished. Several devices can be connected at the same time.

wbDisconnect bridge#

window.WebToApk.wbDisconnect(deviceId: String)

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind device.gatt.disconnect() (and device.forget()). Pages use navigator.bluetooth; do not call this directly.

Returns: nothing (disconnect() is synchronous, as in the web standard). The device then fires gattserverdisconnected. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

var device = null;   // from navigator.bluetooth.requestDevice(...)

document.getElementById('stop').addEventListener('click', function () {
  if (device && device.gatt.connected) device.gatt.disconnect();
});

// Fires for your own disconnect() and when the device goes away by itself.
function watch(dev) {
  dev.addEventListener('gattserverdisconnected', function () {
    showStatus('Disconnected from ' + (dev.name || dev.id));
  });
}

Tell your own Stop apart from the sensor going out of range, and reconnect only in the second case:

var userStopped = false;

function stopSession(device) {
  userStopped = true;
  if (device.gatt.connected) device.gatt.disconnect();
}

function watchDrops(device) {
  device.addEventListener('gattserverdisconnected', function () {
    if (userStopped) { userStopped = false; showStatus('Session ended'); return; }
    showStatus('Sensor out of range, reconnecting...');
    device.gatt.connect()
      .then(resubscribe)                 // your code: get the characteristics, startNotifications()
      .catch(function () { showStatus('Could not reconnect'); });
  });
}

A "Remove sensor" button: forget() disconnects and drops the device, and no gattserverdisconnected follows it:

var device = null;   // from navigator.bluetooth.requestDevice(...)

document.getElementById('forget').addEventListener('click', async function () {
  if (!device) return;
  await device.forget();
  device = null;
  showStatus('Sensor removed. Tap Connect to pick one again.');
});

Notes: Raw signature wbDisconnect(deviceId). After a disconnect the old service and characteristic objects stop receiving notifications; after the next connect(), get them again and call startNotifications() again. The app drops every connection when it closes.

wbGetCharacteristics bridge#

window.WebToApk.wbGetCharacteristics(requestId: String, deviceId: String, service: String)

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind service.getCharacteristic(uuid) and service.getCharacteristics(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through the service) a Promise of one BluetoothRemoteGATTCharacteristic, or of an array of them, each with uuid and properties (read, write, writeWithoutResponse, notify, indicate, broadcast, authenticatedSignedWrites). Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

async function describe(server) {
  var service = await server.getPrimaryService('heart_rate');

  // One characteristic, by standard name, 16-bit number or full UUID:
  var measurement = await service.getCharacteristic('heart_rate_measurement'); // same as 0x2a37

  // All of them:
  var list = await service.getCharacteristics();
  list.forEach(function (c) {
    var p = c.properties;
    console.log(c.uuid, p.read ? 'read' : '', p.write ? 'write' : '', p.notify ? 'notify' : '');
  });
  return measurement;
}

Find the characteristics of a custom service by what they can do, instead of hard-coding their UUIDs:

async function findSerialPipes(server) {
  var svc = await server.getPrimaryService('0000ffe0-0000-1000-8000-00805f9b34fb');
  var list = await svc.getCharacteristics();
  var incoming = list.find(function (c) { return c.properties.notify || c.properties.indicate; });
  var outgoing = list.find(function (c) { return c.properties.write || c.properties.writeWithoutResponse; });
  if (!incoming || !outgoing) throw new Error('This device does not look like a serial adapter');
  return { incoming: incoming, outgoing: outgoing };
}

Read an optional characteristic and treat NotFoundError as "this device does not have it":

var LOCATIONS = ['Other', 'Chest', 'Wrist', 'Finger', 'Hand', 'Ear lobe', 'Foot'];

async function sensorLocation(server) {
  var hr = await server.getPrimaryService('heart_rate');
  try {
    var chr = await hr.getCharacteristic('body_sensor_location');   // 0x2a38, optional in the spec
    var v = await chr.readValue();
    return LOCATIONS[v.getUint8(0)] || 'Unknown';
  } catch (err) {
    if (err.name === 'NotFoundError') return null;                   // this strap does not report it
    throw err;
  }
}

Notes: Raw signature wbGetCharacteristics(requestId, deviceId, service). A UUID the service does not have rejects with NotFoundError; an unknown name throws TypeError (pass a full UUID for custom characteristics). The list is fetched once per service and then reused.

wbIsAvailable bridge#

window.WebToApk.wbIsAvailable(): Boolean

getAvailability(): whether the phone HAS Bluetooth (Chrome's answer), not whether it is switched on. requestDevice() asks the user to turn it on; answering false while it was merely off made pages that check first ("if (!await getAvailability())") say "Bluetooth not supported" and never reach that prompt (fitness report 2026-10-03).

Example

Internal transport behind navigator.bluetooth.getAvailability(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through getAvailability()) a Promise of true when the build has Bluetooth and the phone's Bluetooth is on, else false. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

async function updateBluetoothHint() {
  var hint = document.getElementById('bt-hint');
  if (!navigator.bluetooth) { hint.textContent = 'Bluetooth is not available in this app.'; return; }
  var on = await navigator.bluetooth.getAvailability();
  hint.textContent = on ? '' : 'Turn on Bluetooth to connect your sensor.';
}
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) updateBluetoothHint();
});

Enable the Connect button only while Bluetooth is on, checking again whenever the user comes back to the app:

async function refreshConnectButton() {
  var btn = document.getElementById('connect');
  if (!navigator.bluetooth) { btn.hidden = true; return; }
  btn.disabled = !(await navigator.bluetooth.getAvailability());
}
window.addEventListener('load', refreshConnectButton);
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) refreshConnectButton();
});

Give a clear "Bluetooth is off" message before opening the chooser, instead of a NotFoundError afterwards:

document.getElementById('scan').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  if (!(await navigator.bluetooth.getAvailability())) {
    showStatus('Bluetooth is off. Turn it on, then tap Scan again.');
    return;
  }
  try {
    var device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true });
    showStatus('Picked ' + (device.name || device.id));
  } catch (err) {
    showStatus(err.name === 'NotFoundError' ? 'No device chosen.' : err.message);
  }
});

Notes: Raw signature wbIsAvailable(): Boolean (synchronous). It checks the adapter only - it does not ask for any permission. The availabilitychanged event of the web standard is not fired, so check again when the page becomes visible.

wbNotify bridge#

window.WebToApk.wbNotify(

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind characteristic.startNotifications() and stopNotifications(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through the characteristic) a Promise of the characteristic itself. Each new value fires characteristicvaluechanged on it, with the value in event.target.value (a DataView). Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

var cadenceChr = null;

async function startCadence(server) {
  var svc = await server.getPrimaryService('running_speed_and_cadence');
  cadenceChr = await svc.getCharacteristic('rsc_measurement');
  cadenceChr.addEventListener('characteristicvaluechanged', onRsc);
  await cadenceChr.startNotifications();
}

function onRsc(e) {
  var v = e.target.value;
  var kmh = v.getUint16(1, true) / 256 * 3.6;
  var spm = v.getUint8(3);
  show('run', kmh.toFixed(1) + ' km/h, ' + spm + ' steps/min');
}

async function stopCadence() {
  if (!cadenceChr) return;
  cadenceChr.removeEventListener('characteristicvaluechanged', onRsc);
  try { await cadenceChr.stopNotifications(); } catch (e) {}
}

A thermometer whose measurement only supports indications is subscribed the same way, here through the oncharacteristicvaluechanged property:

async function watchThermometer(server) {
  var svc = await server.getPrimaryService('health_thermometer');
  var chr = await svc.getCharacteristic('temperature_measurement');
  chr.oncharacteristicvaluechanged = function (e) {
    var v = e.target.value;
    var mantissa = v.getUint32(1, true) & 0x00ffffff;          // IEEE-11073 FLOAT
    if (mantissa & 0x800000) mantissa -= 0x1000000;
    var fahrenheit = v.getUint8(0) & 1;
    show('temp', (mantissa * Math.pow(10, v.getInt8(4))).toFixed(1) + (fahrenheit ? ' °F' : ' °C'));
  };
  await chr.startNotifications();
}

Receive text lines from a serial-style device, joining notifications that split a line in the middle:

async function listenForLines(server, onLine) {
  var uart = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');
  var tx = await uart.getCharacteristic('6e400003-b5a3-f393-e0a9-e50e24dcca9e');
  if (!tx.properties.notify && !tx.properties.indicate) throw new Error('This device does not stream data');
  var decoder = new TextDecoder(), buffer = '';
  tx.addEventListener('characteristicvaluechanged', function (e) {
    buffer += decoder.decode(e.target.value, { stream: true });
    var lines = buffer.split('\n');
    buffer = lines.pop();
    lines.forEach(onLine);
  });
  await tx.startNotifications();
  return tx;
}

Notes: Raw signature wbNotify(requestId, deviceId, service, characteristic, enable). A characteristic that only supports indications is subscribed with indications automatically. A characteristic that cannot notify rejects with NotSupportedError. oncharacteristicvaluechanged = fn works too. After a disconnect, subscribe again.

wbRead bridge#

window.WebToApk.wbRead(requestId: String, deviceId: String, service: String, characteristic: String)

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind characteristic.readValue(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through readValue()) a Promise of a DataView; characteristic.value is updated too. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

async function readInfo(server) {
  try {
    var battery = await server.getPrimaryService('battery_service');
    var level = await (await battery.getCharacteristic('battery_level')).readValue();
    show('battery', level.getUint8(0) + '%');

    var info = await server.getPrimaryService('device_information');
    var maker = await (await info.getCharacteristic('manufacturer_name_string')).readValue();
    show('maker', new TextDecoder().decode(maker));
  } catch (err) {
    // NotFoundError: the device has no such service/characteristic
    // NotSupportedError: read rejected by the device   NetworkError: not connected / read failed
    show('info', err.message);
  }
}

Poll a sensor that can only be read, here the standard Temperature characteristic (0x2a6e, in hundredths of a degree):

var pollTimer = null;

async function startTemperaturePolling(server) {
  var env = await server.getPrimaryService('environmental_sensing');
  var temp = await env.getCharacteristic(0x2a6e);
  pollTimer = setInterval(async function () {
    try {
      var v = await temp.readValue();
      show('temp', (v.getInt16(0, true) / 100).toFixed(1) + ' °C');
    } catch (err) {
      clearInterval(pollTimer);          // NetworkError once the sensor is gone
      show('temp', err.message);
    }
  }, 5000);
}

Fill an "About this device" panel by starting several reads at once; they run one after another for you:

async function readDeviceInfo(server) {
  var info = await server.getPrimaryService('device_information');
  var names = ['model_number_string', 'serial_number_string', 'firmware_revision_string'];
  var decoder = new TextDecoder();
  var results = await Promise.allSettled(names.map(async function (name) {
    var chr = await info.getCharacteristic(name);
    return decoder.decode(await chr.readValue());
  }));
  results.forEach(function (r, i) {
    show(names[i], r.status === 'fulfilled' ? r.value : '-');   // missing ones reject with NotFoundError
  });
}

Notes: Raw signature wbRead(requestId, deviceId, service, characteristic). Reads, writes and notification changes on one device run one after another in the order you call them, so you can fire several without waiting in between.

wbRequestDevice bridge#

window.WebToApk.wbRequestDevice(requestId: String, optionsJson: String)

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind navigator.bluetooth.requestDevice(options): it shows the app's device chooser. Pages use navigator.bluetooth.requestDevice; do not call this directly.

Returns: (through requestDevice) a Promise of a BluetoothDevice {id, name, gatt}. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth) and the Nearby devices permission, asked on first use.

document.getElementById('pick').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    var device = await navigator.bluetooth.requestDevice({
      filters: [{ services: ['cycling_power'] }, { namePrefix: 'KICKR' }],
      optionalServices: ['battery_service']
    });
    showStatus('Picked ' + (device.name || device.id));
  } catch (err) {
    if (err.name === 'NotFoundError') showStatus('No device chosen.');   // also: Bluetooth off
    else showStatus(err.name + ': ' + err.message);                     // SecurityError = permission
  }
});

To list every nearby device instead of filtering: requestDevice({ acceptAllDevices: true }).

Pick a custom device by its exact name or name prefix, listing its own 128-bit service so it can be used after connecting:

async function pickLamp() {
  try {
    return await navigator.bluetooth.requestDevice({
      filters: [{ name: 'Desk Lamp' }, { namePrefix: 'LAMP-' }],   // either one matches
      optionalServices: ['0000ffe0-0000-1000-8000-00805f9b34fb']
    });
  } catch (err) {
    if (err.name === 'SecurityError') showStatus('Allow Nearby devices to find your lamp.');
    else if (err.name !== 'NotFoundError') showStatus(err.message);  // NotFoundError = cancelled
    return null;
  }
}

Ask for the device once per session and reuse it on later taps (getDevices() is not available, so keep the object you got):

var scale = null;

document.getElementById('weigh').addEventListener('click', async function () {
  if (!navigator.bluetooth) { showStatus('Bluetooth is not available in this app.'); return; }
  try {
    if (!scale) {
      scale = await navigator.bluetooth.requestDevice({
        filters: [{ services: ['weight_scale'], namePrefix: 'MI' }]   // both keys must match
      });
    }
    var server = await scale.gatt.connect();
    var svc = await server.getPrimaryService('weight_scale');
    var chr = await svc.getCharacteristic('weight_measurement');
    chr.addEventListener('characteristicvaluechanged', onWeight);
    await chr.startNotifications();
  } catch (err) {
    showStatus(err.name + ': ' + err.message);
  }
});

Notes: Raw signature wbRequestDevice(requestId, optionsJson); the answer comes back to the polyfill, not to the page. Call requestDevice from a tap. Filters use services (must be advertised), name (exact) and namePrefix; a filter matches when all its keys match, and the device matches when any filter matches. Neither filters nor acceptAllDevices → TypeError. Cancelling the chooser rejects with NotFoundError. The chooser scans for 30 seconds. device.id is the device's Bluetooth address.

wbWrite bridge#

window.WebToApk.wbWrite(

Raw transport for WebBluetoothPolyfill. A page never calls these directly - it calls navigator.bluetooth, exactly as it would in a browser.

Described by its group, Web Bluetooth (navigator.bluetooth), rather than on its own.

Example

Internal transport behind characteristic.writeValue(), writeValueWithResponse() and writeValueWithoutResponse(). Pages use navigator.bluetooth; do not call this directly.

Returns: (through the characteristic) a Promise of undefined. Needs: Bluetooth (turned on by a ZIP or HTML build when your code mentions navigator.bluetooth).

With response (writeValue is the same): resolves when the device confirms the write.

async function setTargetPower(server, watts) {
  var ftms = await server.getPrimaryService('fitness_machine');
  var cp = await ftms.getCharacteristic('00002ad9-0000-1000-8000-00805f9b34fb'); // FTMS control point
  await cp.writeValueWithResponse(new Uint8Array([0x00]));                       // request control
  var cmd = new DataView(new ArrayBuffer(3));
  cmd.setUint8(0, 0x05);                 // set target power
  cmd.setInt16(1, watts, true);
  await cp.writeValueWithResponse(cmd);
}

Without response: faster, for streams where a lost packet does not matter.

async function sendText(characteristic, text) {
  if (!characteristic.properties.writeWithoutResponse) throw new Error('This characteristic needs writeValue');
  await characteristic.writeValueWithoutResponse(new TextEncoder().encode(text));
}

Send a long text command in 20-byte pieces. The app does not ask the device for a larger packet size, so 20 bytes is what fits in one write:

async function sendLine(rx, text) {
  var bytes = new TextEncoder().encode(text + '\n');
  for (var i = 0; i < bytes.length; i += 20) {
    await rx.writeValueWithoutResponse(bytes.subarray(i, i + 20));
  }
}
// sendLine(rxCharacteristic, 'SET COLOR 255 120 0 BRIGHTNESS 80');

Notes: Raw signature wbWrite(requestId, deviceId, service, characteristic, valueBase64, withResponse). The value can be an ArrayBuffer, any typed array, a DataView or a plain array of bytes; anything else throws TypeError. Errors: NotSupportedError (write rejected), NetworkError (write failed or not connected), NotFoundError.

Generated from the app runtime and its example files on every docs build. Read it as Markdown · All families.

Checked against the shipped bridge on 2026-11-04.