JavaScript bridge API
Web Bluetooth
The navigator.bluetooth shape, answered natively so a page written for Chrome works unchanged.
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.