ブラウザでArduinoを使用して温湿度を読み取る方法 - Web Serial API

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

Serial API とは?

Google Chrome 89 では、Web Serial API が導入され、外部デバイスがブラウザの API を通じて直接やり取りできるようになりました。これには USB デバイスやシリアルインターフェースを持つ Bluetooth デバイスが含まれます。これにより、ブラウザがハードウェアと直接コミュニケーションを取ることができるようになります。

従来、類似のアプリケーションを作成する際には、サーバーを別途用意し、シリアルから送られてくるデータを接続し、API や WebSocket を介してフロントエンドに送信する必要がありましたが、この方法にはいくつかの欠点がありました:

  • サーバーを中介層として必要とする
  • データ転送中に遅延が発生する可能性がある(データがコンピュータに届く → サーバーが受信 → ソケットを介してクライアントに送信)
  • 別途ドライバーのインストールが必要な場合がある

Web Serial API の最大の利点は、外部デバイスをシリアルで直接ウェブページに接続できることです。

Web Serial API の紹介

Serial API は navigator.serial から取得できます。現在のところ、Chrome 89 でのみ実装されており、他のブラウザではこの API はまだ利用できません。

シリアルポート接続の主なプロセスは次のように分かれます:

serial

  1. Serial.requestPort(options) でユーザーが対応するシリアルポートを選択
  2. SerialPort オブジェクトが返される
  3. SerialPort.open() メソッドを呼び出す
  4. SerialPort.readable.getReader() を使用してデータを取得

1. Serial.requestPort(options)

このメソッドを呼び出す際は、ユーザーが事前にインタラクションを行っている必要があります(例えば、クリックやキーボードの押下などのイベント)。このメソッドは port オブジェクトを含む promise を返します。

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

ユーザーのインタラクションなしにこのメソッドを直接呼び出すと、promise は拒否されます。

この関数はユーザーのジェスチャー中に呼び出される必要があります。

2. SerialPort オブジェクトを介してシリアルと通信

シリアルと通信する前に、シリアルポートを開き、ボーレートを決定する必要があります。なぜボーレートを事前に決める必要があるのでしょうか?シリアル通信では、双方に統一されたクロックがないため、正確にデータを解読するためには事前にボーレートを合意しておかなければなりません。

シリアル通信の原理についてもっと知りたい場合は、Arduino の二酸化炭素濃度モニタリングの実装時に書いた記事を参照してください。そこではシリアル通信の基本原理が説明されています。

ブラウザがシリアルポートと通信するには、SerialPort.open() メソッドを呼び出します。このメソッドにはオプションの引数を渡すことができます:

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

その後、シリアルポートから送信されるデータをリスニングすることができます。

3. readable.getReader() を呼び出してシリアルデータを取得

シリアルから送信されたデータをリスニングするには、SerialPort.readable.getReader() を使用して読み取ります。SerialPort の属性の一つである readable は、ReadableStream であり、getReader() API を通じて他のストリームインターフェースを持つオブジェクトとやり取りできます。

リーダーを取得した後は、reader.read() を使用してデータを取得できます。この API は、データがすでに読み取られたかどうかを示す value と done を返します。シリアルから返されるデータはすべて UInt8Array で表されるため、DataView や他の方法でデータを解析する必要があります。

4. SerialPort を閉じる

SerialPort 内で port.close() を呼び出して通信を閉じることができます。ただし、シリアル内の readable と writable がロックされていない場合にこのメソッドを呼び出す必要があります。

進行中のデータ転送がないことを確認するために、reader.cancel() を使用して送信中のデータを強制的にキャンセルできます。これにより、reader.read() から返される done が true になります。最終的に、reader.releaseLock() を呼び出してロックを解除し、port.close() を呼び出して通信を終了します。

5. USB の接続および切断イベントをリスン

navigator.serial では、接続と切断のイベントを監視することで、USB が挿入されているか切断されているかの状態を検出し、UI に表示できます。

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

Web Serial API を使用して温湿度を読み取る

Web Serial API のインターフェースと動作原理を理解したら、次に Web Serial API を使用して Arduino の温湿度データを接続し、ウェブページ上に簡単なコンポーネントを表示してみましょう。

