ESP32をSCPI制御で自動計測する

概要

ESP32をSCPIプロトコルで自動制御できるようにしてみました。内蔵のADCなどの他にI2Cなどで接続した外部ユニットなどからデータ収集することができます。

SCPIライブラリ

GitHub - Vrekrer/Vrekrer_scpi_parser: Simple SCPI parser for Arduino
Simple SCPI parser for Arduino. Contribute to Vrekrer/Vrekrer_scpi_parser development by creating an account on GitHub.

昔に紹介したことがありますが、上記ライブラリを利用してSCPIでの連携を行いたいと思います。こちらのライブラリはシリアル通信を利用してSCPIプロトコルでの通信を実現してくれます。

SCPIはシリアル以外にTCP/IPやUSBなどいろいろな方法で通信が可能ですが、データ自体はテキストでのコマンドとなります。このライブラリはテキストベースのコマンドをきれいにパースしてくれるライブラリとなります。

スケッチ例

#include "Vrekrer_scpi_parser.h"

SCPI_Parser scpi;

void setup() {
  Serial.begin(115200);
  delay(500);

  // Internationalized Domain Name
  scpi.RegisterCommand("*IDN?",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         interface.println("ESP32,Arduino SCPI,#00,v0.0.0");
                       });

  // Reset
  scpi.RegisterCommand("*RST",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         ESP.restart();
                       });

  // Digital In
  scpi.RegisterCommand("DIn#?",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         int GpioPin = -1;
                         String command = String(commands.Last());
                         command.toUpperCase();
                         sscanf(command.c_str(), "%*[DIN]%u?", &GpioPin);

                         if (digitalRead(GpioPin)) {
                           interface.println("HIGH");
                         } else {
                           interface.println("LOW");
                         }
                       });

  // Analog Set Attenuation
  scpi.RegisterCommand("AAttenuation",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         uint8_t value = String(parameters.First()).toInt();
                         analogSetAttenuation((adc_attenuation_t)value);
                       });

  // Analog In(mV)
  scpi.RegisterCommand("AVIn#?",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         int GpioPin = -1;
                         String command = String(commands.Last());
                         command.toUpperCase();
                         sscanf(command.c_str(), "%*[AVIN]%u?", &GpioPin);
                         interface.println(analogReadMilliVolts(GpioPin));
                       });

  // Analog In(RAW)
  scpi.RegisterCommand("ARIn#?",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         int GpioPin = -1;
                         String command = String(commands.Last());
                         command.toUpperCase();
                         sscanf(command.c_str(), "%*[ARIN]%u?", &GpioPin);
                         interface.println(analogRead(GpioPin));
                       });

  // Digital Out
  scpi.RegisterCommand("DOut#",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         int GpioPin = -1;
                         String command = String(commands.Last());
                         command.toUpperCase();
                         sscanf(command.c_str(), "%*[DOUT]%u", &GpioPin);

                         String value = String(parameters.First());
                         value.toUpperCase();
                         if ((value == "HIGH") || (value == "ON") || (value == "1")) {
                           digitalWrite(GpioPin, HIGH);
                           Serial.printf(">digitalWrite(%d, HIGH)\n", GpioPin);
                         } else if ((value == "LOW") || (value == "OFF") || (value == "0")) {
                           digitalWrite(GpioPin, LOW);
                           Serial.printf(">digitalWrite(%d, LOW)\n", GpioPin);
                         }
                       });
}

void loop() {
  scpi.ProcessInput(Serial, "\n");
  delay(1);
}

今回組んでみたスケッチ例となります。GPIOのデジタル入出力とアナログ入力ができるものになります。

宣言

#include "Vrekrer_scpi_parser.h"

SCPI_Parser scpi;

includeしてクラスを宣言するだけになります。中身はパーサーで、状態保存等もあまりありませんので初期化パラメーター等もありません。

通信速度設定

Serial.begin(115200);

SCPIは9600を利用することが多いですが、Arduinoだと一般的な115200にしました。初期設定から変更したくない場合には9600にしたほうが楽だと思います。とくにNI-VISA等のツールから連携テストをする場合にデフォルトが9600になっています。

コマンド登録

  // Internationalized Domain Name
  scpi.RegisterCommand("*IDN?",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         interface.println("ESP32,Arduino SCPI,#00,v0.0.0");
                       });

