Web Serial API の接続ライフサイクルのハンドリング

JINS Advent calendar 7日目も引き続きウェアラブルチームの菰田が担当します。

## Web Serial API

さて、CircuitPythonで良い感じにコーディングしてプロトタイプができたら、他の人に使ってもらうフェーズがきます。しかしJINSは文系の会社なので「設定変えたい時はterminalからシリアル通信で変更してね」と言っても会話が終了してしまうため、簡単にシリアル通信を行える仕組みの需要が高いです。そんな時に強力な味方になってくれるのが [Web Serial API](https://developer.mozilla.org/ja/docs/Web/API/Web_Serial_API) です。Chrome(Edge)でしか動作しないという制約はあるものの、Windows/MacOS/ChromeOSでブラウザさえあれば安定して動作するので、最も敷居の低いシリアル通信手段であると言えます。

Web Serial API、大変便利なのですが、ネットによくあるサンプルコードだと手動切断や接続ロストのハンドリングを行っていないコード（再接続するにはリロードが必要）が多かったので、切断まで考慮に入れたコードを書いてみました。

## コード

もうほぼ出オチなのですが、(1)ボタンを押して切断した、(2)機器と接続中にケーブルを抜いた、の2種類に反応できるフローにします。

```sample.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Web Serial API Console</title>
</head>

<body>
    <h1>Web Serial API Console</h1>
    <button onclick="onConnectButtonClick()" id="connectButton">Connect</button>
    <br />

    <input type="text" id="sendInput" />
    <input type="button" value="Send" onclick="sendSerial();" />
    <br />
    <textarea cols="80" rows="6" id="outputArea" readonly></textarea>
</body>

<script>
    let port;
    let reader;
    let writer;
    let connectionStatus = false;

    async function onConnectButtonClick() {
        //切断時にクリックされた場合
        if(connectionStatus == false){
            try {
                port = await navigator.serial.requestPort();
                await port.open({ baudRate: 9600 });

                //errorになったらcatch (error)に飛ぶ
                connectionStatus = true;
                console.log("connected")
                document.getElementById('connectButton').innerHTML = "Disconnect"

                reader = port.readable.getReader();
                
                //繋がっている最中の処理
                //物理的に突然抜いた時port.readableがfalseになる
                //ボタンを押してCloseした時はconnectionStatusがFalseにされる（切断処理は2回目のクリック側で行われる）
                while (connectionStatus && port.readable) {
                    try {
                        while (true) {
                            const { value, done } = await reader.read();
                            if (done) {
                                addSerial("Canceled\n");
                                break;
                            }
                            const inputValue = new TextDecoder().decode(value);
                            addSerial(inputValue);
                        }
                    } catch (error) {
                        addSerial("Error Read: " + error + "\n");
                    }
                }
            } catch (error) {
                addSerial("Error Open: " + error + "\n");
            }
        }

        //接続時にクリックされた場合、もしくはconnectionStatus == trueの時に突然引き抜かれて上のループを抜けて流れてきた場合
        if(connectionStatus == true){
            connectionStatus = false;

            //readerのリリース(writerは都度リリースしている)
            reader.cancel();//突然引き抜かれたケースではエラーになる
            reader.releaseLock();
            await port.close()
            console.log("disconnected")
            document.getElementById('connectButton').innerHTML = "Connect"
        }
    }

    //結果をmulti-inputに反映
    function addSerial(msg) {
        const textarea = document.getElementById('outputArea');
        textarea.value += msg;
        textarea.scrollTop = textarea.scrollHeight;
    }

    //データの送信
    async function sendSerial() {
        const text = document.getElementById('sendInput').value;
        document.getElementById('sendInput').value = "";

        const encoder = new TextEncoder();
        writer = port.writable.getWriter();
        await writer.write(encoder.encode(text + "\n"));

        writer.releaseLock();
    }
</script>
</html>
```

### 説明

#### 接続・切断ボタン

接続に関しては`try`のところから淡々と実行されるだけです。
問題は切断で、パターンとして(1)ボタンを押した、(2)ケーブルを抜いたの2種類があります。**機器からクローズされた**場合は（２）と同じことが起こります。
(1)の時は`if(connectionStatus == false)`を通過、(2)の時は`port.readable`が`false`になり`while`を抜けることで切断時の`if(connectionStatus == true)`内が実行されます。

#### イベントリスナ

実は自分でもあまり良いコードに見えないなと思いつつも、MDNに乗っている以下のイベントリスナが **動作する時と動作しない時がある** ようでしたので、UIの変更を条件分岐内で処理しています。何か認識間違っていたらご指摘ください、、。

```
navigator.serial.addEventListener("connect", (e) => {
  // `e.target` に接続する、すなわち利用可能なポートのリストに加えます。
});

navigator.serial.addEventListener("disconnect", (e) => {
  // `e.target` を利用可能なポートのリストから外します。
});
```

### 最後に

わたくし、Web Bluetoothのほうもかなりいじっていた時期もあったのですが、Web Serial のほうが圧倒的に安定しています、、、。やっぱり有線は偉大ですね！

JINS Advent calendar 8日目はフワッとしたこと大好き @Takuma_Sato がお送りします。