1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-11-01

はじめに

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

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

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

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

スクリーンショット 2021-11-01 17.12.03.png

サンプルソース

完成したサンプルのソースです。Electron Fiddleで使えます。
micro:bitで加速度センサXYZの数値をコンマ区切りで送信し、Electronで受信します。今回のサンプルはElecronでは受信のみですが、送信もできます。

画面イメージ

スクリーンショット 2021-11-01 16.29.16.png

操作の順番

1.「ポート更新」ボタンをクリック

スクリーンショット 2021-11-01 17.32.37.png
「select...」メニューに、接続されたポート情報が格納される。「ポート更新」するまでメニューは空っぽです。

2.デバイスを選び「接続」ボタンで、デバイスと接続

スクリーンショット 2021-11-01 17.32.49.png
スクリーンショット 2021-11-01 17.33.10.png
うまく通信できると数値とバーが出てきます。

3.「切断」ボタンでデバイスと切断

スクリーンショット 2021-11-01 17.33.23.png
シンプルな操作かなと思いますが、裏ではけっこう複雑にやりとりしています。

main.jsとのやりとりについてメモ

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

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

という感じです。

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

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

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の流れが終わり

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


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のプログラムを参考にさせていただきましたので、ここでの解説は割愛させていただきます。

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

ブラウザの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について参考にさせていただきました

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

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

  • WebSerialAPIとp5js

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?