ブラウザ上でArduino経由で温湿度を読み取る - Web Serial API
Serial APIとは?
Google Chrome 89でWeb Serial APIが導入され、USBデバイスやシリアルインターフェースを持つBluetooth機器などの外部機器が、ブラウザのAPIを通じて直接やり取りできるようになった。これにより、ブラウザがハードウェアと直接通信できるようになったのだ。
以前、同様のアプリケーションを作成する際には、シリアルから送られてくるデータを中継するサーバーを別途記述し、APIやWebSocket経由でフロントエンドに送信する必要があった。しかし、これにはいくつかの欠点があった:
- 中間層としてのサーバーが必要
- データ転送プロセスで遅延が発生する可能性がある(データがPCに届く → サーバーが受信 → ソケット経由でクライアント側に送信)
- ドライバを別途インストールする必要があるかもしれない
Web Serial APIのユーザーにとっての最大のメリットは、外部機器をシリアル経由でWebページに直接接続できることにある。
Web Serial APIの紹介
Serial APIは navigator.serial から取得できる。現在実装されているのはChrome 89のみで、他のブラウザにはまだこのAPIがない。
SerialPortの接続における主なフローは以下の通りだ:

Serial.requestPort(options)ユーザーが対応するシリアルポートを選択するSerialPortオブジェクトが返されるSerialPort.open()メソッドを呼び出すSerialPort.readable.getReader()を使ってデータを取得する
1. Serial.requestPort(options)
このメソッドを呼び出す際には、ユーザーのインタラクション(クリックやキーボードの押下イベントなど)によってトリガーされる必要があることに注意したい。これは port オブジェクトを含む promise を返す。
navigator.serial.requestPort()
.then(port => {
})
ユーザーのインタラクションなしに直接呼び出した場合、この promise は拒絶(reject)される。
This function must be called during a user gesture
2. SerialPortオブジェクトを介してシリアルと通信する
シリアルと通信する前に、まずシリアルポートを開いてボーレート(baud rate)を決める必要がある。なぜ事前にボーレートを決める必要があるのだろうか?シリアル通信では双方に統一されたクロックがないため、事前にボーレートを取り決めておかなければデータを正しく解釈できないからだ。
シリアル通信の原理についてもっと詳しく知りたい場合は、僕がArduinoで二酸化炭素濃度モニタリングを実装した際に書いた記事を参照してほしい。シリアル通信の基本原理を解説している。
ブラウザとシリアルポートを通信させるには、SerialPort.open() メソッドを呼び出せばよい。このメソッドにはオプションパラメータを渡すことができる:
const port = await serial.requestPort();
await port.open({ baudRate: 9600 });
これで、シリアルポートから送られてくるデータの監視を開始できる。
3. readable.getReader() を呼び出してシリアルデータを取得する
シリアルから送信されたデータを監視するには、SerialPort.readable.getReader() を使って読み取ることができる。SerialPortのプロパティの1つである readable は ReadableStream であり、getReader() APIを通じてストリームインターフェースを持つ他のオブジェクトとやり取りできる。
readerを取得した後は、reader.read() でデータを取得できる。このAPIは、データが読み取り完了したかどうかを示す done と、送信されてきた value を返す。シリアルから返されるデータはすべて UInt8Array で表されるため、DataView やその他の方法でデータを解析する必要がある。
4. SerialPortを閉じる
SerialPortでは、port.close() を呼び出して通信を終了できる。ただし、シリアルの readable と writable の両方にロックがかかっていない状態でこのメソッドを呼び出さないと、正常に閉じることができない。
進行中のデータ転送がないことを確認するために、reader.cancel() を使って送信中のデータを強制的にキャンセルできる。これにより、reader.read() が返す done が true になる。最後に reader.realeaseLock() でロックを解放した後に port.close(()) を呼び出して通信を終了する。
5. USBの抜き差しイベントを監視する
navigator.serial で connect イベントと disconnect イベントをリッスンすることで、現在USBが接続されているか取り外されているかの状態を検知し、UIに反映させることができる。
navigator.serial.addEventListener('connect', () => {});
navigator.serial.addEventListener('disconnect', () => {});
Web Serial APIで温湿度を読み取る
Web Serial APIのインターフェースと動作原理が分かったところで、次はWeb Serial APIを使ってArduinoの温湿度データを連携させ、Webページ上に表示するシンプルなコンポーネントを実装してみよう。
準備するもの
- Chrome 89 (他のブラウザは現在Web Serial APIに未対応)
- DHT11 (DHT22でも可)
- Arduino nano(他のArduinoボードでも可)
1. Arduinoの回路を準備する
この記事ではArduinoの回路に関する詳細にはあまり触れないが、ここでは温度センサーをArduinoに接続して温湿度を読み取り、Arduinoからシリアル経由でPCにデータを送信する。コードは以下のようになる:
#include <dht.h>
dht DHT;
#define DHT11_PIN 7
void setup(){
// 設定 baud rate 為 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);
}
DHTライブラリはGitHubで見つけることができる
DHT.read11 メソッドを呼び出すと、温湿度データが DHT.temperature と DHT.humidity に格納されるので、Serial.write を使ってデータをSerialPortに送信する。ここでは(パースしやすいように)データをJSON形式で構築している。
2. データをシリアルに送信する
Serial.write はデータをSerialPortに送信する。Arduinoが9600 bit/sのレートでデータを送信することを認識できるよう、Serial.begin(9600) を設定するのを忘れないようにしよう。
単にシリアルデータの読み取りを試してみたいだけであれば、センサーを使う必要はなく、直接 Serial.write APIを呼び出すだけでも構わない。
3. ブラウザでデータを受信する
実装は記事の冒頭で触れたフローとほぼ同じで、ここではデコードのために TextDecoderStream を使用している(送信しているのが文字列であるため)。
async function requestSerialPort() {
const serial = navigator.serial;
// 選擇目標 Serial Port
const port = await serial.requestPort();
// 設定 baud rate 為 9600
await port.open({ baudRate: 9600 });
// 將 bit data 解碼為文字
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);
}
};
コード内では現在受信した文字列を保持するために buffer 変数を使っている(現在の実装には問題も多いが、非常にシンプルだ)。これはシリアル転送のパケットサイズが約1バイト(データフレームとストップビットによる)であるためで、すべてのデータ(JSON)が送信されきるまで、バッファとして一時保存する変数が必要になるからだ。
4. UIの実装(Svelteの例)
UI面では、見た目が単調にならないように d3-shape と d3-scale を使ってゲージ(Gauge)コンポーネントを実装した。コードはSvelteで実装しているが、他のフロントエンドフレームワークや、フレームワークを一切使わなくても簡単に実装できる:
<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>
scaleの定義
まず scale を定義する。計算しやすいようにデータ値を0〜1にマッピングしたい。次に angleScale を定義し、0〜1を-90度〜90度にマッピングする。
arc
d3-shape のarc APIを使うと、指定した半径(radius)、開始角度(startAngle)、終了角度(endAngle)に基づいて対応するpathを生成してくれるため、svgにそのまま埋め込むのに便利だ。
viewBoxの定義
通常の座標系とは異なり、svgの原点は左上にある。ゲージコンポーネントを中央に配置するためには、viewBox を定義してオフセットする必要がある。viewBoxはウィンドウのようなものだとイメージしてほしい。最初の2つの値は原点位置を表し、後ろの2つの値は幅と高さを表す。したがって、viewbox=10,10,5,5 は (10, 10) を原点として、幅5、高さ5の可視ウィンドウを作成することを意味する。
僕たちのゲージコンポーネントは何もオフセットしていないため、円の中心が原点 0, 0 にある。半径1で円を描くと下図のようになり、可視領域の外にはみ出して何も見えなくなってしまう。

ゲージ全体を可視範囲内に表示するために、原点を -1, -1 の位置にオフセットする必要がある。後ろの 2, 1 は幅と高さをそれぞれ2と1にして、可視領域全体をちょうど埋めるようにするためだ。

5. 成果
すべてを統合すれば成果を確認できる!詳細なソースコードは Github で確認できる。
まとめ
最近のWeb技術はハードウェアへの応用にまで徐々に広がっていることが分かる。Serial APIのほかにも、NFC APIやHID APIなどがあり、アプリケーションがますます豊かになる一方で、アーキテクチャ全体がより複雑になっていくことも意味している。センサーのデータを受信して画面に表示するのは、その応用の1つにすぎない。
これらの標準はまだドラフト段階であり、現状ではChromeだけが熱心に実装を進めている状態だ。それほど早くから全力を注いで研究する必要はないかもしれないが、これはフロントエンドエンジニアが探求すべき領域が、UIの実装やデータフローの管理、インタラクションなどからハードウェアとのインタラクションへと拡大したことを示している。将来のフロントエンドエンジニアのキャリアパスにおいて、また新たな選択肢が1つ増えたと言えるだろう。
参考リソース
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。