If you have any questions or feedback, pleasefill out this form
This post is translated by ChatGPT and originally written in Mandarin, so there may be some inaccuracies or mistakes.
What is the Serial API?
Google Chrome 89 introduced the Web Serial API, allowing external devices to interact directly with the browser's API, including USB devices or Bluetooth devices with Serial interfaces. This enables direct communication between the browser and hardware.
Previously, when developing similar applications, it was necessary to write a separate server to interface with the serial data and then transfer it to the frontend via API or WebSocket. However, this approach has some drawbacks:
- A server is required as an intermediary layer.
- There may be delays during data transmission (data sent to the computer → server receives → sent to the client via socket).
- Additional driver installation may be necessary.
The biggest advantage of the Web Serial API for users is that it allows external devices to be directly connected to the webpage via Serial.
Introduction to the Web Serial API
The Serial API can be accessed through navigator.serial
, and it is currently implemented only in Chrome 89; other browsers do not yet support this API.
The main flow for connecting to a SerialPort can be broken down into:
Serial.requestPort(options)
- The user selects the corresponding serial port.- Returns a
SerialPort
object. - Calls the
SerialPort.open()
method. - Uses
SerialPort.readable.getReader()
to retrieve data.
1. Serial.requestPort(options)
When calling this method, it is important to note that the user must first trigger an interaction (e.g., click, key press, etc.). It will return a promise
that contains the port
object.
navigator.serial.requestPort()
.then(port => {
})
If this method is called without a user interaction, the promise will be rejected.
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 decide on the baud rate. Why is it necessary to set the baud rate first? Because Serial communication does not have a unified clock between both parties, it is essential to agree on a baud rate in advance to accurately interpret the data.
For more information on the principles of Serial communication, you can refer to my article on implementing an Arduino CO2 concentration monitor, which explains the basic principles of Serial communication: article.
To enable communication between the browser and the serial port, you can call the SerialPort.open()
method, which can accept option parameters:
const port = await serial.requestPort();
await port.open({ baudRate: 9600 });
After this, you can start listening for data coming from the serial port.
3. Calling readable.getReader()
to Obtain Serial Data
To listen for data transmitted via Serial, you can use SerialPort.readable.getReader()
to read it. One of the properties of SerialPort is readable
, which is a ReadableStream that allows communication with other stream interface objects using the getReader()
API.
After obtaining the reader, you can use reader.read()
to retrieve data. This API will return value
and done
, indicating whether the data has been fully read. The data returned from Serial is represented as UInt8Array
, meaning you will need to use DataView
or other methods to parse the data.
4. Closing the SerialPort
You can call port.close()
to close communication on the SerialPort. However, this method can only be successfully called when the readable and writable in Serial are not locked.
To ensure that there is no ongoing data transmission, you can use reader.cancel()
to forcibly cancel the data being sent, which will make the done
returned by reader.read()
turn true
. Finally, by calling reader.releaseLock()
, you can release the lock and then call port.close()
to end communication.
5. Listening for USB Connection and Disconnection Events
You can listen for connect and disconnect events on navigator.serial
to detect the current status of USB connections, allowing you to update the UI accordingly.
navigator.serial.addEventListener('connect', () => {});
navigator.serial.addEventListener('disconnect', () => {});
Reading Temperature and Humidity with the Web Serial API
Now that we've covered the interface and workings of the Web Serial API, let's try using it to connect to Arduino's temperature and humidity data and create a simple component to display it on the webpage.
Materials Needed
- Chrome 89 (other browsers currently do not support Web Serial API)
- DHT11 (DHT22 also works)
- Arduino Nano (other Arduino boards can also be used)
1. Setting Up the Arduino Circuit
This article will not delve too deeply into the circuit details of Arduino. Here, we will connect the temperature sensor to the Arduino to read temperature and humidity, then send the data to the computer via Serial. The code will 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);
}
You can find the DHT library on GitHub.
After calling the DHT.read11
method, the temperature and humidity data will be stored in DHT.temperature
and DHT.humidity
, which are then sent to the SerialPort using Serial.write
. The data is formatted as JSON for easier parsing.
2. Sending Data to Serial
Serial.write
sends data to the SerialPort. Remember to set Serial.begin(9600)
so that the Arduino knows to transmit data at a rate of 9600 bits/s.
If you simply want to test reading Serial data, you don’t need to use a sensor; you can call the Serial.write
API directly.
3. Receiving Data in the Browser
The implementation is similar to the process mentioned at the beginning of the article. Here, we use TextDecoderStream
to help decode the data (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 (the current implementation has many issues but is simple enough). This is because the packet size for Serial transmission is approximately 1 byte (depending on the data frame and stop bit), so we need a variable to temporarily hold the data until the entire JSON has been sent.
4. UI Implementation (Using Svelte as an Example)
For the UI, we implemented a Gauge component using d3-shape and d3-scale to make it visually interesting. Although the code is implemented in Svelte, it can be easily accomplished with 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>
Defining Scale
First, we define the scale
, as we want to map data values to the range of 0 to 1 for easier calculations. Next, we define angleScale
to map the range of 0 to 1 to angles between -90 to 90 degrees.
Arc
Using the d3-shape
arc API, we can get the corresponding path based on the given radius, start angle, and end angle, making it easy to place directly into the SVG.
Defining the ViewBox
Unlike a typical coordinate system, the origin of SVG is in the upper left corner. To center our Gauge component, we need to define the viewBox
for offset. The viewBox can be thought of as a window, where the first two values represent the position of the origin, and the last two values represent width and height. Thus, viewBox=10,10,5,5
means that we establish a visible area with a width of 5 and a height of 5, using (10, 10) as the origin.
Since our Gauge component has no offsets and is centered at the origin (0, 0), drawing a circle with a radius of 1 would cause it to appear outside the visible area.
To ensure the entire Gauge is displayed within the visible area, we need to offset the origin to -1, -1. The last two values, 2 and 1, set the width and height to fully occupy the available space.
5. Results
By integrating everything together, you can see the results! The complete source code can be found on GitHub.
Conclusion
It is evident that web technologies are gradually extending into hardware applications. In addition to the Serial API, there are also the NFC API and HID API, with applications becoming increasingly rich, which also means that the overall architecture will become more complex. Displaying sensor data on the screen is just one of many applications.
These standards are still in draft form, and currently, it appears that only Chrome is diligently implementing these features. There may not be a need to invest time into researching these just yet, but this also means that the realm for frontend developers has expanded from implementing UI, managing data flow, and interactions to include hardware interactions. In the future, frontend developers will have another avenue to explore.
Reference Resources
If you found this article helpful, please consider buying me a coffee ☕ It'll make my ordinary day shine ✨
☕Buy me a coffee