はじめに
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と間接的にやりとりする部分でした。
サンプルソース
完成したサンプルのソースです。Electron Fiddleで使えます。
micro:bitで加速度センサXYZの数値をコンマ区切りで送信し、Electronで受信します。今回のサンプルはElecronでは受信のみですが、送信もできます。
画面イメージ
操作の順番
1.「ポート更新」ボタンをクリック
「select...」メニューに、接続されたポート情報が格納される。「ポート更新」するまでメニューは空っぽです。
2.デバイスを選び「接続」ボタンで、デバイスと接続
3.「切断」ボタンでデバイスと切断
シンプルな操作かなと思いますが、裏ではけっこう複雑にやりとりしています。
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が入ります。
port = await navigator.serial.requestPort();
renderer.js(210行目) その「port」で、openすることで、接続します。
await port.open(options);
シンプルに情報をやりとりしたいだけなのに、渡して受けとってまた渡すという双方向の処理が、何種類か出てきます。もう少しいい方法があるかもしれません。
A:ポート情報をメニューに表示する流れ
A-1. renderer.jsでボタンクリック
renderer.js(101行目)
//ポートの更新
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行目)
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行目)
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行目)
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行目)
window.api.GetPort((arg) => getSerialList(arg));
「getSerialList」関数の中身は、
renderer.js(286行目)
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行目)
app.setDropdownOption(sp);
「setDropdownOption」関数の中では、メニュー項目をセットします。
renderer.js(76行目)
//ドロップダウン項目の追加
//このとき前回選んだ項目があれば、それを選択する
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行目)
//ポートの接続
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行目)
p.draw = () => {
p.background(255, 200, 100);
//ドロップダウン項目の選択されたものをチェック
p.dropdownChecking();
...省略
}
renderer.js(89行目)
//ドロップダウン項目をチェックする
p.dropdownChecking = () =>{
nowSelect = dropDownList.value();
//もし以前に選んだ項目とちがうときは、
if(nowSelect != lastSelect){
//ポート選択情報もアップデートする
p.portSelectUpdate();
lastSelect = nowSelect;
}
}
B-3. メニューが変更された瞬間に、main.jsへ情報を送る
renderer.js(124行目)
//ポート選択情報をアップデートする
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行目)
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行目)
const {ipcMain} = require('electron')
ipcMain.on("set_serialport_p5", (event, arg) => {
// console.log(arg)
selectedPortInfo = arg;
});
B-4. もう一度、空のポートをリクエストする
renderer.js(202行目)
try {
port = await navigator.serial.requestPort();
}
catch (e) {
return
}
B-5. main.jsの発生イベントで、現在接続されたポート情報と一致するものを調べる
main.js(32行目)
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行目)
if (!selectedPort) {
// 何もコールバックしない
callback('')
} else {
// シリアルポートのIDをコールバック
callback(selectedPort.portId)
}
renderer.js(203行目) 上記のコールバックを受けて、port変数にポートIDが格納されます。
port = await navigator.serial.requestPort();
B-7. ポートをオープンする
(B-6)の「port」に対して、「open」することで、接続することができます。引数として「options」を渡すことで、ボーレートやパリティ、ストップビットなどを指定できます。
renderer.js(209行目)
try {
await port.open(options);
}
catch (e) {
return
}
「options」では、シリアルポートのボーレートやパリティ、ストップビットなどを設定できます。
renderer.js(192行目)
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