「*IDN?」はSCPIの標準コマンドで端末の情報を取得するものになります。

my_instrument.RegisterCommand(F("*IDN?"), &Identify);

ライブラリのスケッチ例だと上記のように別途宣言した関数を設定していますが、短い処理の場合には無名関数で処理したほうがわかりやすいような気もしますが、好みです。

ループ

void loop() {
  scpi.ProcessInput(Serial, "\n");
  delay(1);
}

シリアルの初期化とコマンドの登録が終わったらシリアルからの入力を処理するだけで動きます。Streamクラスを引数に取っているので、シリアル以外にも無線のBluetoothを利用してSPP(Serial Port Protocol)などでも動くと思います。

“\n”を指定しているのがコマンドの区切りになります。SCPIのデフォルトは””で区切り文字なしなのですが、一般的な機材では”\n”が使われています。ただしNI-VISAとかからコマンドを投げるとデフォルトが””で投げてくるので通信が成立しません。そして”\n”で待っているところに””で投げてくるとVrekrer_scpi_parserが無限ループに入ってハングアップしてしまうバグがあります。

シリアル通信の場合には文字の途中で送信されちゃうことがあるので”\n”を明示的に指定したほうが安全だと思います。送信側が””にしか対応していない場合には待ち受け側でそれに合わせる必要があります。

パラメータの処理について

  scpi.RegisterCommand("DOut#",
                       [](SCPI_C commands, SCPI_P parameters, Stream& interface) {
                         int GpioPin = -1;
                         String command = String(commands.Last());
                         command.toUpperCase();
                         sscanf(command.c_str(), "%*[DOUT]%u", &GpioPin);

                         String value = String(parameters.First());
                         value.toUpperCase();
                         if ((value == "HIGH") || (value == "ON") || (value == "1")) {
                           digitalWrite(GpioPin, HIGH);
                           Serial.printf(">digitalWrite(%d, HIGH)\n", GpioPin);
                         } else if ((value == "LOW") || (value == "OFF") || (value == "0")) {
                           digitalWrite(GpioPin, LOW);
                           Serial.printf(">digitalWrite(%d, LOW)\n", GpioPin);
                         }
                       });

上記のように処理可能です。

