半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

在瀏覽器上透過 Arduino 讀取溫濕度 - Web Serial API

在瀏覽器上透過 Arduino 讀取溫濕度 - Web Serial API

Serial API 是什麼?

Google Chrome 89 推出了 Web Serial API外接設備能夠透過瀏覽器的 API 直接互動,包含 USB 設備或是有 Serial 介面的藍牙設備。這樣一來瀏覽器就能與硬體直接溝通。

以往在做類似的應用時需要另外寫 Server 串接 serial 送過來的資料,再透過 API 或是 WebSocket 的方式傳到前端,但是這樣做有一些缺點:

  • 需要伺服器當作中介層
  • 資料傳輸過程中可能會有延遲(資料傳到電腦 → 伺服器接收 → 透過 socket 傳到 client 端)
  • 可能需要另外安裝 Driver

Web Serial API 對於使用者最大的好處在於可以將外部設備透過 Serial 直接串接到網頁當中

Web Serial API 介紹

Serial API 可以從 navigator.serial 當中獲取,目前只有在 Chrome 89 有實作,其他的瀏覽器還沒有這個 API。

一個 SerialPort 串接的主要流程可以分為:

serial

  1. Serial.requestPort(options) 使用者選取對應的 serial port
  2. 回傳 SerialPort 物件
  3. 呼叫 SerialPort.open() 方法
  4. 使用 SerialPort.readable.getReader() 拿資料

1. Serial.requestPort(options)

呼叫這個方法的時候要注意使用者必須要先有互動觸發(例如點擊、鍵盤按下等事件),它會回傳一個 promise,裡頭包含了 port 物件。

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

如果沒有經過使用者互動行為就直接呼叫的話會直接拒絕這個 promise

This function must be called during a user gesture

2. 透過 SerialPort 物件與 serial 溝通

在與 Serial 溝通之前,必須要先打開 serial port 並決定 baud rate。為什麼需要先決定 baud rate?透過 Serial 通訊因為雙方沒有統一的 clock,所以必須在事先約定好 baud rate 才可以正確解讀資料。

如果想要了解更多有關於 Serial 通訊的原理,可以參考我在實作 Arduino 二氧化碳濃度監測時所寫的文章,裡頭說明了 Serial 通訊的基本原理。

要讓瀏覽器與 serial port 溝通,可以呼叫 SerialPort.open() 方法,這個方法可以傳入選項參數:

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

之後就可以開始監聽從 serial port 傳過來的資料了。

3. 呼叫 readable.getReader() 拿 Serial 資料

要監聽 Serial 傳過的資料,可以用 SerialPort.readable.getReader() 的方式讀取,SeiralPort 當中其中之一屬性是 readable,是一個 ReadableStream,可以透過 getReader() API 與其他具有 stream 介面的物件互相溝通。

拿到 reader 之後可以用 reader.read() 來拿取資料,這個 API 會回傳 value 以及 done,來表示資料是否已經讀取完畢已經傳過來的 value。Serial 回傳的資料都是以 UInt8Array 表示,代表你必須用 DataView 或其他方式來解析資料。

4. 關閉 SerialPort

在 SerialPort 當中可以呼叫 port.close() 來關閉通訊。但是必須要在 Serial 當中的 readable 與 writable 都沒有 lock 的情況下呼叫這個方法才能順利關閉。

為了確保沒有進行中的資料傳輸,你可以用 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 的溫濕度資料,實作一個簡單的元件顯示在網頁上。

準備材料

  • Chrome 89 (其他瀏覽器目前不支援 Web Serial API)
  • DHT11 (DHT22 也可以)
  • Arduino nano(其他 Arduino 板都可以)

1. 準備 Arduino 電路

這篇文章中不會描述太多有關於 Arduino 的電路細節,在這邊我們將溫度感測器接到 Arduino 讀取溫濕度,再由 Arduino 藉由 Serial 傳送資料到電腦當中。程式碼會像這樣:

#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.temperatureDHT.humidity 當中,再用 Serial.write 將資料送到 SerialPort。這邊將資料用 JSON 的格式拼貼(解析比較方便)

2. 將資料傳到 Serial

Serial.write 會將資料傳到 SerialPort。記得設定 Serial.begin(9600),Arduino 才知道要以 9600 bit/s 的速率傳送資料。

如果只是單純想試試看讀取 Serial 資料的話,其實也不需要使用感測器,直接呼叫 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 變數來存目前接受到的字串(目前的實作會有很多問題,不過夠簡單),這是因為 Serial 傳輸的封包大小為 1byte 左右(根據 data frame 跟 stop bit 而定),所以在全部的資料(JSON)傳送完之前我們需要先有一個變數當作暫存。

4. UI 實作(以 Svelte 為例)

UI 上用 d3-shaped3-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)、開始角度(startEngle)、結束角度(endAngle)給出對應的 path 方便我們直接塞到 svg 當中。

定義 viewBox

跟一般座標系不同,svg 的原點是在左上角,為了讓我們的 Gauge 元件在正中心,需要定義 viewBox 做偏移。viewbox 可以想像成視窗,前兩個值代表原點位置,後兩個值代表寬與高。因此 viewbox=10,10,5,5 的意思是指以 (10, 10) 當作原點,建立一個寬為 5、高為 5 的可視視窗。

我們的 Gauge 元件因為沒有做任何偏移,圓心在原點 0, 0。以半徑為 1 的方式畫圓的話會像下圖一樣,直接跳出可視範圍之外什麼都看不到。

viewbox default

為了讓整個 Gauge 在可視範圍顯示,我們需要將原點偏移到 -1, -1 的位置,才能讓整個 Gauge 元件在可視範圍內。後面的 2, 1 則是讓寬跟高為 2 跟 1,剛好佔滿整個可視區域。

viewbox default

5. 成果

將所有東西整合在一起就可以看到成果了!詳細的原始碼可以在 Github 上找到。

總結

可以發現,最近網頁的技術已經逐漸延伸到硬體應用當中,除了 Serial API 之外,還有 NFC API、HID API,應用變得越來越豐富的同時也代表著整個架構會變得越來越複雜。接收感測器的資料顯示在螢幕上只是其中一種應用而已。

這些標準都還在草案階段,目前看來也只有 Chrome 孜孜不倦地實作這些功能,可能也不需要那麼早投入心力研究,不過這也代表著前端工程師要探索的領域從實作 UI、管理資料流、互動等擴展到了與硬體的互動,未來前端工程師的領域又多了一條路可以走了。

參考資源

https://web.dev/serial/