Skip to content

Instantly share code, notes, and snippets.

@ChristopherHButler
Created October 18, 2020 13:26
Show Gist options
  • Save ChristopherHButler/9223b2ec51ed9360f09c71a2e7dc8805 to your computer and use it in GitHub Desktop.
Save ChristopherHButler/9223b2ec51ed9360f09c71a2e7dc8805 to your computer and use it in GitHub Desktop.
AirPods Scanner

AirPods Scanner

This Gist was generated by Contrived.

Do not modify the metadata file if you want to open in Contrived again. Otherwise, it is safe to delete.

Happy Hacking!

{"user":"5f0c542a4a2ce5e528e01fdf","templateVersion":"1","templateId":"vanillajs","resources":["<meta charset=\"UTF-8\" />","<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">","<link href=\"https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css\" rel=\"stylesheet\" />"],"dependencies":[],"files":[{"id":1,"parentId":0,"name":"src","path":"/src","type":"folder","isRoot":true,"selected":false,"expanded":true,"children":[{"id":3,"name":"index.js"},{"id":4,"name":"styles.css"}]},{"id":2,"parentId":0,"name":"index.html","path":"/index.html","type":"file","mimeType":"html","isRoot":true,"open":true,"selected":true,"content":"<div class=\"flex flex-col max-w-xl m-auto\">\n <h1 class=\"text-3xl\">AirPods WebBluetooth Demo</h1>\n <aside>\n <a\n class=\"text-blue-600\"\n href=\"https://codesandbox.io/s/airpods-webbluetooth-demo-c4jfh\"\n >Source</a\n >\n </aside>\n <div class=\"my-4\">\n Example usage of the\n <a\n class=\"text-blue-600\"\n href=\"https://webbluetoothcg.github.io/web-bluetooth/scanning.html\"\n >WebBluetooth Scanning API</a\n >; using <code class=\"tracking-tight\">requestLEScan</code> to listen for\n Apple AirPods Manufacture Data. <br /><br />\n <strong\n >If the AirPods are not paird with this device the battery percentage\n is only report in 10% increments.</strong\n >\n\n <blockquote class=\"bg-green-200 p-4 mt-4 -mx-4\">\n <h4 class=\"font-bold\">Note:</h4>\n Scanning is still under development. You must be using Chrome 79+ with\n the\n <code class=\"tracking-tighter\">\n chrome://flags/#enable-experimental-web-platform-features\n </code>\n flag enabled. <br />\n <a href=\"chrome://flags/\">open chrome flags: chrome://flags/</a><br />\n Bluetooth scanning is currently (as of 25.04.2020) only supported on\n Android and Mac see\n <a\n class=\"text-blue-500\"\n href=\"https://bugs.chromium.org/p/chromium/issues/detail?id=897312&desc=2\"\n >Chrome issue</a\n >\n </blockquote>\n </div>\n <div class=\"hidden\" id=\"🎧\">\n <div\n class=\"rounded flex-grow border-solid border-4 border-gray-600 m-1\"\n >\n <div class=\"px-6 py-2\">\n <div class=\"font-bold text-xl text-center mb-2\">\n <span id=\"name\"></span>\n <span class=\"invisible\" id=\"airpods-connected\">🔊</span>\n </div>\n </div>\n </div>\n <div class=\"flex flex-grow\">\n <div\n class=\"rounded flex-grow border-solid border-4 border-gray-600 m-1\"\n >\n <div class=\"px-6 py-2\">\n <div class=\"font-bold text-xl text-center mb-2\">Left</div>\n <div class=\"text-gray-700 text-base text-center\">\n <div class=\"inline-block w-13\">\n <span id=\"left-charging\" class=\"invisible\">⚡</span>\n <span id=\"left-battery\">-</span>\n <span>%</span>\n </div>\n </div>\n </div>\n </div>\n <div\n class=\"rounded flex-grow border-solid border-4 border-gray-600 m-1\"\n >\n <div class=\"px-6 py-2\">\n <div class=\"font-bold text-xl text-center mb-2\">Right</div>\n <div class=\"text-gray-700 text-base text-center\">\n <div class=\"inline-block w-13\">\n <span id=\"right-charging\" class=\"invisible\">⚡</span>\n <span id=\"right-battery\">-</span>\n <span>%</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div\n class=\"rounded flex-grow border-solid border-4 border-gray-600 m-1\"\n >\n <div class=\"px-6 py-2\">\n <div class=\"font-bold text-xl text-center mb-2\">Case</div>\n <div class=\"text-gray-700 text-base text-center\">\n <div class=\"inline-block w-13\">\n <span id=\"case-charging\" class=\"invisible\">⚡</span>\n <span id=\"case-battery\">-</span>\n <span>%</span>\n <span id=\"case-lidOpen\"> (Open)</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div class=\"hidden font-bold text-center text-green-700\" id=\"connecting\">\n Please allow the scaning request and open your AirPods next to this\n device.\n </div>\n <div class=\"mb-3 hidden font-bold text-center text-red-700\" id=\"error\">\n Filled by JS\n </div>\n <button\n id=\"scan\"\n class=\"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded\"\n >\n Start Scanning for AirPods\n </button>\n </div>"},{"id":3,"parentId":1,"name":"index.js","path":"/src/index.js","type":"file","mimeType":"js","isRoot":false,"open":true,"selected":false,"isEntry":true,"content":"class AirPodsBatteryScanner {\n constructor() {\n this.listener = new Set();\n this.deviceMap = {\n \"8194\": \"AirPods\",\n \"8195\": \"Powerbeats\",\n \"8197\": \"BeatsX\",\n \"8198\": \"Beats Solo³\",\n \"8199\": \"Beats Studio³\",\n \"8201\": \"Beats Studio³\",\n \"8203\": \"Powerbeats Pro\",\n \"8204\": \"Beats Solo Pro\",\n \"8205\": \"Powerbeats\",\n \"8206\": \"AirPods Pro\",\n \"8207\": \"AirPods (2nd gen.)\"\n };\n }\n\n // Musst be called from a user interaction\n async startScanning() {\n let options = {\n // Filter isn't working in chrome yet\n /*filters: [\n {\n manufacturerData: {\n 0x004c: {\n dataPrefix: new Uint8Array([0x07])\n }\n }\n }\n ],*/\n keepRepeatedDevices: true,\n acceptAllAdvertisements: true\n };\n\n const scan = await navigator.bluetooth.requestLEScan(options);\n if (scan.active) {\n navigator.bluetooth.addEventListener(\"advertisementreceived\", event => {\n // Filter for APPL AirPod data...\n // chrome has no support for it in the request yet.\n const applData = event.manufacturerData.get(0x004c); // APPL Key;\n if (applData) {\n const data = new Uint8Array(applData.buffer);\n if (data[0] === 0x07 && data[1] === 0x19) {\n const deviceId = \"\" + ((data[4] << 8) + data[3]);\n const dataSpec = data[5];\n\n const leftFirst = (dataSpec & 0b00100000) !== 0;\n const hasCaseData = (dataSpec & 0b00010000) !== 0;\n const hasOther = (dataSpec & 0b00000001) !== 0;\n const connected = (dataSpec & 0b00000010) !== 0;\n const lidOpen = (data[8] & 0b00001000) === 0;\n const lidOpenCounter = data[8] & 0b00000111;\n\n // data[21] && data[22] where always 0 on decrypted deviced.\n // No idea if this is always true\n const decrypted = data[21] === 0 && data[22] === 0;\n\n let status = {\n device: {\n id: deviceId,\n name: this._getDeviceName(deviceId),\n decrypted,\n connected\n }\n };\n\n let firstCharging, firstBattery;\n let secondCharging, secondBattery;\n let batteryCaseCharging, batteryCase;\n\n // if the AirPods where paird\n if (decrypted) {\n firstCharging = (data[12] & 0b10000000) !== 0;\n firstBattery = data[12] & 0b01111111;\n\n secondCharging = (data[13] & 0b10000000) !== 0;\n secondBattery = data[13] & 0b01111111;\n\n batteryCaseCharging = (data[14] & 0b10000000) !== 0;\n batteryCase = data[14] & 0b01111111;\n // Not paird only low resolution data avaiable\n } else {\n firstCharging = (data[7] & 0b00010000) !== 0;\n firstBattery = ((data[6] >> 4) & 0xf) * 10;\n\n secondCharging = (data[7] & 0b00100000) !== 0;\n secondBattery = (data[6] & 0xf) * 10;\n\n batteryCaseCharging = (data[7] & 0b01000000) !== 0;\n batteryCase = (data[7] & 0xf) * 10;\n }\n\n if (leftFirst) {\n status.left = {\n charging: firstCharging,\n battery: firstBattery\n };\n if (hasOther) {\n status.right = {\n charging: secondCharging,\n battery: secondBattery\n };\n }\n } else {\n status.right = {\n charging: firstCharging,\n battery: firstBattery\n };\n if (hasOther) {\n status.left = {\n charging: secondCharging,\n battery: secondBattery\n };\n }\n }\n if (hasCaseData) {\n status.case = {\n charging: batteryCaseCharging,\n battery: batteryCase,\n lidOpen,\n lidOpenCounter\n };\n }\n\n this._fireStatusEvent(status);\n }\n }\n });\n }\n }\n\n onBatteryChange(callback) {\n this.listener.add(callback);\n }\n\n removeBatteryChange(callback) {\n this.listener.delete(callback);\n }\n\n _fireStatusEvent(event) {\n for (let callback of this.listener) {\n callback(event);\n }\n }\n _getDeviceName(deviceId) {\n return this.deviceMap[deviceId] || `Unkown (${deviceId})`;\n }\n}\n\nconst toHex = valueDataView => {\n return [...new Uint8Array(valueDataView.buffer)]\n .map(b => {\n return b.toString(16).padStart(2, \"0\");\n })\n .join(\" \");\n};\n\nconst toBin = valueDataView => {\n return [...new Uint8Array(valueDataView.buffer)]\n .map(b => {\n return b.toString(2).padStart(8, \"0\");\n })\n .join(\" \");\n};\n\nconst airPods = new AirPodsBatteryScanner();\n\nconst $scanBtn = document.querySelector(\"#scan\");\nconst $name = document.querySelector(\"#name\");\nconst $airpodsStatus = document.querySelector(\"#🎧\");\nconst $airpodsConected = document.querySelector(\"#airpods-connected\");\nconst $connecting = document.querySelector(\"#connecting\");\nconst $error = document.querySelector(\"#error\");\nconst leftSetter = createValueSetters(\"left\");\nconst rightSetter = createValueSetters(\"right\");\nconst caseSetter = createValueSetters(\"case\", name => {\n const $lidOpen = document.querySelector(`#${name}-lidOpen`);\n return value => {\n if (value) {\n $lidOpen.textContent = value.lidOpen ? \"🔓\" : \"🔒\";\n }\n };\n});\n\n$scanBtn.addEventListener(\"click\", onButtonClick);\n\nasync function onButtonClick() {\n airPods.onBatteryChange(status => {\n $airpodsStatus.classList.remove(\"hidden\");\n $connecting.classList.add(\"hidden\");\n\n $name.textContent = status.device.name;\n $airpodsConected.style.visibility = status.device.connected\n ? \"visible\"\n : \"hidden\";\n\n leftSetter(status.left);\n rightSetter(status.right);\n caseSetter(status.case);\n });\n try {\n $connecting.classList.remove(\"hidden\");\n $error.classList.add(\"hidden\");\n $scanBtn.classList.add(\"hidden\");\n await airPods.startScanning();\n } catch (e) {\n $connecting.classList.add(\"hidden\");\n $scanBtn.classList.remove(\"hidden\");\n $error.classList.remove(\"hidden\");\n let message = e.message;\n if (message.includes(\"blocked by user\")) {\n message +=\n \"<br /> You can re-enable request for BLE Scanning by clicking the lock icon next to the URL in the address bar.\";\n }\n $error.innerHTML = message;\n }\n}\n\nfunction createValueSetters(name, setup) {\n const $battery = document.querySelector(`#${name}-battery`);\n const $charging = document.querySelector(`#${name}-charging`);\n const addition = setup && setup(name);\n return function(value) {\n if (value) {\n $battery.textContent = value.battery;\n $charging.style.visibility = value.charging ? \"visible\" : \"hidden\";\n }\n addition && addition(value);\n };\n}\n"},{"id":4,"parentId":1,"name":"styles.css","path":"/src/styles.css","type":"file","mimeType":"css","isRoot":false,"open":false,"selected":false,"content":"body {\n font-family: sans-serif;\n}\n"}],"experimentId":"5f7df5a9f4bb6c001798e54b"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment