Electron Fiddle + p5js + WebSerialAPIでポート選択UIをつくる(Electron v15)

# はじめに
Electron Fiddle＋p5jsでデスクトップアプリケーションができるようになりました。

https://qiita.com/mathrax-s/items/af6e3e4cdb4fe9622783

それなら、WebSerialAPIが使えるのではないかと思って、基本機能に絞ったアプリケーションを作ってみました。macOSで作ってもWindowsで動作できたり、割と安定しているなぁと思います。今回はWebSerialAPIの使い方に関しては割愛して、p5jsのDOMを使って「シリアルポートを選択するUI」をつくる記事になります。

WebSerialAPIは、Chromeブラウザでは正式に対応しているのですが、Electronではまだ実験中のようです。特に不便なのが、ブラウザでは簡単に出てくる「シリアルポートを選択するUI」が、出てこないことです。こちらのElectronのエンジニアの方のコメントが情報のもとです。

https://github.com/electron/electron/issues/22478#issuecomment-898650533

以下はmicro:bitのエディタでデバイスを選ぶところですが、Electronでこのようにデバイスを選ぼうとすると、この部分（なんという名前のUI？）を自分でつくる必要があります。そして難しかったのが、Electron v12からセキュリティ面を強化したおかげで、ポート情報の取得など直接renderer.jsから実行できない処理があり、preload.jsやmain.jsと間接的にやりとりする部分でした。


![スクリーンショット 2021-11-01 17.12.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/893fa9ab-0ddd-dd72-fc9c-ed212c75ae72.png)


# サンプルソース
完成したサンプルのソースです。<a href="https://www.electronjs.org/fiddle">Electron Fiddle</a>で使えます。
micro:bitで加速度センサXYZの数値をコンマ区切りで送信し、Electronで受信します。今回のサンプルはElecronでは受信のみですが、送信もできます。

https://github.com/mathrax-s/electron-webserial-simple

https://makecode.microbit.org/_Uf3KRD5mUTxc


# 画面イメージ
![スクリーンショット 2021-11-01 16.29.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/6b9f6d8b-d246-881f-e73c-e8d9e08ac345.png)

## 操作の順番

### 1.「ポート更新」ボタンをクリック
![スクリーンショット 2021-11-01 17.32.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/85869aed-46e9-fb5d-1de9-b7e38ee37c15.png)
「select...」メニューに、接続されたポート情報が格納される。「ポート更新」するまでメニューは空っぽです。

### 2.デバイスを選び「接続」ボタンで、デバイスと接続
![スクリーンショット 2021-11-01 17.32.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/75af808d-3006-8c17-e99a-1b1a31edc3e3.png)
![スクリーンショット 2021-11-01 17.33.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/e99da6f0-865d-bc72-cdc3-39df848fb44d.png)
うまく通信できると数値とバーが出てきます。

### 3.「切断」ボタンでデバイスと切断
![スクリーンショット 2021-11-01 17.33.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/668579/769a9ff9-89af-122a-07a5-dfeff9f2aab5.png)
シンプルな操作かなと思いますが、裏ではけっこう複雑にやりとりしています。


# main.jsとのやりとりについてメモ
基本的に、main.jsとrenderer.jsは直接のやりとりができず、preload.jsを仲介してやりとりします。ワンクッションあるので、1つの処理をイメージしていると、2つの処理が必要になります。

- main.jsとpreload.jsで通信
- preload.jsとrenderer.jsで通信

という感じです。

この2つの通信は名前が違っていて、1つめが<strong>IPC通信（Inter Process Communication=プロセス間通信）</strong>、2つめが<strong>contextBridge</strong>という名前になっています。

そしてWebSerialAPIの場合、少し変わっていて<strong>renderer.jsでポートをリクエストすると直接main.jsでイベントが発生します。また、このイベントから、呼び出し先へコールバックすることで、main.jsからrenderer.jsへ値を直接返すことができます</strong>。

WEBで調べると「renderer.jsとmain.jsは直接やりとりできない」という説明が多いので、注意です。

## A:ポート情報をメニューに表示するには
最初にrenderer.jsからポートをリクエストすることで、main.jsでイベントが発生し、シリアルポート情報を取得できます。それをrenderer.jsでメニューに表示するために、「main.jsからIPC通信で情報をpreload.jsに渡し、preload.jsからさらにcontextBridgeでrenderer.jsにその情報を渡す」という流れになります。

## B:メニューで選んだポートで接続するには
メニューを選ぶと、先ほどとは逆に、renderer.jsからmain.jsへそのポート情報を渡します。ここまでは「contextBridgeでrenderer.jsからpreload.jsへ情報を渡し、さらにIPC通信でpreload.jsからmain.jsへその情報を渡す」という流れになります。

次に接続を開始する処理ですが、これはrenderer.jsで「open」します。しかしその前に、main.jsで選んだポート情報と接続されたポートの情報が一致するかを確認しています。これは、「メニューに表示されているのに接続先がない」という状況（デバイスのケーブルが抜けたとき）を想定した対応です。

そのため、renderer.jsから「open」する前に、もう一度ポートをリクエストし、main.jsで発生したイベントの中で、メニューで選んだポート情報と、現在接続されているポート情報の一致するものを選び、そのポートIDをコールバックします。

renderer.js（202行目）　　このようにすると、main.jsでイベントが発生しコールバック経由で「port」にポートIDが入ります。

~~~renderer.js 
   port = await navigator.serial.requestPort();
~~~

renderer.js（210行目）　　その「port」で、openすることで、接続します。

~~~renderer.js
  await port.open(options);
~~~


シンプルに情報をやりとりしたいだけなのに、渡して受けとってまた渡すという双方向の処理が、何種類か出てきます。もう少しいい方法があるかもしれません。


# A:ポート情報をメニューに表示する流れ

## A-1. renderer.jsでボタンクリック
renderer.js（101行目）

~~~renderer.js
  //ポートの更新
  p.updatePortButton = () => {
    p.updatePort();
  }

  p.updatePort = async () => {
    //ドロップダウンとシリアル通信と一緒に使うと、
    //ドロップダウンが機能しなくなるので
    //やむなく強引に解決したもので、
    //いったんremoveして、新しく生成することにしています
    dropDownList.remove()

    dropDownList = p.createSelect();
    dropDownList.position(30, 40);
    dropDownList.size(170, 30);
    dropDownList.option('select...');

    //updateSerialPort()で、
    //main.jsに、シリアルポートのリクエストを行い、
    //返ってくる情報から、ドロップダウンの項目をつくります
    await updateSerialPort();
  }
~~~

## A-2. 空のポートをリクエストする
renderer.js（179行目）

~~~renderer.js
const updateSerialPort = async () => {
  try {
    let updateport = await navigator.serial.requestPort();
  } catch (e) {

  }
}
~~~

## A-3. main.jsで'select-serial-port'のイベントが発生
空のポートをリクエストすることで、main.jsでイベントが発生します。このイベントでポート情報を取得し、preload.jsに渡します。

以下は、「get_serialport_p5」というチャンネルに、引数として文字列に変換した「portList」を渡しています。

renderer.js（25行目）

~~~main.js
mainWindow.webContents.session.on('select-serial-port', (event, portList, webContents, callback) => {
    // console.log('SELECT-SERIAL-PORT FIRED WITH', portList);

    //レンダラープロセスに、送信
    webContents.send('get_serialport_p5', JSON.stringify(portList));
    event.preventDefault();

...省略
});
~~~


## A-4.preload.jsで「get_serialport_p5」を受けとり、renderer.jsの「GetPort」へ渡す
そのままcontextBridgeで、renderer.jsの「GetPort」へ渡します。

renderer.js（14行目）

~~~preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld(
  "api", {

...省略

  // シリアルポートの情報を受け取る
  // ipcRendererが受信したら、
  // その引数を、GetPortの引数にある関数に送る
  GetPort: (f) => {
    ipcRenderer.on("get_serialport_p5", (event, arg) => f(arg));
  }
})
~~~


## A-5.renderer.jsで、受けとる
renderer.jsでは「GetPort」が呼ばれると、その引数が「getSerialList」の引数として渡されるようにしています。

renderer.js（304行目）

~~~renderer.js
window.api.GetPort((arg) => getSerialList(arg));
~~~

「getSerialList」関数の中身は、

renderer.js（286行目）

~~~renderer.js
const getSerialList = (port_info) => {
  serialPortList = JSON.parse(port_info);
  // console.log(serialPortList);
  portCustomNameList = [];

  for (let i = 0; i < serialPortList.length; i++) {
    let sp = '';
    sp += serialPortList[i].portName;
    if (serialPortList[i].displayName != null) {
      sp += (' (' + serialPortList[i].displayName + ')');
    }
    app.setDropdownOption(sp);
    portCustomNameList[i] = sp;
    // console.log("port " + i + ":" + sp);
  }

}
~~~


## A-6. 渡された情報を、メニューの項目として追加
（A-5）の、getSetialList関数の中では、portListをJSONに戻しています。そしてそのJSONをもとに、ポート名をわかりやすく整えて（接続されたデバイス名をカッコで追加しています）、p5jsの中に書いてある「setDropDownOprion」関数を呼び出します。

renderer.js（297行目）

~~~renderer.js
    app.setDropdownOption(sp);
