A browser based Web Bluetooth application that connects to the ESP32 board I made over BLE. It controls a servo motor angle from 0 to 180 degrees and toggles an LED, with a debounced UI built in plain HTML, CSS, and JavaScript. No app install required.
Interface week moves the microcontroller out of isolation and gives a human a way to talk to it in real time. For this assignment I built a browser control panel that connects to the ESP32 board I made over Bluetooth Low Energy with no native app, no USB cable, and no Wi-Fi router. The user opens a page in Chrome, clicks Connect, and immediately has live control over a servo motor angle and an LED. The board itself is one I designed and milled in an earlier week, so this UI talks to my own hardware end to end.
The Web Bluetooth API gives browsers direct access to BLE GATT peripherals, which makes it possible to write hardware control interfaces in plain HTML and JavaScript. My ESP32 runs a GATT server that listens for text commands (S:90 for servo, L:1 for LED) and acts on them immediately.

Individual Assignment
Write an application that interfaces a user with an input and/or output device that you made.
I built a full browser UI, with HTML structure, a CSS dark theme, and JavaScript BLE logic, that controls a servo motor and LED on the ESP32 board I made over BLE. The interface includes a real time animated servo arm and an LED bulb that glows amber when toggled on.
Group Assignment
Compare as many tool options as possible.
As a group we compared the main ways to build an interface that talks to a board you made. The question we kept asking was simple. What does the user have to install, and how does the tool reach the hardware. Our full write up lives on the group assignment page. The table below is the summary we landed on after each of us tried a different tool against the same servo and LED task.
| Tool | Language | Link to board | User install | Runs on | Catch |
|---|---|---|---|---|---|
| Web Bluetooth (my choice) | HTML, CSS, JavaScript | BLE GATT, direct | None, opens a URL | Chrome, Edge, Android | No iOS, needs HTTPS |
| Processing | Java based sketches | Serial over USB | Processing runtime | Win, Mac, Linux | Wired, desktop only |
| Python (Tkinter, PySerial, Bleak) | Python | Serial or BLE via Bleak | Python plus packages | Win, Mac, Linux | Setup heavy for users |
| MIT App Inventor | Visual blocks | BLE extension | Android APK | Android only | No iOS, blocks get messy |
| Node-RED dashboard | Flow nodes, visual | MQTT or serial bridge | Server plus broker | Web dashboard | Needs a running server |
Web Bluetooth is a browser API that lets a web page connect directly to a BLE GATT peripheral, with no native app, no installation, and no backend server. The page must be served over HTTPS (or localhost), and the user must grant Bluetooth permission through a dialog at the operating system level.
characteristic.writeValue() with a byte buffer. Plain ASCII text keeps both sides of the protocol readable and simple.The build had three clear phases: hardware wiring and ESP32 firmware, then the web UI layout and CSS, then the JavaScript BLE logic and servo debounce. Each phase was tested independently before combining.


S:<angle> moves the servo and L:1/L:0 sets the LED. The BLE callback only updates the target angle, and the smooth sweep itself runs inside loop() so the BLE stack stays responsive during movement. On disconnect, startAdvertising() is called again automatically so the page can reconnect.#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <ESP32Servo.h>
#define SERVICE_UUID "12345678-1234-1234-1234-123456789abc"
#define CHAR_UUID "12345678-1234-1234-1234-123456789abd"
#define SERVO_PIN 18
#define LED_PIN 2
Servo servo;
int targetAngle = 90, currentAngle = 90;
bool newCommand = false;
// Command format sent by the browser over one BLE write:
// S:<0..180> set servo target angle e.g. S:120
// L:0 / L:1 turn the LED off or on
class CharCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* pChar) {
String val = pChar->getValue().c_str();
if (val.startsWith("S:")) {
targetAngle = constrain(val.substring(2).toInt(), 0, 180);
newCommand = true;
} else if (val.startsWith("L:")) {
digitalWrite(LED_PIN, val.substring(2).toInt());
}
}
};
// Re-advertise so the page can find the board again after a drop
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* s) {}
void onDisconnect(BLEServer* s) { s->startAdvertising(); }
};
void setup() {
pinMode(LED_PIN, OUTPUT);
servo.attach(SERVO_PIN);
servo.write(currentAngle);
BLEDevice::init("ESP32_Controller");
BLEServer* server = BLEDevice::createServer();
server->setCallbacks(new ServerCallbacks());
BLEService* service = server->createService(SERVICE_UUID);
BLECharacteristic* ch = service->createCharacteristic(
CHAR_UUID, BLECharacteristic::PROPERTY_WRITE);
ch->setCallbacks(new CharCallbacks());
service->start();
BLEAdvertising* adv = BLEDevice::getAdvertising();
adv->addServiceUUID(SERVICE_UUID);
adv->start();
}
void loop() {
if (newCommand && currentAngle != targetAngle) {
newCommand = false;
while (currentAngle != targetAngle) {
currentAngle += (targetAngle > currentAngle) ? 1 : -1;
servo.write(currentAngle);
delay(8); // 8 ms per degree = smooth sweep
}
}
delay(10);
}Key insight: Separating the BLE callback, which sets the target, from loop(), which runs the sweep, means the ESP32 can still receive a new command mid sweep without the BLE stack blocking. The 8 ms per degree gives a 180 degree sweep in about 1.4 seconds, fast enough to feel responsive and slow enough to stay smooth.

