· 10 min read

Reading Temperature and Humidity via Arduino in the Browser - Web Serial API

This article was auto-translated from Chinese. Some nuances may be lost in translation.

What is the Serial API?

Google Chrome 89 introduced the Web Serial API, allowing external devices to interact directly via browser APIs, including USB devices or Bluetooth devices with a Serial interface. This allows browsers to communicate directly with hardware.

In the past, building similar applications required writing a separate server to interface with data sent over the serial port, which was then forwarded to the frontend via an API or WebSocket. However, this approach had several drawbacks:

  • Requires a server to act as middleware
  • Potential latency during data transmission (data sent to computer → received by server → sent to client via socket)
  • May require installing additional drivers

The biggest benefit of the Web Serial API for users is that it allows connecting external devices directly to web pages via Serial.

Introduction to the Web Serial API

The Serial API can be accessed via navigator.serial. Currently, it is only implemented in Chrome 89, and other browsers do not yet support this API.

The main flow of connecting to a SerialPort can be divided into:

serial

  1. Serial.requestPort(options): The user selects the corresponding serial port
  2. Returns a SerialPort object
  3. Calls the SerialPort.open() method
  4. Uses SerialPort.readable.getReader() to retrieve data

1. Serial.requestPort(options)

When calling this method, note that it must be triggered by a user gesture (such as a click or keypress event). It returns a promise containing the port object.

navigator.serial.requestPort()
	.then(port => {
  
  })

If called directly without user interaction, the promise will be rejected immediately:

This function must be called during a user gesture

2. Communicating with Serial via the SerialPort Object

Before communicating with Serial, you must open the serial port and determine the baud rate. Why do you need to determine the baud rate first? Because in Serial communication, both sides do not share a common clock, a baud rate must be agreed upon in advance to decode the data correctly.

If you want to learn more about how Serial communication works, you can check out the article I wrote when implementing an Arduino CO2 concentration monitor, which explains the basic principles of Serial communication.

To allow the browser to communicate with the serial port, you can call the SerialPort.open() method, which accepts configuration options:

const port = await serial.requestPort();
await port.open({ baudRate: 9600 });

After this, you can start listening to data transmitted from the serial port.

3. Calling readable.getReader() to Retrieve Serial Data

To listen to data sent over Serial, you can read it using SerialPort.readable.getReader(). One of the properties of SerialPort is readable, which is a ReadableStream. It allows communication with other stream-enabled objects through the getReader() API.

Once you have the reader, you can call reader.read() to retrieve data. This API returns value and done to indicate whether the data reading is complete and to provide the transmitted value. The data returned by Serial is always represented as a Uint8Array, meaning you need to use DataView or other methods to parse it.

4. Closing the SerialPort

You can call port.close() on the SerialPort to terminate communication. However, this method can only be executed successfully if both readable and writable streams in Serial are unlocked.

To ensure there is no ongoing data transmission, you can use reader.cancel() to force-cancel any active transfer, which sets done returned by reader.read() to true. Finally, release the lock via reader.releaseLock() and call port.close() to close the connection.

5. Listening to USB Connect and Disconnect Events

You can listen for connect and disconnect events on navigator.serial to detect whether a USB device is plugged in or removed, and update the UI accordingly.

navigator.serial.addEventListener('connect', () => {});
navigator.serial.addEventListener('disconnect', () => {});

Reading Temperature and Humidity with the Web Serial API

Now that we understand the interface and working principles of the Web Serial API, let’s try using it to connect to temperature and humidity data from an Arduino and implement a simple component to display on a webpage.

Prerequisites

  • Chrome 89 (other browsers do not currently support the Web Serial API)
  • DHT11 (DHT22 also works)
  • Arduino Nano (any other Arduino board will also work)

1. Preparing the Arduino Circuit

This article won’t go into too much detail about the Arduino circuitry. Here, we connect the temperature sensor to the Arduino to read temperature and humidity, and then the Arduino sends the data to the computer via Serial. The code looks like this:

#include <dht.h>

dht DHT;

#define DHT11_PIN 7

void setup(){
  // Set baud rate to 9600
  Serial.begin(9600);
}

void loop(){
	DHT.read11(DHT11_PIN);
  sprintf(output, "{ \"temperature\": %.2f, \"humidity\": %.2f }", DHT.temperature, DHT.humidity);
  Serial.write(output);
  delay(1000);
}

The DHT library used here can be found on GitHub.

Calling the DHT.read11 method stores the temperature and humidity data in DHT.temperature and DHT.humidity, and Serial.write is then used to send the data to the SerialPort. Here, the data is formatted as JSON (which makes parsing easier).

2. Sending Data to Serial

Serial.write sends data to the SerialPort. Remember to configure Serial.begin(9600) so that the Arduino knows to transmit data at a rate of 9600 bit/s.

If you just want to test reading Serial data, you don’t actually need a sensor; calling the Serial.write API directly is sufficient.

3. Receiving Data in the Browser

The implementation is very similar to the flow described earlier in this article. Here, we use TextDecoderStream to help decode the stream (since we are sending strings).

async function requestSerialPort() {
  const serial = navigator.serial;
  // Select target Serial Port
  const port = await serial.requestPort();
  // Set baud rate to 9600
  await port.open({ baudRate: 9600 });
  // Decode bit data into text
  let decoder = new TextDecoderStream();
  port.readable.pipeTo(decoder.writable);
  const reader = decoder.readable.getReader();

  try {
    let buffer = '';

    const timerId = setInterval(async () => {
      const { value, done } = await reader.read();
     
      buffer += value;

      if (buffer.includes('{') && buffer.includes('}')) {
        const start = buffer.indexOf('{');
        const end = buffer.indexOf('}');
        buffer = buffer.slice(start, end + 1);
        try {
          const { temperature, humidity } = JSON.parse(buffer);
         	console.log(temperature, humidity);
        } catch (err) {
          console.log(err);
        }
        buffer = '';
      }
    }, 1500);
  } catch (err) {
    console.log(err);
  }
};

The code uses a buffer variable to store the incoming string (this simple implementation has its edge-case issues, but it works fine for demonstration purposes). This is because the packet size in Serial transmission is around 1 byte (depending on data frames and stop bits), so we need a variable as a temporary buffer before the entire payload (JSON) is fully transmitted.

4. UI Implementation (Using Svelte as an Example)

For the UI, a Gauge component was implemented using d3-shape and d3-scale to make it look less plain. Although the code is written in Svelte, it can easily be achieved with other frontend frameworks or even vanilla JavaScript:

<script>
  import { tweened } from 'svelte/motion'
  import { arc } from 'd3-shape';
  import { scaleLinear } from 'd3-scale';
  export let maxValue;
  export let minValue;
  export let value;
  export let unit;
  export let label;
  export let toFixed;
  export let fillColor;

  let indicator = tweened(0)
  
  $: scale = scaleLinear()
    .domain([minValue || 0, maxValue])
    .range([0, 1]);
  
  $: precentage = scale(value);
  
  $: angleScale = scaleLinear()
    .domain([0, 1])
    .range([-Math.PI / 2, Math.PI / 2])
    .clamp(true);
  
  $: angle = angleScale(precentage);
  
  let backgroundArc = arc()
    .innerRadius(0.75)
    .outerRadius(1)
    .startAngle(-Math.PI / 2)
    .endAngle(Math.PI / 2)
    .cornerRadius(1);
  $: filledArc = arc()
    .innerRadius(0.75)
    .outerRadius(1)
    .startAngle(-Math.PI / 2)
    .endAngle(angle)
    .cornerRadius(1);

  $: {
    indicator.set(angle)  
  }
</script>

<div class="gauge">
  <svg viewBox="-1 -1 2 1" class="circle">
    <path d={backgroundArc()} fill="#aaa" />
    <path d={filledArc()} fill={fillColor} />
  </svg>
  <svg style={`transform: translateX(-50%) rotate(${$indicator}rad);`} class="dial" width="9" height="23" viewBox="0 0 9 23" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M4 0L8.02368 18.5089C8.5256 20.8178 6.76678 23 4.40402 23V23C2.10447 23 0.360582 20.9267 0.754589 18.6611L4 0Z" fill="#C4C4C4"/>
  </svg>
    
  
  <span class="label">
    <span class="labelName">{label}</span>
    <div>
      <span class="value" style="color: {fillColor}"> {toFixed ? value.toFixed(toFixed) : Math.floor(value)} <small>{unit}</small></span>
    </div>
  </span>
</div>

Defining Scales

First, define scale. We want to map data values to 0 and 1 for easier calculations. Next, define angleScale to map 0 and 1 to -90° ~ 90° (-π/2 to π/2 radians).

arc

Using the d3-shape arc API, it generates the corresponding SVG path based on the given inner/outer radius, start angle (startAngle), and end angle (endAngle), making it convenient to embed directly into the SVG.

Defining viewBox

Unlike typical Cartesian coordinate systems, an SVG’s origin is at the top-left corner. In order to center our Gauge component, we need to configure viewBox with an offset. You can think of the viewBox as a viewport: the first two values represent the origin position, and the last two represent the width and height. Therefore, viewBox="10 10 5 5" means using (10, 10) as the origin to create a visible window with a width of 5 and a height of 5.

Because our Gauge component has no inherent offset, the center of the circle sits at the origin (0, 0). If we draw a circle with a radius of 1, it will fall outside the visible area as shown below, and nothing will be visible.

viewbox default

To make the entire Gauge visible within the viewport, we need to shift the origin to (-1, -1). The following values, 2 and 1, set the width and height to 2 and 1, perfectly filling the entire visible area.

viewbox default

5. Results

Combine everything together, and you will see the results! The complete source code can be found on GitHub.

Conclusion

As you can see, web technologies have recently been extending gradually into hardware applications. In addition to the Serial API, there are also the NFC API and HID API. As applications become richer, it also means system architectures become increasingly complex. Receiving sensor data and displaying it on a screen is just one of many use cases.

These standards are still in working draft stages, and currently Chrome seems to be the only browser actively implementing them. You may not need to invest heavy effort into studying them just yet, but it signals that the domain for frontend engineers—ranging from building UIs, managing data flows, and handling interactions—is expanding toward hardware integration. In the future, frontend engineers will have yet another path to explore.

References

https://web.dev/serial/

Related Posts

Explore Other Topics