Guide to Custom Ground Control Station: Building a Vanilla JS Dashboard to Graph Drone Sensor Data via WebSerial

Custom Ground Control Station: Build a Vanilla JS Dashboard to Graph Drone Sensor Data via WebSerial

Overview

In this tutorial you create a lightweight Ground Control Station (GCS) that runs entirely in the browser. Using the Web Serial API you open a direct serial link to a drone (or any micro‑controller), read live sensor streams, and plot them in real time with Chart.js. No frameworks, no server‑side code – just vanilla JavaScript, HTML, and a dash of CSS.

Prerequisites

  • Chrome (89+) or Edge – browsers that support the Web Serial API.
  • A drone or development board (e.g., Arduino, Pixhawk) exposing sensor data over UART/USB.
  • Basic knowledge of JavaScript, HTML, and CSS.
  • Optional: Node.js if you want to serve the page locally.

Project Setup

1️⃣ Create the folder

mkdir vanilla-gcs && cd vanilla-gcs

2️⃣ Add index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Drone Dashboard</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
  <!-- UI will be injected by script.js -->
  <script src="script.js"></script>
</body>
</html>

3️⃣ Add script.js

// script.js – will be filled later

Understanding the WebSerial API

The Web Serial API lets JavaScript speak directly to serial devices. It works behind a permission prompt, keeping the user in control.

Key objects

  • navigator.serial – entry point to request ports.
  • SerialPort – represents the opened connection.
  • ReadableStream & WritableStream – for data flow.

Basic connection flow

async function connect() {
  // 1️⃣ Prompt user to select a serial port
  const port = await navigator.serial.requestPort();

  // 2️⃣ Open the port at the expected baud rate
  await port.open({ baudRate: 115200 });

  // 3️⃣ Set up a text decoder to turn bytes into strings
  const decoder = new TextDecoderStream();
  const inputDone = port.readable.pipeTo(decoder.writable);
  const inputStream = decoder.readable;

  // 4️⃣ Listen for incoming lines
  const reader = inputStream.getReader();
  // … use reader.read() inside a loop
}

Reading Sensor Data from the Drone

Most flight controllers broadcast CSV‑style lines, e.g., ALT,12.3 or GPS,48.8584,2.2945. We parse each line and push the numeric values into an array that feeds Chart.js.

Live‑read loop

async function startReading(port) {
  const decoder = new TextDecoderStream();
  const inputDone = port.readable.pipeTo(decoder.writable);
  const inputStream = decoder.readable;
  const reader = inputStream.getReader();

  let buffer = '';
  while (true) {
    const { value, done } = await reader.read();
    if (done) break; // port closed

    buffer += value;
    const lines = buffer.split('\n');
    buffer = lines.pop(); // incomplete line stays in buffer

    for (const line of lines) {
      if (!line.trim()) continue;
      processLine(line.trim());
    }
  }
}

Parsing helper

function processLine(line) {
  const [type, ...data] = line.split(',');
  switch (type) {
    case 'ALT':
      altitudeChart.data.labels.push(Date.now());
      altitudeChart.data.datasets[0].data.push(parseFloat(data[0]));
      break;
    case 'TMP':
      temperatureChart.data.labels.push(Date.now());
      temperatureChart.data.datasets[0].data.push(parseFloat(data[0]));
      break;
    // Add more cases as needed
  }
  // Keep only the last 60 points
  trimChart(altitudeChart);
  trimChart(temperatureChart);
  altitudeChart.update('none');
  temperatureChart.update('none');
}
function trimChart(chart) {
  const max = 60;
  if (chart.data.labels.length > max) {
    chart.data.labels.splice(0, chart.data.labels.length - max);
    chart.data.datasets[0].data.splice(0, chart.data.datasets[0].data.length - max);
  }
}

Visualizing Data with Chart.js

Chart.js gives us responsive, animated line charts with minimal code.

Create canvas elements

<div class="card">
  <h3>Altitude (m)</h3>
  <canvas id="altitudeChart"></canvas>
</div>
<div class="card">
  <h3>Temperature (°C)</h3>
  <canvas id="temperatureChart"></canvas>
</div>

Instantiate the charts

const chartConfig = (label, color) => ({
  type: 'line',
  data: {
    labels: [],
    datasets: [{
      label,
      borderColor: color,
      backgroundColor: 'rgba(0,0,0,0.05)',
      data: [],
      tension: 0.2,
      fill: true,
    }]
  },
  options: {
    animation: { duration: 0 },
    scales: {
      x: { type: 'realtime', display: false },
      y: { beginAtZero: true }
    },
    plugins: {
      legend: { display: false }
    }
  }
});

const altitudeChart = new Chart(
  document.getElementById('altitudeChart'),
  chartConfig('Altitude', '#6B7C3A')
);
const temperatureChart = new Chart(
  document.getElementById('temperatureChart'),
  chartConfig('Temperature', '#ff6600')
);

Building a Clean UI with Vanilla JS

Connect button

Status indicator

Disconnected

Wire up the UI

document.getElementById('connectBtn').addEventListener('click', async () => {
  try {
    const port = await navigator.serial.requestPort();
    await port.open({ baudRate: 115200 });
    document.getElementById('status').textContent = 'Connected';
    startReading(port);
  } catch (err) {
    console.error('Connection error:', err);
    alert('Failed to connect: ' + err);
  }
});

Error Handling & Graceful Disconnect

Serial devices can disappear or the user may close the tab. Implement cleanup to avoid memory leaks.

async function disconnect(port) {
  if (port && port.readable) {
    await port.readable.cancel();
    await port.close();
  }
  document.getElementById('status').textContent = 'Disconnected';
}
window.addEventListener('beforeunload', async () => {

Comments

Popular posts from this blog

Guide to Designing an ESP32-S3 Powered WiFi-Streaming Pocket Drone with Minimalist Code and Hardware