Web Bluetooth LEGO Controller: Complete JavaScript Tutorial with Code
Control your LEGO MINDSTORMS, SPIKE Prime, or Powered UP sets directly from a web browser – no apps to install, no native code required. This comprehensive tutorial teaches you to build real web applications using the Web Bluetooth API and LEGO Wireless Protocol, with complete working code examples.
Table of Contents
- What is Web Bluetooth?
- Browser & Hub Compatibility
- Initial Setup & Connection
- LEGO Wireless Protocol Basics
- Motor Control Examples
- Reading Sensor Data
- Building a Control Interface
- Using node-poweredup Library
- Troubleshooting
What is Web Bluetooth?
Web Bluetooth is a JavaScript API that enables websites to communicate with Bluetooth Low Energy (BLE) devices. Combined with LEGO's open wireless protocol, it unlocks powerful capabilities:
- ✅ No Installation: Run LEGO controllers directly in a browser
- ✅ Cross-Platform: Works on Windows, Mac, Linux, Android, ChromeOS
- ✅ Custom UIs: Build intuitive control panels with HTML/CSS/JavaScript
- ✅ Real-Time: Low-latency communication (~50-100ms)
- ✅ Educational: Teach web development + robotics simultaneously
- ✅ Demos & Kiosks: Create interactive product demonstrations
Browser & Hub Compatibility
Supported Browsers
| Browser | Desktop | Mobile | Notes |
|---|---|---|---|
| Chrome | ✅ Yes | ✅ Android | Best support, recommended |
| Edge | ✅ Yes | ✅ Android | Chromium-based, full support |
| Opera | ✅ Yes | ✅ Android | Chromium-based, full support |
| Safari | ❌ No | ❌ iOS | No Web Bluetooth support |
| Firefox | ⚠️ Flag | ❌ No | Experimental, disabled by default |
Compatible LEGO Hubs
- MINDSTORMS Robot Inventor (51515) - Technic Hub - Full Support
- SPIKE Prime (45678) - Technic Hub - Full Support
- SPIKE Essential (45345) - Small Hub - Full Support
- Powered UP Hub (88016) - Full Support
- Boost Move Hub (17101) - Full Support
- Technic Control+ Hub (88012) - Full Support
- Duplo Train Base (28743) - Basic Support
Initial Setup & Connection
Step 1: Feature Detection
Always check if Web Bluetooth is available before attempting to connect:
// Check if Web Bluetooth is supported if (!navigator.bluetooth) { alert('Web Bluetooth is not supported in this browser. Try Chrome or Edge.'); throw new Error('Web Bluetooth not supported'); }
console.log('Web Bluetooth is supported!');
Step 2: Request Bluetooth Device
Request access to LEGO hubs by filtering for the LEGO Wireless Protocol service UUID:
// LEGO Wireless Protocol service UUID const LEGO_SERVICE_UUID = '00001623-1212-efde-1623-785feabcd123';
async function connectToLEGOHub() { try { console.log('Requesting LEGO hub...');
// Request device with LEGO service filter const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [LEGO_SERVICE_UUID] }], optionalServices: [LEGO_SERVICE_UUID] });
console.log('Device selected:', device.name);
// Connect to GATT server const server = await device.gatt.connect(); console.log('Connected to GATT server');
// Get LEGO service const service = await server.getPrimaryService(LEGO_SERVICE_UUID); console.log('LEGO service found');
// Get characteristic for communication const characteristic = await service.getCharacteristic( '00001624-1212-efde-1623-785feabcd123' // LEGO characteristic UUID );
console.log('Ready to communicate!'); return { device, server, characteristic };
} catch (error) { console.error('Connection failed:', error); throw error; } }
Step 3: HTML Button to Trigger Connection
<!DOCTYPE html> <html> <head> <title>LEGO Web Bluetooth Controller</title> </head> <body> <h1>LEGO Hub Controller</h1> <button id="connectBtn">Connect to LEGO Hub</button> <div id="status">Not connected</div>
<script> document.getElementById('connectBtn').addEventListener('click', async () => { const connection = await connectToLEGOHub(); document.getElementById('status').textContent = 'Connected!'; }); </script> </body> </html>
LEGO Wireless Protocol Basics
LEGO hubs use a binary protocol called the LEGO Wireless Protocol (LWP). Commands are sent as byte arrays:
Message Structure
// Basic message format: // [Length, HubID, MessageType, ...Payload]
// Example: Turn on LED to blue const message = new Uint8Array([ 0x08, // Length (8 bytes) 0x00, // Hub ID 0x81, // Port Output Command 0x32, // Port 50 (RGB LED) 0x11, // Start Power (Mode 1, Exec Immediately) 0x51, // Sub-command: Set RGB 0x01, // Mode: Direct RGB 0x00, 0x00, 0xFF // RGB: Blue (R=0, G=0, B=255) ]);
// Send command to hub await characteristic.writeValue(message);
Common Message Types
| Message Type | Hex | Purpose |
|---|---|---|
| Hub Properties | 0x01 | Get hub name, battery, etc. |
| Hub Actions | 0x02 | Shutdown, disconnect, etc. |
| Port Information | 0x43 | Query port capabilities |
| Port Value | 0x45 | Receive sensor data |
| Port Output Command | 0x81 | Control motors/LEDs |
Motor Control Examples
Example 1: Run Motor at Speed
/** * Run a motor at specified speed * @param {BluetoothRemoteGATTCharacteristic} characteristic * @param {number} port - Motor port (0-5 for ports A-F) * @param {number} speed - Speed (-100 to 100) */ async function runMotor(characteristic, port, speed) { // Clamp speed to valid range speed = Math.max(-100, Math.min(100, speed));
// Convert to signed byte const speedByte = speed < 0 ? 256 + speed : speed;
const message = new Uint8Array([ 0x0A, // Length 0x00, // Hub ID 0x81, // Port Output Command port, // Port number (0=A, 1=B, etc.) 0x11, // Start Power 0x01, // Mode: Speed speedByte // Speed value ]);
await characteristic.writeValue(message); console.log(`Motor on port ${port} running at ${speed}%`); }
// Usage: await runMotor(characteristic, 0, 50); // Port A forward at 50% await runMotor(characteristic, 1, -75); // Port B backward at 75%
Example 2: Run Motor for Degrees
/** * Run motor for specific number of degrees * @param {BluetoothRemoteGATTCharacteristic} characteristic * @param {number} port - Motor port * @param {number} degrees - Degrees to rotate * @param {number} speed - Speed (0-100) */ async function runMotorForDegrees(characteristic, port, degrees, speed = 50) { // Convert degrees to little-endian 32-bit integer const degreesBytes = new Uint8Array(4); new DataView(degreesBytes.buffer).setInt32(0, degrees, true);
const message = new Uint8Array([ 0x0E, // Length 0x00, // Hub ID 0x81, // Port Output Command port, 0x11, // Start Power 0x0B, // Mode: GoToAbsolutePosition ...degreesBytes, // Degrees (4 bytes, little-endian) speed, // Speed 0x64, // Max power (100%) 0x7F // End state: Hold ]);
await characteristic.writeValue(message); console.log(`Motor ${port} rotating ${degrees} degrees`); }
// Usage: await runMotorForDegrees(characteristic, 0, 360, 75); // 1 full rotation
Example 3: Stop Motor
async function stopMotor(characteristic, port) { const message = new Uint8Array([ 0x07, // Length 0x00, // Hub ID 0x81, // Port Output Command port, 0x11, // Start Power 0x00 // Speed: 0 (stop) ]);
await characteristic.writeValue(message); console.log(`Motor ${port} stopped`); }
Example 4: Set Hub LED Color
/** * Set the hub's RGB LED color * @param {number} r - Red (0-255) * @param {number} g - Green (0-255) * @param {number} b - Blue (0-255) */ async function setHubLED(characteristic, r, g, b) { const message = new Uint8Array([ 0x08, // Length 0x00, // Hub ID 0x81, // Port Output Command 0x32, // Port 50 (RGB LED on hub) 0x11, // Start Power 0x51, // Sub-command: Set RGB 0x00, // Mode r, g, b // RGB values ]);
await characteristic.writeValue(message); }
// Usage: await setHubLED(characteristic, 255, 0, 0); // Red await setHubLED(characteristic, 0, 255, 0); // Green await setHubLED(characteristic, 0, 0, 255); // Blue await setHubLED(characteristic, 255, 255, 0); // Yellow
Reading Sensor Data
Example 5: Subscribe to Port Value Updates
/** * Enable notifications from a sensor port * @param {BluetoothRemoteGATTCharacteristic} characteristic * @param {number} port - Sensor port * @param {number} mode - Sensor mode (0 = color, 1 = distance, etc.) */ async function subscribeSensor(characteristic, port, mode = 0) { // Enable notifications await characteristic.startNotifications();
// Listen for port value updates characteristic.addEventListener('characteristicvaluechanged', (event) => { const value = event.target.value; const bytes = new Uint8Array(value.buffer);
// Check if this is a port value message (0x45) if (bytes[2] === 0x45 && bytes[3] === port) { // Parse sensor value (depends on sensor type) const sensorValue = bytes[4]; console.log(`Port ${port} value:`, sensorValue);
// Trigger custom event with sensor data document.dispatchEvent(new CustomEvent('sensorData', { detail: { port, value: sensorValue } })); } });
// Send port input format setup const message = new Uint8Array([ 0x0A, // Length 0x00, // Hub ID 0x41, // Port Input Format Setup (Single) port, mode, // Mode 0x01, 0x00, 0x00, 0x00, // Delta interval (1) 0x01 // Notification enabled ]);
await characteristic.writeValue(message); console.log(`Subscribed to port ${port} in mode ${mode}`); }
// Usage: Subscribe to color sensor on port 2 await subscribeSensor(characteristic, 2, 0);
// Listen for sensor updates document.addEventListener('sensorData', (e) => { console.log(`Sensor on port ${e.detail.port}: ${e.detail.value}`); });
Example 6: Reading Distance Sensor
// Subscribe to distance sensor (port 3, mode 0 = distance in cm) await subscribeSensor(characteristic, 3, 0);
document.addEventListener('sensorData', (e) => { if (e.detail.port === 3) { const distanceCm = e.detail.value; document.getElementById('distance').textContent = `${distanceCm} cm`;
// React to proximity if (distanceCm < 10) { console.log('Object detected close by!'); } } });
Building a Complete Control Interface
<!DOCTYPE html> <html> <head> <title>LEGO Hub Controller</title> <style> body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; } button { padding: 10px 20px; margin: 5px; font-size: 16px; } .motor-control { margin: 20px 0; padding: 20px; border: 1px solid #ccc; } input[type=range] { width: 100%; } #status { padding: 10px; background: #f0f0f0; border-radius: 5px; } </style> </head> <body> <h1>LEGO Web Bluetooth Controller</h1>
<button id="connectBtn">Connect to Hub</button> <div id="status">Not connected</div>
<div class="motor-control" id="controls" style="display:none;"> <h2>Motor A Control</h2> <label>Speed: <span id="speedValue">0</span>%</label> <input type="range" id="speedSlider" min="-100" max="100" value="0"> <button id="stopBtn">Stop Motor</button>
<h2>Hub LED</h2> <button onclick="setColor(255,0,0)">Red</button> <button onclick="setColor(0,255,0)">Green</button> <button onclick="setColor(0,0,255)">Blue</button> <button onclick="setColor(0,0,0)">Off</button>
<h2>Distance Sensor</h2> <div>Distance: <span id="distance">--</span> cm</div> </div>
<script> const LEGO_SERVICE = '00001623-1212-efde-1623-785feabcd123'; const LEGO_CHARACTERISTIC = '00001624-1212-efde-1623-785feabcd123'; let characteristic = null;
// Connect button document.getElementById('connectBtn').addEventListener('click', async () => { try { const device = await navigator.bluetooth.requestDevice({ filters: [{ services: [LEGO_SERVICE] }] });
const server = await device.gatt.connect(); const service = await server.getPrimaryService(LEGO_SERVICE); characteristic = await service.getCharacteristic(LEGO_CHARACTERISTIC);
document.getElementById('status').textContent = 'Connected to ' + device.name; document.getElementById('controls').style.display = 'block';
// Subscribe to distance sensor on port 3 await subscribeSensor(characteristic, 3, 0);
} catch (error) { alert('Connection failed: ' + error); } });
// Speed slider document.getElementById('speedSlider').addEventListener('input', async (e) => { const speed = parseInt(e.target.value); document.getElementById('speedValue').textContent = speed; if (characteristic) { await runMotor(characteristic, 0, speed); // Port A } });
// Stop button document.getElementById('stopBtn').addEventListener('click', async () => { if (characteristic) { await stopMotor(characteristic, 0); document.getElementById('speedSlider').value = 0; document.getElementById('speedValue').textContent = 0; } });
// LED color function async function setColor(r, g, b) { if (characteristic) { await setHubLED(characteristic, r, g, b); } }
// Listen for sensor data document.addEventListener('sensorData', (e) => { if (e.detail.port === 3) { document.getElementById('distance').textContent = e.detail.value; } });
// [Include motor control functions from previous examples] </script> </body> </html>
Using node-poweredup Library (Easier Approach)
For production projects, use the node-poweredup library which abstracts the protocol complexity:
<!-- Include library via CDN --> <script src="https://cdn.jsdelivr.net/npm/node-poweredup@latest/dist/poweredup-browser.js"></script>
<script> const PoweredUP = window.poweredup; const hub = new PoweredUP.Hub();
// Connect to hub document.getElementById('connectBtn').addEventListener('click', async () => { await hub.connect(); console.log('Connected!'); });
// Run motor (much simpler!) async function runMotor(port, speed) { const motor = await hub.waitForDeviceAtPort(port); await motor.setPower(speed); }
// Usage: await runMotor('A', 50); // Port A at 50% speed </script>
Benefits of node-poweredup:
- ✅ Handles protocol encoding/decoding automatically
- ✅ Type-safe TypeScript support
- ✅ Supports all LEGO hub types
- ✅ Active maintenance and community
- ✅ Built-in sensor data parsing
GitHub: nathankellenicki/node-poweredup
Troubleshooting Common Issues
"Bluetooth not supported" Error
- ✓ Use Chrome, Edge, or Opera (not Safari/Firefox)
- ✓ Ensure HTTPS connection (required for security)
- ✓ Check
chrome://flags/#enable-web-bluetoothis enabled
Hub Not Appearing in Device List
- ✓ Turn hub off and on again (press center button 5+ seconds)
- ✓ Make sure hub is not connected to another device
- ✓ Check hub battery is charged (LED should be green/blue)
- ✓ Ensure Bluetooth is enabled on your computer
Commands Not Working
- ✓ Verify correct port numbers (0=A, 1=B, 2=C, etc.)
- ✓ Check message length byte is correct
- ✓ Ensure motor/sensor is actually connected to that port
- ✓ Use browser DevTools console to see error messages
Connection Drops Frequently
- ✓ Reduce distance between computer and hub (BLE range ~10m)
- ✓ Remove obstacles between devices
- ✓ Avoid interference from other Bluetooth devices
- ✓ Update hub firmware via official LEGO app
Advanced Topics
Multi-Hub Control
Control multiple hubs simultaneously by maintaining separate connections:
const hub1 = await connectToHub(); // First hub const hub2 = await connectToHub(); // Second hub
// Control both independently await runMotor(hub1.characteristic, 0, 50); await runMotor(hub2.characteristic, 0, -50); Synchronized Motor Control
Use virtual port commands to synchronize multiple motors:
// Command 0x81 with virtual port combines motors A+B // See LEGO Wireless Protocol docs for virtual port details Learning Resources
- Official LEGO Wireless Protocol Documentation
- node-poweredup Library (GitHub)
- LWP JavaScript Library
- Building a Bluetooth LE LEGO Controller (Medium)
- Chrome Developers: LEGO Education + Web Bluetooth
Project Ideas
- Virtual Joystick: Use HTML5 canvas to create on-screen joystick for robot control
- Sensor Dashboard: Real-time graphs of distance/color sensor data with Chart.js
- Voice Control: Combine Web Speech API with Web Bluetooth for voice-controlled robots
- Multiplayer Game: WebSocket server coordinates multiple players controlling different hubs
- Educational IDE: Block-based programming interface that generates Web Bluetooth code
- AR Control: Use WebXR + Web Bluetooth to control physical robot with AR interface
Get MINDSTORMS Robot Inventor for Web Control Get SPIKE Prime for Educational Web Apps Get Powered UP Hub for Custom Projects
Use Our Tools to Go Further
Get more insights about the sets mentioned in this article with our free LEGO tools