Created
January 16, 2026 09:28
-
-
Save EionRobb/090c35de8c54794e4b2433eea90f58d0 to your computer and use it in GitHub Desktop.
iBeacon scanner using WebBluetooth's requestLEScan API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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