~~~

「setDropdownOption」関数の中では、メニュー項目をセットします。

renderer.js（76行目）

~~~renderer.js
    //ドロップダウン項目の追加
    //このとき前回選んだ項目があれば、それを選択する
    p.setDropdownOption = (_op) =>{
      dropDownList.option(_op);

      //最後に選んだ項目があれば、
      if(lastPortName!=null){
        //最後に選んだ項目を、自動的に選択する
        //（切断するたびに何度もドロップダウンで選ぶ手間を省くため）
        dropDownList.selected(lastPortName);
      }
    }
~~~

## Aの流れが終わり
ここまでで、ドロップダウンメニューに、接続されたデバイス名を入れることができました。メニューで選ぶことはできますが、まだ、そのポートに接続することはできません。<strong>次は、「接続」ボタンを押したときに、メニューで選んだポート情報を、main.jsに送る必要があります</strong>。

---

# B:メニューで選んだポートで接続する流れ

## B-1. 「接続」ボタンをクリックする
ポートがオープンしてたらいったん切断して、「connectSerialPort」関数を呼びます。

そのあと、「p.updatePort();」でメニュー項目をアップデートしていますが、これはもう少し改善できるかもしれません。このメニュー項目のアップデートは、やや強引な方法で、ドロップダウンメニューとシリアル通信と一緒に使うと、ドロップダウンメニューが機能しなくなってしまったので、やむなくいったんremoveして、新しく生成しなおすことにした次第です。

renderer.js（153行目）

~~~renderer.js
  //ポートの接続
  p.connectPortButton = async () => {
    //ポートが使われていたら切断する
    if (port) {
      await disconnectSerialPort();
      port = null;
    }
    //シリアルポートと接続する
    await connectSerialPort();
    p.updatePort();
  }
~~~


## B-2. 現在メニューで選ばれたポート情報を取得する

p5jsのDOMでは「dropDownList.changed(p.dropdownChanged)」のように、メニューが選ばれた時に呼び出す関数を指定できるはずなのですが、シリアル通信と一緒につかうとうまく機能しませんでした。そこで、p5jsのdraw関数で毎フレームごとにチェックするようにしました。

renderer.js（56行目）

~~~renderer.js
p.draw = () => {
    p.background(255, 200, 100);

    //ドロップダウン項目の選択されたものをチェック
    p.dropdownChecking();

...省略
}
~~~

renderer.js（89行目）

~~~renderer.js
    //ドロップダウン項目をチェックする
    p.dropdownChecking = () =>{
      nowSelect = dropDownList.value();
      //もし以前に選んだ項目とちがうときは、
      if(nowSelect != lastSelect){
        //ポート選択情報もアップデートする
        p.portSelectUpdate();
        lastSelect = nowSelect;
      }
    }
~~~

## B-3. メニューが変更された瞬間に、main.jsへ情報を送る

renderer.js（124行目）

~~~renderer.js
    //ポート選択情報をアップデートする
    p.portSelectUpdate=() =>{
      if(nowSelect != null){
        //選んだポート名が、JSONにあるとき、そのJSONをメインに送る
        if( (nowSelect != 'select...')){
          let index = null;
          for(let i=0; i<serialPortList.length; i++){  
            if(serialPortList != null){
              //今選んだドロップダウン項目に、portNameが含まれているかどうかチェック
              let result = nowSelect.indexOf(serialPortList[i].portName);
              if(result !== -1){
                index = i;
                // console.log("index : "+i)
              }
            }
          }
          if(index != null){
					  const sendData = serialPortList[index];
            //main.jsに、選んだポートのJSONを送信する
					  window.api.SetPort(sendData);
            //いま選んだポートの名前をlastPortNameに格納
            //（ドロップダウンの項目を追加するとき、
            // lastPortNameが存在すればそれを選ぶようにするため）
            lastPortName = portCustomNameList[index];
          }
        }
      }
    }
~~~

実際にmain.jsへ送っているのは、148 行目の「window.api.SetPort(sendData);」です。contextBridgeでpreload.jsの「Setport」が呼び出され、引数としてsendDataが渡されます。

preload.js（14行目）

~~~preload.js
const {contextBridge, ipcRenderer} = require('electron')
contextBridge.exposeInMainWorld(
  "api", {
    // シリアルポートの番号を送る
    SetPort: (data) => ipcRenderer.send("set_serialport_p5", data),
    
...省略

})
~~~

そのあと、main.jsのipcMain.onで「set_serialport_p5」チャンネルに渡された引数が届きます。その引数をそのまま「selectedPortInfo」変数に入れます。つまり「selectedPortInfo」は、メニューで選んだポートの情報になります。

main.js（76行目）

~~~main.js
const {ipcMain} = require('electron')
ipcMain.on("set_serialport_p5", (event, arg) => {
  // console.log(arg)
  selectedPortInfo = arg;
});
~~~

## B-4. もう一度、空のポートをリクエストする

renderer.js(202行目）

~~~renderer.js
  try {
    port = await navigator.serial.requestPort();
  }
  catch (e) {
    return
  }
~~~


## B-5. main.jsの発生イベントで、現在接続されたポート情報と一致するものを調べる

main.js（32行目）

~~~main.js
    let selectedPort = portList.find((device) => {
      //ipcRendererから送られてきた、
      //選択したポートのJSONをチェックする
      //シリアル番号、USBベンダーID、プロダクトIDが一致するものがあれば、true
      if (selectedPortInfo != null) {
        if (device.serialNumber == selectedPortInfo.serialNumber &&
          device.vendorId == selectedPortInfo.vendorId &&
          device.productId == selectedPortInfo.productId) {
          return true
        }
      }
    });
~~~

## B-6. ポートのIDをコールバックする
（B-5）で一致したものがあれば、ポートIDを返します。返される先は、renderer.jsの「port = await navigator.serial.requestPort();」のところで、portという変数にポートIDが格納されることになります。

main.js（44行目）

~~~main.js
    if (!selectedPort) {
      // 何もコールバックしない
      callback('')
    } else {
      // シリアルポートのIDをコールバック
      callback(selectedPort.portId)
    }
~~~

renderer.js(203行目） 上記のコールバックを受けて、port変数にポートIDが格納されます。

~~~renderer.js
  	port = await navigator.serial.requestPort();
~~~


## B-7. ポートをオープンする
（B-6）の「port」に対して、「open」することで、接続することができます。引数として「options」を渡すことで、ボーレートやパリティ、ストップビットなどを指定できます。

renderer.js(209行目）

~~~renderer.js
  try {
    await port.open(options);
  }
  catch (e) {
    return
  }
~~~

「options」では、シリアルポートのボーレートやパリティ、ストップビットなどを設定できます。

renderer.js(192行目）

~~~renderer.js
  const options = {
    baudRate: 115200,
    dataBits: 8,
    parity: 'none',
    stopBits: 1,
    flowControl: 'none',
  };
~~~

## Bの流れ終わり
これでようやく接続できました！

---

# 感想など
その後のシリアル通信の受信や送信については、こちらのCodeLabsのプログラムを参考にさせていただきましたので、ここでの解説は割愛させていただきます。

https://codelabs.developers.google.com/codelabs/web-serial#2


また、以下のリンクは、上記のCodeLabsのプログラムを参考に、ブラウザのp5jsで使えるよう、過去に作成したものです。こちらのほうが今回のサンプルに近いです。

https://editor.p5js.org/kuzeshozo/sketches/ahXeFwTB3

ブラウザのWebSerialAPIは、今回の記事に比べればとてもシンプルに使えます。また、ElectronでのWebSerialAPIでも、ポートを決め打ちにするなら、もっと簡単です（ポートをリクエストするときにフィルターとして、USBベンダーIDなどを指定すると、決め打ちにできます）。ただ、これでは実用的ではないので、今回のようなドロップダウンメニューを作りました。

自分で使ってみた感想としては、macOSでもWindowsでも問題なく動いてくれたので少し驚きました（ボタンの文字レイアウト崩れとかもなく）。ただ、手持ちのWindowsが古いせいなのか、Windowsでは切断するときに少しフリーズしてしまい、その間、おかしくなったかと自分でも戸惑ってしまいました。

なので改良案として、切断が終わるまで、「切断ボタン」を押せなくして、その上にグルグル回るGIFアニメを表示しようと考えました。ところが、Electron Fiddleでは、jsやhtmlやcss以外のファイル形式を作成することができず、GIFファイルは作成も保存もできませんでした。Electron Fiddleはフォルダも分けることもできないので、そこが難点だなぁと思います。

結局その改良版は、Electron Fiddleは使わず、VS-CodeでElectronの環境を整えて開発中です。やっぱりVS-Codeのほうがやりやすい気がしてます。

---

# 参考にしたサイト

- WebSerialAPIについて参考にさせていただきました

https://codelabs.developers.google.com/codelabs/web-serial#2


- ElectronとWebSerialAPIについて参考にさせていただきました

https://gist.github.com/jkleinsc/284893c7f01d3cb4559508ca06919481#file-main-js-L21


- ElectronでWebSerialの「ポート選択のUIがありません」という情報

https://github.com/electron/electron/issues/22478#issuecomment-898650533


- WebSerialAPIとp5js

https://editor.p5js.org/kuzeshozo/sketches/ahXeFwTB3