準備材料

  • Chrome 89 (他のブラウザは現在 Web Serial API をサポートしていません)
  • DHT11 (DHT22 でも可)
  • Arduino nano(他の Arduino ボードでも可)

1. Arduino 回路の準備

この記事では、Arduino の回路の詳細についてはあまり説明しません。ここでは、温度センサーを Arduino に接続し、温湿度を読み取り、Arduino からシリアルを通じてデータをコンピュータに送信します。コードは次のようになります:

#include <dht.h>

dht DHT;

#define DHT11_PIN 7

void setup(){
  // ボーレートを 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.temperatureDHT.humidity に格納され、その後 Serial.write でデータを SerialPort に送信します。データは JSON フォーマットで組み立てられています(解析が容易になります)。

2. データをシリアルに送信

Serial.write はデータを SerialPort に送信します。Serial.begin(9600) を設定することを忘れないでください。これにより、Arduino が 9600 bit/s の速さでデータを送信することができます。

シリアルデータの読み取りを試すだけの場合は、センサーを使用せずに Serial.write API を直接呼び出すことも可能です。

3. ブラウザでデータを受信

実装は、記事の冒頭で説明したプロセスとほぼ同様です。ここでは、TextDecoderStream を使用してデータをデコードします(文字列を送信していますので)。

async function requestSerialPort() {
  const serial = navigator.serial;
  // 目標のシリアルポートを選択
  const port = await serial.requestPort();
  // ボーレートを 9600 に設定
  await port.open({ baudRate: 9600 });
  // ビットデータを文字列にデコード
  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 変数を使用しています(現在の実装には多くの問題がありますが、単純さは保たれています)。シリアル転送のパケットサイズは約 1byte であるため(データフレームやストップビットに依存)、すべてのデータ(JSON)が送信されるまで、まずは一時的な変数を用意しておく必要があります。

4. UI 実装(Svelte の例)

UI では、d3-shape および d3-scale を使用してゲージコンポーネントを実装し、見た目が単調にならないようにしています。コードは 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 を定義します。データ値を 0 と 1 にマッピングすると計算がしやすくなります。次に angleScale を定義し、0 と 1 を -90° から 90° にマッピングします。

arc

d3-shape の arc API を使用して、指定された半径(radius)、開始角度(startAngle)、終了角度(endAngle)に基づいて対応するパスを提供し、それを直接 SVG に挿入できます。

viewBox の定義

一般的な座標系とは異なり、SVG の原点は左上隅にあります。ゲージコンポーネントを中央に配置するために、viewBox を定義してオフセットを設定する必要があります。viewBox はウィンドウのように考えられ、最初の二つの値は原点の位置を、後の二つの値は幅と高さを表します。つまり、viewBox=10,10,5,5 は、(10, 10) を原点として、幅が 5、高さが 5 の視覚ウィンドウを作成することを意味します。

私たちのゲージコンポーネントでは、オフセットを行っていないため、円の中心は原点 (0, 0) になります。半径 1 で円を描くと、下の図のように、視覚範囲を超えて表示されてしまい、何も見えなくなります。

viewbox default

ゲージ全体が視覚範囲に表示されるようにするためには、原点を (-1, -1) の位置にオフセットする必要があります。その後の 2, 1 は、幅と高さをそれぞれ 2 と 1 に設定し、ちょうど視覚領域を満たすようになります。

viewbox default

5. 成果

すべての要素を統合すれば、成果を見ることができます!詳細なソースコードは Github で見つけられます。

まとめ

最近、ウェブ技術がハードウェアアプリケーションに徐々に広がっていることがわかります。Serial API に加えて、NFC API、HID API などがあり、アプリケーションがますます豊富になる一方で、全体の構造が複雑化していくことを意味します。センサーから受け取ったデータを画面に表示することは、その一例にすぎません。

これらの標準はまだ草案段階にあり、現時点では Chrome だけがこれらの機能を熱心に実装しています。まだ早く研究に取り組む必要はないかもしれませんが、これはまた、フロントエンドエンジニアが UI の実装、データフローの管理、インタラクションなどからハードウェアとのインタラクションに広がる新たな領域を探索することを意味しています。将来的にはフロントエンドエンジニアの領域がさらに広がることでしょう。

参考リソース

https://web.dev/serial/

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee