Kalan's Blog

Software Engineer / Taiwanese / Life in Fukuoka

Current Theme light

What is the Serial API?

Google Chrome 89 introduced the Web Serial API which allows external devices to directly interact with the browser through its API, including USB devices or Bluetooth devices with a serial interface. This enables the browser to communicate directly with hardware.

In the past, when developing similar applications, it was necessary to write a separate server to handle the data received from the serial port, and then transmit it to the frontend through an API or WebSocket. However, this approach has some drawbacks:

  • Requires a server as an intermediary layer
  • Possible delays during data transmission (data sent to the computer → server receives it → transmitted to the client through a socket)
  • May require additional driver installations

The Web Serial API provides users with the ability to directly connect external devices to web pages through a serial interface.

Introduction to the Web Serial API

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

The main process of connecting to a SerialPort can be divided into the following steps:

serial

  1. Serial.requestPort(options): User selects the corresponding serial port.
  2. Returns a SerialPort object.
  3. Call the SerialPort.open() method.
  4. Use SerialPort.readable.getReader() to retrieve data.

1. Serial.requestPort(options)

When calling this method, it is important to note that the user must have an interactive trigger (such as a click or key press event). It returns a promise that contains the port object.

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

If called directly without any user interaction, the promise will be rejected.

This function must be called during a user gesture

2. Communicating with Serial through the SerialPort object

Before communicating with the Serial, the serial port must be opened and the baud rate must be determined. Why is it necessary to determine the baud rate first? In Serial communication, since there is no unified clock between the two parties, the baud rate must be agreed upon in advance in order to correctly interpret the data.

If you want to learn more about the principles of Serial communication, you can refer to the article I wrote when implementing an Arduino CO2 sensor, which explains the basics of Serial communication.

To communicate with the serial port in the browser, you can call the SerialPort.open() method, which can take optional parameters:

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

Afterwards, you can start listening for data coming from the serial port.

3. Calling readable.getReader() to retrieve Serial data

To listen for data transmitted by Serial, you can use the SerialPort.readable.getReader() method. One of the properties of SerialPort is readable, which is a ReadableStream that can be used to communicate with other objects that have a stream interface through the getReader() API.

Once you have the reader, you can use reader.read() to retrieve data. This API returns a value and done to indicate whether the data has been fully read or received. The data returned by Serial is represented as a UInt8Array, which means you need to use DataView or other methods to parse the data.

4. Closing the SerialPort

In the SerialPort, you can call port.close() to close the communication. However, this method can only be successfully called when both the readable and writable parts of the Serial do not have a lock.

To ensure that there is no ongoing data transmission, you can use reader.cancel() to forcefully cancel the data being sent. This will cause reader.read() to return done as true. Finally, release the lock using reader.releaseLock() and call port.close() to close the communication.

5. Listening for USB insertion and removal events

On navigator.serial, you can listen for connect and disconnect events to detect whether a USB device is being inserted or removed. This can be used to display the status on the UI.

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

Reading Temperature and Humidity with the Web Serial API

Now that we understand the interface and workings of the Web Serial API, let's try using it to connect to an Arduino and retrieve temperature and humidity data, and display it on a simple component on a web page.

Materials Needed

  • Chrome 89 (Other browsers currently do not support the Web Serial API)
  • DHT11 (DHT22 can also be used)
  • Arduino Nano (Other Arduino boards can also be used)

1. Prepare the Arduino Circuit

This article does not describe the details of the Arduino circuit. Here, we connect a temperature sensor to the Arduino to read the temperature and humidity, and then send the data from the Arduino to the computer through Serial. The code would look 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 can be found on GitHub.

After calling the DHT.read11 method, the temperature and humidity data will be stored in DHT.temperature and DHT.humidity respectively. We then use Serial.write to send the data to the SerialPort. Here, the data is formatted in JSON for easier parsing.

2. Transmitting Data to Serial

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

If you simply want to test reading Serial data, you don't actually need to use a sensor. You can directly call the Serial.write API.

3. Receiving Data in the Browser

In the implementation, we use TextDecoderStream to decode the received bit data into text (since we are sending strings).

async function requestSerialPort() {
  const serial = navigator.serial;
  // Select the 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);
  }
};

In the code, a buffer variable is used to store the currently received string (there are many issues with the current implementation, but it is kept simple for now). This is because the packet size transmitted by Serial is around 1 byte (depending on the data frame and stop bit), so we need a variable as a temporary storage until all the data (JSON) has been transmitted.

4. UI Implementation (Using Svelte as an Example)

On the UI side, a Gauge component is implemented using d3-shape and d3-scale to make it visually appealing. Although the code is written in Svelte, it can be easily implemented in other frontend frameworks or even without any framework:

<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>

Define the scale

First, define the scale. We want to map the data values to a range of 0 to 1 for easier calculation. Then, define angleScale to map the range of 0 to 1 to -90 to 90 degrees.

arc

Using the arc API from d3-shape, we can create a path based on the given radius, start angle, end angle, and corner radius. This makes it easier to directly include the path in the SVG.

Define viewBox

Unlike the usual coordinate system, the origin of an SVG is at the top left corner. To center our Gauge component, we need to define a viewBox to offset it. Think of the viewBox as a window. The first two values represent the origin position, and the last two values represent the width and height. Therefore, viewBox=10,10,5,5 means that a window with a width of 5 and a height of 5 is created, with the origin at (10, 10).

Our Gauge component, since it doesn't have any offset, has its center at the origin (0, 0). If we draw a circle with a radius of 1 without any offset, it will look like this, going beyond the visible area:

viewbox default

To make the entire Gauge visible, we need to offset the origin to -1, -1, so that the entire Gauge component fits within the visible area. The subsequent 2, 1 values ensure that the width and height are 2 and 1 respectively, filling the entire visible area.

viewbox default

5. Result

Now, everything is integrated and you can see the result! You can find the detailed source code on Github.

Conclusion

It can be observed that web technologies have gradually extended into hardware applications. In addition to the Serial API, there are also the NFC API and HID API. As applications become more diverse, the overall architecture becomes more complex. Displaying sensor data on the screen is just one of the many applications.

These standards are still in the draft stage, and currently, only Chrome is diligently implementing these features. It may not be necessary to invest too much effort into researching them at this moment. However, this also means that front-end engineers have another path to explore, expanding their field of work from UI implementation, data management, and interaction to hardware interaction.

Prev

Application of form tag with formData

Next

Setting up a server in a company is not as easy as I imagined.

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee

作者

Kalan 頭像照片,在淡水拍攝,淺藍背景

愷開 | Kalan

Hi, I'm Kai. I'm Taiwanese and moved to Japan in 2019 for work. Currently settled in Fukuoka. In addition to being familiar with frontend development, I also have experience in IoT, app development, backend, and electronics. Recently, I started playing electric guitar! Feel free to contact me via email for consultations or collaborations or music! I hope to connect with more people through this blog.