Skip to content

Instantly share code, notes, and snippets.

@EionRobb
Created January 16, 2026 09:28
Show Gist options
  • Select an option

  • Save EionRobb/090c35de8c54794e4b2433eea90f58d0 to your computer and use it in GitHub Desktop.

Select an option

Save EionRobb/090c35de8c54794e4b2433eea90f58d0 to your computer and use it in GitHub Desktop.
iBeacon scanner using WebBluetooth's requestLEScan API
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>iBeacon Scanner (Web Bluetooth)</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;max-width:900px;margin:24px}
button{margin-right:8px}
table{border-collapse:collapse;width:100%;margin-top:12px}
th,td{border:1px solid #ddd;padding:6px;text-align:left}
th{background:#f2f2f2}
.small{font-size:0.9em;color:#555}
pre{background:#f8f8f8;padding:8px}
</style>
</head>
<body>
<h1>iBeacon Scanner</h1>
<p class="small">Scans nearby BLE advertisements and lists iBeacons.</p>
<div>
<button id="start">Start scan</button>
<button id="stop" disabled>Stop scan</button>
<span id="status" class="small">Idle</span>
</div>
<table id="results">
<thead>
<tr><th>Name</th><th>Distance</th><th>UUID</th><th>Major</th><th>Minor</th><th>RSSI</th><th>Tx</th><th>Last seen</th></tr>
</thead>
<tbody></tbody>
</table>
<h3>Notes</h3>
<ul>
<li>Web Bluetooth advertisement scanning requires a compatible browser (Chrome) and may require enabling experimental features: <code>chrome://flags/#enable-experimental-web-platform-features</code>.</li>
<li>Page must be served over HTTPS or from <code>http://localhost</code>.</li>
<li>Some platforms limit BLE scanning from web pages.</li>
</ul>
<script>
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const statusEl = document.getElementById('status');
const tbody = document.querySelector('#results tbody');
let scan = null;
const seen = new Map(); // key -> {uuid,major,minor,rssi,tx,timestamp}
function formatUUID(bytes) {
// bytes: Uint8Array length 16
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
return [hex.slice(0,8),hex.slice(8,12),hex.slice(12,16),hex.slice(16,20),hex.slice(20)].join('-');
}
function updateTable() {
tbody.innerHTML = '';
for (const [key, v] of seen.entries()) {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${v.name ?? ''}</td><td>${calculateDistance(v.tx, v.rssi) ?? ''}</td><td>${v.uuid}</td><td>${v.major}</td><td>${v.minor}</td><td>${v.rssi ?? ''}</td><td>${v.tx ?? ''}</td><td>${new Date(v.ts).toLocaleTimeString()}</td>`;
tbody.appendChild(tr);
}
}
// https://stackoverflow.com/a/20434019/895744
function calculateDistance(txPower, rssi) {
const ratio = rssi / txPower;
if (ratio < 1) {
// See https://davidgyoungtech.com/2020/05/15/how-far-can-you-go
//return Math.pow(10, (txPower - rssi) / (10 * 2));
return Math.pow(ratio, 10);
}
return 0.89976 * Math.pow(ratio, 7.7095) + 0.111;
}
function handleAdvertisement(event) {
try {
// manufacturerData is a Map of companyIdentifier -> DataView
for (const companyId of event.manufacturerData.keys()) {
const view = event.manufacturerData.get(companyId);
if (!view) continue;
// iBeacon uses Apple's company id 0x004C (76)
if (companyId !== 0x004C) continue;
const bytes = new Uint8Array(view.buffer);
// iBeacon payload starts with 0x02 0x15
if (bytes.length < 23) continue;
if (bytes[0] !== 0x02 || bytes[1] !== 0x15) continue;
const uuidBytes = bytes.slice(2,18);
const uuid = formatUUID(uuidBytes).toLowerCase();
const major = (bytes[18] << 8) | bytes[19];
const minor = (bytes[20] << 8) | bytes[21];
// tx power is signed int8
let tx = bytes[22];
if (tx & 0x80) tx = tx - 0x100;
const key = `${uuid}_${major}_${minor}`;
seen.set(key, {uuid, major, minor, rssi: event.rssi, tx, ts: Date.now(), name: event.device?.name});
updateTable();
}
} catch (err) {
console.error('Error parsing advertisement', err);
}
}
async function startScan() {
if (!('bluetooth' in navigator)) {
statusEl.textContent = 'Web Bluetooth not supported in this browser.';
return;
}
if (!('requestLEScan' in navigator.bluetooth)) {
statusEl.textContent = 'Advertisement scanning not available. Try Chrome with experimental features enabled.';
return;
}
try {
statusEl.textContent = 'Requesting scan permission...';
// Try to use a manufacturerData filter for Apple iBeacon prefix (0x02 0x15)
const options = {
acceptAllAdvertisements: true,
keepRepeatedDevices: true,
// Some browsers support manufacturerData filters; we'll still accept all and filter in JS
// manufacturerData: [{companyIdentifier: 0x004C, dataPrefix: new Uint8Array([0x02,0x15]).buffer}]
};
scan = await navigator.bluetooth.requestLEScan(options);
navigator.bluetooth.addEventListener('advertisementreceived', handleAdvertisement);
startBtn.disabled = true;
stopBtn.disabled = false;
statusEl.textContent = 'Scanning (listening for iBeacons)...';
} catch (err) {
console.error(err);
statusEl.textContent = 'Scan failed: ' + (err && err.message ? err.message : String(err));
}
}
function stopScan() {
if (scan && scan.active) {
try { scan.stop(); } catch(e){}
}
try { navigator.bluetooth.removeEventListener('advertisementreceived', handleAdvertisement); } catch(e){}
scan = null;
startBtn.disabled = false;
stopBtn.disabled = true;
statusEl.textContent = 'Stopped';
}
startBtn.addEventListener('click', startScan);
stopBtn.addEventListener('click', stopScan);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment