Web Bluetooth API Integration on Website
Web Bluetooth API allows the browser to communicate directly with Bluetooth Low Energy (BLE) devices without a mobile app. User opens a web page, clicks "Connect", selects a device from the list — and the site starts reading sensor data, controlling a lamp, or sending commands to a trainer.
Support and Limitations
Supported: Chrome 70+, Edge 79+, Chrome Android 56+. Not available: Firefox (frozen), Safari/iOS (blocked by Apple policy).
HTTPS only. Only after user gesture — requestDevice() call must be inside a click handler. Web Workers not supported.
BLE Architecture
BLE device contains Services → Characteristics. For example, "Heart Rate" service (UUID 0x180D) contains "Heart Rate Measurement" characteristic (UUID 0x2A37). Standard service UUIDs are defined by Bluetooth SIG, custom ones are 128-bit manufacturer UUIDs.
Connecting to Device
interface BluetoothDevice {
name: string
gatt: BluetoothRemoteGATTServer
}
async function connectToDevice(serviceUUID: string): Promise<BluetoothRemoteGATTServer> {
const device = await navigator.bluetooth.requestDevice({
filters: [
{ services: [serviceUUID] },
// Or search by name:
// { namePrefix: 'Mi Band' },
],
optionalServices: [
'battery_service', // Standard UUID
'0x180A', // Device Information
],
})
console.log(`Connecting to: ${device.name}`)
device.addEventListener('gattserverdisconnected', () => {
console.log('Device disconnected')
// Attempt reconnect here
})
const server = await device.gatt!.connect()
return server
}
Reading Data: Heart Rate Monitor
class HeartRateMonitor {
private server: BluetoothRemoteGATTServer | null = null
private characteristic: BluetoothRemoteGATTCharacteristic | null = null
async connect() {
this.server = await connectToDevice('heart_rate')
const service = await this.server.getPrimaryService('heart_rate')
this.characteristic = await service.getCharacteristic('heart_rate_measurement')
// Subscribe to notifications (data arrives automatically)
await this.characteristic.startNotifications()
this.characteristic.addEventListener(
'characteristicvaluechanged',
this.handleHeartRateMeasurement.bind(this)
)
}
private handleHeartRateMeasurement(event: Event) {
const value = (event.target as BluetoothRemoteGATTCharacteristic).value!
const flags = value.getUint8(0)
let heartRate: number
if (flags & 0x01) {
// 16-bit format
heartRate = value.getUint16(1, true)
} else {
// 8-bit format
heartRate = value.getUint8(1)
}
// RR-intervals (distance between beats in ms) — optional
const rrIntervals: number[] = []
if (flags & 0x10) {
for (let i = 2; i + 1 < value.byteLength; i += 2) {
rrIntervals.push(value.getUint16(i, true) / 1024 * 1000)
}
}
console.log(`Heart Rate: ${heartRate} bpm, RR: [${rrIntervals.join(', ')}] ms`)
}
async disconnect() {
await this.characteristic?.stopNotifications()
this.server?.disconnect()
}
}
Writing Data: Smart Light Control
class SmartLightController {
private server: BluetoothRemoteGATTServer | null = null
private controlChar: BluetoothRemoteGATTCharacteristic | null = null
// Custom service UUID (example: Govee H6003)
private readonly SERVICE_UUID = '00010203-0405-0607-0809-0a0b0c0d1910'
private readonly CONTROL_UUID = '00010203-0405-0607-0809-0a0b0c0d2b11'
async connect() {
this.server = await connectToDevice(this.SERVICE_UUID)
const service = await this.server.getPrimaryService(this.SERVICE_UUID)
this.controlChar = await service.getCharacteristic(this.CONTROL_UUID)
}
async setColor(r: number, g: number, b: number) {
// Device-specific protocol — read from documentation or reverse engineer
const command = new Uint8Array([
0x33, 0x05, 0x02, // Color command header
r, g, b, // RGB
0x00, 0x00, 0x00, // Padding
r ^ g ^ b, // XOR checksum
])
await this.controlChar!.writeValueWithResponse(command)
}
async setBrightness(level: number) {
// level: 0–100
const command = new Uint8Array([
0x33, 0x04,
Math.round(level * 2.55),
0x00,
])
await this.controlChar!.writeValueWithoutResponse(command)
// writeValueWithoutResponse — faster, no confirmation
}
async readBatteryLevel(): Promise<number> {
const service = await this.server!.getPrimaryService('battery_service')
const char = await service.getCharacteristic('battery_level')
const value = await char.readValue()
return value.getUint8(0) // 0–100%
}
}
React Hook
function useBluetooth() {
const [device, setDevice] = useState<BluetoothRemoteGATTServer | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [isSupported] = useState(() => 'bluetooth' in navigator)
const [error, setError] = useState<string | null>(null)
const connect = useCallback(async (serviceUUID: string) => {
try {
setError(null)
const server = await connectToDevice(serviceUUID)
setDevice(server)
setIsConnected(true)
} catch (err) {
if ((err as Error).name === 'NotFoundError') {
setError('Device not selected')
} else {
setError((err as Error).message)
}
}
}, [])
const disconnect = useCallback(() => {
device?.disconnect()
setDevice(null)
setIsConnected(false)
}, [device])
return { device, isConnected, isSupported, error, connect, disconnect }
}
Reconnect After Disconnect
device.addEventListener('gattserverdisconnected', async () => {
let retries = 0
while (retries < 3) {
await new Promise((r) => setTimeout(r, 1000 * (retries + 1)))
try {
await device.gatt!.connect()
await resubscribeCharacteristics()
console.log('Reconnected')
return
} catch {
retries++
}
}
console.error('Failed to reconnect')
})
What We Do
Study specific BLE device documentation or reverse-engineer protocol (Wireshark + BLE sniffer if no docs). Implement connection, reading characteristics via notifications or polling, writing commands. Add reconnect, error handling, connection status UI.
Timeline: integration with documented BLE device — 3–5 days. Device without documentation (protocol reverse-engineering) — 7–12 days.