scpi.RegisterCommand("DOut#",

まずコマンドで小文字の場所は省略可能なのがSCPIのルールになります。#がついている場所は可変で数字などが取得可能です。

int GpioPin = -1;
String command = String(commands.Last());
command.toUpperCase();
sscanf(command.c_str(), "%*[DOUT]%u", &GpioPin);

上記でコマンド部分を取得して、大文字化してからsscanfで可変部分を取得しています。DOut32を送信した場合にはsscanfで32がGpioPinに代入されます。

String value = String(parameters.First());
value.toUpperCase();
if ((value == "HIGH") || (value == "ON") || (value == "1")) {
    digitalWrite(GpioPin, HIGH);
    Serial.printf(">digitalWrite(%d, HIGH)\n", GpioPin);
} else if ((value == "LOW") || (value == "OFF") || (value == "0")) {
    digitalWrite(GpioPin, LOW);
    Serial.printf(">digitalWrite(%d, LOW)\n", GpioPin);
}

上記がパラメーター部分を取得する部分になります。valueにスペース以降の文字が入ってきます。「DOut32 HIGH」を送信するとHIGHが取得できます。

以上の動きからDOut(デジタル出力)でGPIO32に対してHIGHにするSCPIコマンドとなります。

Pythonからの呼び出し

Windows環境にPythonを入れて自動制御する場合になります。WSL環境の場合にはUSBへのアクセスが面倒なので、直接WindowsにPythonを入れています。

pip install pyvisa pyvisa-py pyserial

必要そうなモジュールをインストールします。シリアルにアクセスするためのpyserialと、VISA用ライブラリのpyvisa、そしてVISAへのバックエンドであるpyvisa-pyを入れます。

バックエンドは何を使っても良いのですがNI-VISAなど有名なのがありますが、ちょっとしたものだと軽いpyvisa-pyで十分な気がします。ちなみに動かすともう少し必要なモジュールがでる可能性があるので追加します。

SCPIの仕組み

まずはVISAという自動連携の仕組みがあり、そのプロトコルがSCPIです。ほぼイコールなのですが、意味の範囲が若干異なります。

PythonからVISAを利用する場合にはpyvisaなどのSCPIプロトコルを喋るモジュールを利用します。このモジュールが実際の機材に直接接続するわけではありません。バックエンドと呼ばれるSCPIプロトコルから実際の機材に接続するアプリを経由します。このバックエンドがシリアルやTCP/IPなどのプロトコルの差を吸収して、同じようなインターフェースでアクセスできるようにしてくれています。

バックエンドはNI-VISAやその他ベンダーから無償ソフトウエアとして提供されていますが、かなり大きくてインストールが面倒です。今回は最低限の機能しかないですが軽いpyvisa-pyを利用しました。pyvisa-pyはデフォルトではTCP/IPしかアクセスできません。pyserialを入れているとシリアルポート経由でもアクセス可能になります。

「Python → pyvisa → pyvisa-py → pyserial → USB経由のESP32」という経路でのアクセスとなります。

pyvisaの確認

pyvisa-info

上記のコマンドを実行することでpyvisa-pyの状態を確認できます。

   py:
      Version: 0.7.1
      ASRL INSTR: Available via PySerial (3.5)
      TCPIP INSTR: Available
         Resource discovery:
         - VXI-11: ok
         - hislip: ok
      TCPIP SOCKET: Available
      USB INSTR:
         Please install PyUSB to use this resource type.
         No module named 'usb'
      USB RAW:
         Please install PyUSB to use this resource type.
         No module named 'usb'

上記のpyの部分が重要でpyvisa-pyで利用可能なデバイスが表示されています。ASRL INSTRがシリアルポートになり、PySerialが入っていないと利用できません。TCPIPは通常利用可能なはずです。USBはUSBモジュールが入っていないので利用できないとなっています。

USBやGPIBなどのデバイスを利用する場合にはNI-VISAとかを入れたほうがいいかもしれません。

SCPIデバイスの確認

>python -c "import pyvisa;rm = pyvisa.ResourceManager('@py');print(rm.list_resources())"
('ASRL5::INSTR',)

上記のワンライナーで利用可能なデバイスがリストアップされます。

import pyvisa;

rm = pyvisa.ResourceManager('@py');
print(rm.list_resources())

本当は上記のようなコードとなります。リソースの列挙のみになりますが、これが重要です。ちなみに「COM5」がSCPIだと「ASRL5::INSTR」になります。NI-VISAだと別名でCOM5でもアクセス可能な場合がありますが、リソースの列挙で出た名前を使うほうが無難です。pyvisa-pyだと別名を使うと一部うまくいかないコードがありました。

ESP32との接続

import time
import pyvisa

rm = pyvisa.ResourceManager("@py")
inst = rm.open_resource(
    "ASRL5::INSTR", baud_rate=115200, read_termination="\n", write_termination="\n"
)

print(inst.query("*IDN?"))

for setma in range(0, 30):
    valv = int(inst.query("AVIn32?").strip())
    valr = int(inst.query("ARIn32?").strip())
    valrtov = valr / 4096 * 3300
    print("%d, %d, %d" % (valv, valrtov, valr))
    time.sleep(1)

上記で接続して、*IDN?を呼び出したあとにGPIO32のアナログ入力を取得しています。

rm = pyvisa.ResourceManager("@py")

@pyがバックエンドの指定になります。無指定の場合には適当に選択してくれますが、@pyでpyvisa-pyを明示的に指定しています。

inst = rm.open_resource(
    "ASRL5::INSTR", baud_rate=115200, read_termination="\n", write_termination="\n"
)

SCPIを開いているコードです。ここがかなり重要です。baud_rateで通信速度と、read_terminationとwrite_terminationでコマンドの区切りを指定しています。ちなみに”COM5″で指定するとbaud_rateなどが指定できなかったので”ASRL5::INSTR”で指定しています。inst.baud_rate = 115200などで指定する方法もあります。

まとめ

SCPI連携は機材が変わっても同じような連携になるので使いやすいです。

とはいえ、ESP32単体だとなかなか精度がでないので、上記のように外部ユニットのセンサーで計測をするのがよいのではないかと思います。

実際に他の機材と連携して自動計測をしたのですが長くなりそうなので別の記事にしたいと思います。

コメント