ひょんなことからFirmataとJohnny-fiveで行うような処理を自作したという話です。
Macに接続したArduinoに対して、ピン番号と0〜255の値をシリアル通信で送ることで、Arduinoがその通りに出力します。
以前つくった、ディマーをOSCで制御するアプリのシリアル通信版のような感じです。
実装にはNext.jsのElectronテンプレートを使いました。
未だ、この問題が解決していないため、
electron-nextは自分のリポジトリのものを使っています。
仕様
システム図は描くまでもないですが、ArduinoとMacをUSBで繋ぐだけです。
ArduinoのピンにLEDなどを接続しないと動作が確認できないですが、そこは別の話なので端折ります。
シリアル通信の仕様としては、
- ピン番号(一桁の16進数)
- PWMの値(0〜255の整数 ※ 0埋めの3桁)
の計4文字の文字列で送信します。
- 3番ピンに255を送りたい場合は、「3255」
- 10番ピンに10を送りたい場合は、「A010」
といった感じです。
Arduino Leonardoでは、
- 3番ピン
- 5番ピン
- 6番ピン
- 9番ピン
- 10番ピン
- 11番ピン
- 13番ピン
が、PWMの出力が可能なピンなので、それらに値を送れるようにします。
ソースコード(抜粋)
arduino.ino(Leonardoに書き込むことを想定)
int p3 = 3; // 3 int p5 = 5; // 5 int p6 = 6; // 6 int p9 = 9; // 9 int p10 = 10; // A int p11 = 11; // B int p13 = 13; // D int recieveByte = 0; String buffer = ""; void setup() { Serial.begin(115200); pinMode(p3, OUTPUT); pinMode(p5, OUTPUT); pinMode(p6, OUTPUT); pinMode(p9, OUTPUT); pinMode(p10, OUTPUT); pinMode(p11, OUTPUT); pinMode(p13, OUTPUT); analogWrite(p3, 0); analogWrite(p5, 0); analogWrite(p6, 0); analogWrite(p9, 0); analogWrite(p10, 0); analogWrite(p11, 0); analogWrite(p13, 0); } void loop() { buffer = ""; while (Serial.available() > 0) { recieveByte = Serial.read(); if (recieveByte == (int)'\n') { break; } buffer.concat((char)recieveByte); } if (buffer.length() == 4) { // ex) A100 -> 10番ピンが100 if (buffer[0] == '3') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p3, level); } else if (buffer[0] == '5') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p5, level); } else if (buffer[0] == '6') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p6, level); } else if (buffer[0] == '9') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p9, level); } else if (buffer[0] == 'A') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p10, level); } else if (buffer[0] == 'B') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p11, level); } else if (buffer[0] == 'D') { int level = String(buffer[1]).toInt() * 100 + String(buffer[2]).toInt() * 10 + String(buffer[3]).toInt(); if (level < 0) { level = 0; } else if (255 < level) { level = 255; } analogWrite(p13, level); } } delay(1); } }
electron-src/index.ts
import { join } from 'path'; import { format } from 'url'; import { BrowserWindow, app, ipcMain, IpcMainEvent } from 'electron'; import isDev from 'electron-is-dev'; import prepareNext from 'electron-next'; import { SerialPort } from 'serialport'; let serialPort: SerialPort; let isReadyBoard = false; SerialPort.list().then((ports) => { ports.forEach((port) => { if (/usb/.test(port.path)) { serialPort = new SerialPort({ path: port.path, baudRate: 115200, }); serialPort.on('open', () => { isReadyBoard = true; }); } }); }); let mainWindow: BrowserWindow | null; app.on('ready', async () => { await prepareNext('./renderer'); mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: join(__dirname, 'preload.js'), }, }); const url = isDev ? 'http://localhost:8000/' : format({ pathname: join(__dirname, '../renderer/out/index.html'), protocol: 'file:', slashes: true, }); mainWindow.loadURL(url); }); app.on('window-all-closed', app.quit); ipcMain.on('isReady', (_evt: IpcMainEvent) => { if (isReadyBoard) { mainWindow?.webContents.send('ready'); } }); ipcMain.on('sendValue3', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('3' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue5', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('5' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue6', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('6' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue9', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('9' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue10', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('A' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue11', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('B' + String(value).padStart(3, '0') + '\n'); } }); ipcMain.on('sendValue13', (_evt: IpcMainEvent, value: number) => { if (isReadyBoard) { serialPort.write('D' + String(value).padStart(3, '0') + '\n'); } });
electron-src/preload.ts
import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electron', { onReady: (listener: () => void) => ipcRenderer.on('ready', listener), isReady: () => ipcRenderer.send('isReady'), sendValue3: (value: number) => ipcRenderer.send('sendValue3', value), sendValue5: (value: number) => ipcRenderer.send('sendValue5', value), sendValue6: (value: number) => ipcRenderer.send('sendValue6', value), sendValue9: (value: number) => ipcRenderer.send('sendValue9', value), sendValue10: (value: number) => ipcRenderer.send('sendValue10', value), sendValue11: (value: number) => ipcRenderer.send('sendValue11', value), sendValue13: (value: number) => ipcRenderer.send('sendValue13', value), });
renderer/pages/index.tsx
import { useEffect, useState } from 'react'; import styles from './index.module.scss'; declare global { interface Window { electron: { onReady: (listener: () => void) => void; isReady: () => void; sendValue3: (value: number) => void; sendValue5: (value: number) => void; sendValue6: (value: number) => void; sendValue9: (value: number) => void; sendValue10: (value: number) => void; sendValue11: (value: number) => void; sendValue13: (value: number) => void; }; } } const pinNumbers = [3, 5, 6, 9, 10, 11, 13]; export default function IndexPage() { const [ isReady, setIsReady ] = useState(false); const [ levels, setLevels ] = useState(pinNumbers.map(() => 0)); useEffect(() => { window.electron.onReady(() => { setIsReady(true); }); window.electron.isReady(); }, []); if (!isReady) { return ( <main className={ styles.loading }> <p>Connecting to Arduino</p> </main> ); } return ( <main className={ styles.main }> <ol> {levels.map((level, i) => { return ( <li> <label> <span>{ String(pinNumbers[i]).padStart(2, '0')}</span> <input readOnly value={ level } type="range" min={ 0 } max={ 255 } step={ 1 } onChange={(evt) => { const value = parseInt(evt.target.value, 10); setLevels((prevLevels) => { const newLevels = [ ...prevLevels ]; newLevels[i] = value; return newLevels; }); window.electron[`sendValue${pinNumbers[i]}`](value); }} /> </label> </li> ) })} </ol> </main> ); }
こんな感じです。
絶対にもっとスマートに書けるなと思いながら、愚直に書きました。
DEMO
PWMでLEDを調光してみました。
リポジトリ
あとがき
本当は、FirmataとJohnny-fiveでアプリをつくっていたのですが、書き出しにめちゃめちゃ苦戦したので、結局自作に切り替えた際に生まれたコードです。
Johnny-fiveの内部のserialportのバージョン(10.5.0)が古いことが書き出せない原因じゃないかと疑っていますが、深く調査せずに自作のコードに切り替えたため、そこは謎に包まれています。