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.requestPort(options)
: User selects the corresponding serial port.- Returns a
SerialPort
object. - Call the
SerialPort.open()
method. - 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:
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.
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.