<div class="app">
<div class="header-card">
<span id="status-badge">Disconnected</span>
<button id="btn-connect" class="btn-connect"
onclick="toggleConnect()">Connect</button>
</div>
<div class="controls-row" id="controls">
<!-- Servo panel: slider drives the arm and the board -->
<div class="card" id="servo-card">
<svg id="servo-svg" viewBox="0 0 200 120">
<line id="servo-arm" x1="100" y1="110" x2="100" y2="20"
stroke="#1D9E75" stroke-width="6"/>
</svg>
<span id="angle-readout">90°</span>
<input type="range" min="0" max="180" value="90"
oninput="onAngleMove(this.value)"
onchange="onAngleCommit(this.value)">
</div>
<!-- LED panel: toggle sends L:1 / L:0 -->
<div class="card" id="led-card">
<div class="led-big" id="led-dot"><i class="ti ti-bulb"></i></div>
<label class="switch">
<input type="checkbox" onchange="onLED(this.checked)">
</label>
</div>
</div>
<ul id="log"></ul>
</div>
.app { background: #0f1923; }
.card { background: #1a2634; border: 0.5px solid #2a3a4a; }
/* Button: green → red on connect */
.btn-connect { background: #1D9E75; border: none; }
.btn-connect.active { background: #c0392b; }
/* Slider thumb accent */
input[type=range] { accent-color: #1D9E75; }
/* LED off → on */
.led-big { background: #253040; border-color: #2a3a4a; }
.led-big.on { background: #3a2a00; border-color: #EF9F27; }
.led-big.on i { }
/* Disabled state when not connected */
.disabled-overlay { opacity: 0.35; pointer-events: none; }

navigator.bluetooth.requestDevice() filtered by the device name ESP32_Controller. The browser shows a Bluetooth device picker. Once the user selects the ESP32, the code connects to the GATT server, retrieves the service by UUID, and gets the characteristic handle, which is then used for every write after that.const SERVICE_UUID = '12345678-1234-1234-1234-123456789abc';
const CHAR_UUID = '12345678-1234-1234-1234-123456789abd';
let characteristic;
async function toggleConnect() {
const device = await navigator.bluetooth.requestDevice({
filters: [{ name: 'ESP32_Controller' }],
optionalServices: [SERVICE_UUID]
});
// Update the UI if the board drops the link
device.addEventListener('gattserverdisconnected', () => {
setConnected(false);
log('Device disconnected.');
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
characteristic = await service.getCharacteristic(CHAR_UUID);
setConnected(true);
log('Connected to ' + device.name);
}
// Flip the badge, button colour, and control state
function setConnected(isConnected) {
const badge = document.getElementById('status-badge');
const button = document.getElementById('btn-connect');
const controls = document.getElementById('controls');
badge.textContent = isConnected ? 'Connected' : 'Disconnected';
button.textContent = isConnected ? 'Disconnect' : 'Connect';
button.classList.toggle('active', isConnected);
controls.classList.toggle('disabled-overlay', !isConnected);
if (!isConnected) characteristic = null;
}
oninput event updates the SVG arm visually for instant feedback, while onchange fires the BLE write only on release. A 300 ms debounce timer covers the case where the slider is held still. A deduplication check skips writes if the angle has not changed. A sending lock stops overlapping async writes.let sendTimer = null, lastSentAngle = 90, sending = false;
// Fires on every pixel, only updates the visual arm
function onAngleMove(v) {
updateArm(v);
if (sendTimer) clearTimeout(sendTimer);
// fallback, send after 300 ms of stillness
sendTimer = setTimeout(() => onAngleCommit(v), 300);
}
// Fires on mouse or touch release, sends the BLE command
function onAngleCommit(v) {
if (sendTimer) { clearTimeout(sendTimer); sendTimer = null; }
v = parseInt(v);
if (v === lastSentAngle) return; // deduplicate, nothing changed
lastSentAngle = v;
send('S:' + v); // one clean BLE write
}
// LED toggle sends a single byte command
function onLED(isOn) {
send('L:' + (isOn ? 1 : 0));
}
async function send(cmd) {
if (!characteristic || sending) return;
try {
sending = true;
await characteristic.writeValue(new TextEncoder().encode(cmd));
log('Sent ' + cmd);
} finally {
sending = false;
}
}
// Move the SVG arm and update the readout
function updateArm(v) {
const angle = (v - 90) * Math.PI / 180;
const arm = document.getElementById('servo-arm');
arm.setAttribute('x2', 100 + 90 * Math.sin(angle));
arm.setAttribute('y2', 110 - 90 * Math.cos(angle));
document.getElementById('angle-readout').textContent = v + '°';
}
// Print a timestamped line in the activity log
function log(msg) {
const li = document.createElement('li');
li.textContent = new Date().toLocaleTimeString() + ' ' + msg;
document.getElementById('log').prepend(li);
}Root cause of jitter: BLE GATT write operations are asynchronous. Sending faster than the ESP32 can process creates a backlog, so the servo tries to follow a queue of stale angle values and the motion turns erratic. Debouncing keeps only one write in flight at a time.



This week connected the software and hardware worlds in the most direct way I could find. A browser tab controls a physical servo motor on the board I made, with no cable, no app, and no cloud. The Web Bluetooth API makes this surprisingly easy to reach, because the whole control stack is a single HTML file that anyone with Chrome can open.
The most important lesson was the debounce architecture. The simple first approach of sending a BLE write on every slider pixel broke the servo right away. Splitting visual feedback (oninput) from the real BLE write (onchange plus debounce) is a pattern that applies to any hardware connected UI where commands have physical side effects and the channel has limited bandwidth.
The same split applies on the firmware side. The BLE callback sets a target, and loop() carries it out. This keeps the BLE stack free and ready for the next command even while the servo is still moving.