DEI.
Week 13 · Fab Academy 2026 · Lab Rwanda

Interface & Application Programming

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.

Overview

Introduction

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.

Controller
Browser
Chrome / Edge
Slider → S:<angle> Toggle → L:1 / L:0
BLE GATT · Write
Peripheral
ESP32
GATT Server
Servo → GPIO 18 LED → GPIO 2
the browser app connected and driving the servo and LED on the ESP32 board I made
      Hero shot
the browser app connected and driving the servo and LED on the ESP32 board I made Hero shot
This Week

Assignments

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.

View Group Assignment Interface and Application Programming, Tool Comparison
Group Assignment

Comparing UI Tool Options

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.

Tools compared
5
Zero install
1web bluetooth
Cross device
3
My pick
Web BLE
ToolLanguageLink to boardUser installRuns onCatch
Web Bluetooth (my choice)HTML, CSS, JavaScriptBLE GATT, directNone, opens a URLChrome, Edge, AndroidNo iOS, needs HTTPS
ProcessingJava based sketchesSerial over USBProcessing runtimeWin, Mac, LinuxWired, desktop only
Python (Tkinter, PySerial, Bleak)PythonSerial or BLE via BleakPython plus packagesWin, Mac, LinuxSetup heavy for users
MIT App InventorVisual blocksBLE extensionAndroid APKAndroid onlyNo iOS, blocks get messy
Node-RED dashboardFlow nodes, visualMQTT or serial bridgeServer plus brokerWeb dashboardNeeds a running server
How we scored them
We rated each tool on install friction for the user, how directly it reaches a board we made, which platforms it covers, and how readable the resulting code stays. Web Bluetooth won on install friction and directness for a single BLE device.
Why I picked Web Bluetooth
My board already speaks BLE from networking week, and the user just opens a link in Chrome. No APK, no runtime, no driver. The trade off I accepted is no iPhone support, which is fine for a lab demo on a laptop or Android phone.
Concepts

Why Web Bluetooth?

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.

Zero Install
The user opens a URL in Chrome or Edge and clicks Connect. The Bluetooth dialog shows nearby BLE devices filtered by name. Once connected, the page writes directly to the hardware characteristic, with no driver and no app store.
📝
GATT Write
The Web Bluetooth API mirrors the BLE GATT model of services, characteristics, and properties. Writing a value calls characteristic.writeValue() with a byte buffer. Plain ASCII text keeps both sides of the protocol readable and simple.
Browser Limits
Only Chrome and Edge (desktop + Android) support Web Bluetooth. Firefox and all iOS browsers (including Chrome on iPhone, which uses WebKit) are blocked by Apple policy. A native Swift or Flutter app is required for iPhone users.
Process

Step-by-Step

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.

Phase 01
Hardware Setup & ESP32 Firmware BLE GATT server · Servo sweep · LED
Step 01
Wire the Components
I connected the SG90 servo signal wire to GPIO 18, power to VIN (5 V), and ground to GND on the ESP32 board I made. The onboard LED on GPIO 2 needed no extra components. An optional 1.3 inch OLED (SH1106) was wired to GPIO 21 (SDA) and GPIO 22 (SCL) over I2C for a local angle readout. The OLED first showed a blank screen because of an I2C address mismatch (0x3C versus 0x3D), which I fixed by running an I2C scanner sketch.
SG90GPIO 18GPIO 2I2C scanner
Wiring: ESP32 board
Wiring: ESP32 board
Serial Monitor
Serial Monitor
Step 02
Write the ESP32 BLE GATT Firmware
The firmware advertises a custom BLE service under the name ESP32_Controller. One writable characteristic receives text commands. 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.
BLEDevice::init()PROPERTY_WRITEonWrite callbackESP32Servo
arduinocopy
#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.

Serial Monitor: the ESP32 advertising and receiving commands
Serial Monitor: the ESP32 advertising and receiving commands
Phase 02
Build the Web UI with HTML and CSS Dark theme · Servo SVG · LED toggle · Activity log
Step 03
HTML Structure
The page is a single HTML file with no framework. The layout has three parts. A header card holds the BLE status badge and the connect button. A two column grid holds the servo panel, which is a slider plus an animated SVG arm, next to the LED panel, which is a bulb icon plus a toggle. An activity log at the bottom prints each command with a timestamp so the user can see exactly what was sent. This is the full control panel that satisfies the slider for the servo and the toggle for the LED.
Single fileCSS GridSVG servo armNo framework
index.htmlcopy
<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&deg;</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>
      
Step 04
CSS Dark Theme
The UI uses a dark navy background (#0f1923) with dark slate cards (#1a2634), so it feels like an embedded hardware control panel rather than a generic web form. Teal (#1D9E75) is the primary accent. The connect button is solid green by default and switches to solid red in its Disconnect state when a device is connected. The LED bulb glows amber (#EF9F27) on toggle.
#0f1923#1D9E75accent-colorCSS transitions
style.csscopy
.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; }
UI in its disconnected state
UI in its disconnected state
UI in its connected state
UI in its connected state
Phase 03
JavaScript BLE Logic and Servo Debounce Web Bluetooth API · Debounce · Dedup · Send lock
Step 05
Web Bluetooth Connection
Clicking Connect calls 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.
requestDevice()gatt.connect()getPrimaryService()getCharacteristic()
app.jscopy
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;
}
Browser Bluetooth picker
Browser Bluetooth picker
Step 06
Servo Debounce, Solving the Jitter Problem
The first version sent a BLE write on every slider pixel. That flooded the ESP32 with dozens of commands per second and caused visible servo jitter. The fix splits the slider into two events. The 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.
oninput / onchangesetTimeout debouncededuplicationasync lock
app.jscopy
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.

Step 07
Test End-to-End
With the firmware flashed and the HTML file open in Chrome, I verified the full flow. Clicking Connect opened the Bluetooth picker showing ESP32_Controller. Selecting it connected the page and lit the status badge green. Moving the slider swept the servo smoothly with the SVG arm tracking the angle. Toggling the LED switch fired the LED instantly and lit the amber bulb in the UI. The activity log confirmed every command sent with a timestamp.
ChromeServo sweep verifiedLED toggle verifiedLog confirmed
Physical servo moving as the slider is dragged
Physical servo moving as the slider is dragged
LED on
LED on
Activity log
Activity log
Results

System Summary

Interface
Web Bluetooth
Protocol
BLEGATT
Servo range
0–180deg
Sweep speed
8ms/deg
Debounce
300ms
LED states
2on / off
App install
✗ None
Status
Working
UI Browser, HTML, CSS, JS
Servo controlRange slider + SVG arm
LED controlToggle + amber bulb
FeedbackTimestamped log
BLE connectWeb Bluetooth API
StabilityDebounced + deduped
FW ESP32, Arduino, C++
BLE roleGATT Peripheral
Service UUID12345678-…abc
Command parseronWrite() callback
Servo sweeploop(), non blocking
Re-advertiseAuto on disconnect
Takeaways

Conclusion

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.

Web Bluetooth BLE GATT ESP32 ESP32Servo Servo Motor LED Control Debounce HTML / CSS / JS Dark UI No App Install Chrome / Edge Single File
← Week 11 · Networking All Assignments →