みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Macからシリアル通信でArduinoにピン番号と値を送り、その通りにPWMを出力する 💻

ひょんなことからFirmataJohnny-fiveで行うような処理を自作したという話です。

www.arduino.cc
johnny-five.io

Macに接続したArduinoに対して、ピン番号と0〜255の値をシリアル通信で送ることで、Arduinoがその通りに出力します。

以前つくった、ディマーをOSCで制御するアプリのシリアル通信版のような感じです。

blog.kimizuka.org

実装にはNext.jsのElectronテンプレートを使いました。

github.com

未だ、この問題が解決していないため、

blog.kimizuka.org

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を調光してみました。

リポジトリ

github.com

あとがき

本当は、FirmataJohnny-fiveでアプリをつくっていたのですが、書き出しにめちゃめちゃ苦戦したので、結局自作に切り替えた際に生まれたコードです。
Johnny-fiveの内部のserialportのバージョン(10.5.0)が古いことが書き出せない原因じゃないかと疑っていますが、深く調査せずに自作のコードに切り替えたため、そこは謎に包まれています。