概要
デジタルオシロスコープ LeCroy wavepro 950 を obniz Board 1Y から制御するシステムを開発しました。このオシロスコープは、つまみを回すことによる電圧や時間軸の設定変更が遅いので、それを高速化するシステムとなっています。
LeCroy wavepro 950 は 4 チャンネルのデジタルオシロスコープです。古い機種だからなのか、特に時間軸のつまみを回して 1 マスあたりの秒数を変化させようとすると、かなり時間がかかります。おそらく、内部的に回路を切り替えているのでしょうね、所々でリレーが切り替わる音(カチカチ、カチ、みたいな)がして、1 秒くらい時間がかかる場所もあります。
このオシロスコープは GPIB、RS232、10BASE-T のポートを持ち、リモート制御ができます。様々なコマンドに対応しており、各チャンネル毎の電圧や時間軸の設定も可能。その機能を使えば、つまみで 1 段階ずつゆっくり変化させるのではなく、一発でバシっと設定を変えられるはずです。今回作ったものは、愚直にそのアイデアを形にしたものです。
今回は RS232 を使って制御することにしました。RS232 は 3 種類の通信方式の中で速度が遅いので、できれば避けたかったですが、最終的にこれを選んだのは他の 2 つが使えなかったからです。GPIB は電子計測器の共通仕様らしいですが、私は通信方式をよく知らないため見送りました。10BASE-T は通信方式も分かりますし通信速度も申し分なく、本来はこれを使いたいです。ただ、かなり古い機種ということもあり、実験したところ通信が不安定(一瞬通信できても、すぐ切断され、認識されなくなってしまう)なため、これも採用しませんでした。
システム構成
iPad や PC 上で動作させた obniz 用アプリから、WiFi を経由して obniz Board 1Y と通信します。obniz Board 1Y とオシロスコープを RS232 で接続します。オシロスコープの制御コマンドは、遠隔操作アプリで文字列として生成され、そのままオシロスコープに伝達されます。
メインのコントローラとして obniz Board 1Y を採用しました。obniz Board を使うことで、PC や iPad から簡単に接続することができます。加えて、ブラウザで動作するアプリを作りやすいという点でも利点があります。
ESP32 などを使っても WiFi 接続したい機器を作れますが、ブラウザで動作するアプリを作ろうと思うと壁があります。ぱっと思いつくのは、ESP32 上で HTTP サーバーを動かし、ブラウザ上の JavaScript から接続する、というような方法ですが、非常に面倒です。今回、obniz を初めて使ってみましたが、簡単に GUI アプリ(ブラウザアプリ)を作れるという点でとても便利だなと思いました。
今回作成する遠隔操作アプリは LeCroy wavepro 950 を制御できることを確認していますが、他社、あるいは LeCroy の他機種で使えるかどうかは未検証です。
制御基板
今回作成した制御基板を説明します。制御基板の目的は obniz Board 1Y の入出力電圧と RS232 の制御電圧を変換することです。obniz Board / obniz Board 1Y は、UART 機能で 5 ボルトの電圧しか出力できませんし、最高で 5V の電圧しか入力できません。しかし、RS232 ではそれより高い電圧で信号をやり取りすることになっていますので、電圧の変換が必要なのです。
U1 が電圧変換の IC です。ロジック側(回路図右側)の電圧と、RS232 側(回路図左側)の電圧を変換してくれます。秋月電子通商の SP208ECA を使いました。
J1 が RS232 の接続端子です。いわゆる「D サブ 9 ピン」ってやつですね。この制御基板は「制御対象の機器」ではなく、「制御する側の機器」として振る舞うので、オス(Male)のタイプを使います。秋月電子通商の基板取付用コネクタ を使いました。
U2 が、メインコントローラである obniz Board 1Y です。回路図には描いてありませんが、obniz Board 1Y の電源は USB Type-C の端子で供給しています。その場合、obniz Board 1Y の -/+ 端子は回路内への電源供給端子として使えるようになりますので、SP208ECA の電源として活用しています。
D サブ 9 ピンの基板取付用コネクタは、ピン配置が斜めになっていて、普通のユニバーサル基板には刺さりません。ピッチ変換基板を使うのも手ですが、今回は D サブ 9P 基板(小) というのを使ってみました。コネクタ専用の取り付け穴が左右に設置してあり、ピッチ変換基板を使うよりも高さを抑えて、がたつきもなく製作できました。
obniz Board 1Y と SP208ECA の接続に、IO の後ろの方を使っているのは、深い意味はありません。IO の若い方の番号を空けておく方が、後々パーツを付け足すときになんとなく便利かな?と思っただけです。
遠隔操作アプリ
こちらで公開しています。 https://obniz.com/ja/webapp/2954?key=915f2cbc59c0596a929dd5e431fe8263f18dcc08
直接の利用者はほとんどいないかと思いますが、プログラムの参考になさってください。
このアプリを使って、実際にオシロスコープを操作するデモ動画です。
アプリにはスライダーバーが 5 本あります。先頭の 1 つは時間軸の設定です。オシロスコープの対応範囲(0.2ns/div~1000s/div)がすべて設定可能です。
残りの 4 本は、4 チャンネルそれぞれに対応する電圧の設定です。これもオシロスコープの対応範囲(1mV/div~2V/div)が設定可能です。なお、減衰比を考慮しない設定値ですので、仮に減衰比が 1:10 のプローブを使用した場合、オシロスコープの画面上には設定値の 1/10 の高さで波形が描かれます。
下にある「オシロスコープ画面キャプチャ」は、その名の通り、オシロスコープの画面を画像として取得する機能です。オシロスコープの画面をカメラで撮るよりも綺麗な画像を得られ、便利です。RS232 は通信が遅く、画像の転送にしばらく時間が掛かりますから、進捗を表すバーを設置してみました。取得した画像は BMP 形式になります。ブラウザの機能でファイルとして保存したり、Twitter に送ったりと、活用できます。
遠隔操作アプリの説明
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" />
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script src="https://unpkg.com/obniz@3.x/obniz.js" crossorigin="anonymous" ></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/11.0.2/css/bootstrap-slider.min.css" />
</head>
<body>
<div id="obniz-debug"></div>
<div class="container text-center">
<h3 style="margin-bottom:20px;">オシロスコープ制御卓</h3>
<div class="row">
<div class="col-sm-1">
<h4>Time</h4>
</div>
<div class="col-sm-11">
0.2ns <input id="tdiv_slider" type="text" style="width:80%" /> 1000s
</div>
</div>
<div class="row">
<div class="col-sm-1">
<h4>ch1</h4>
</div>
<div class="col-sm-11">
<input id="vdiv_ch1_slider" type="text" style="width:80%" data-slider-enabled="true"/>
<input id="vdiv_ch1_slider-enabled" type="checkbox" checked="true"/>Enabled
</div>
</div>
<div class="row">
<div class="col-sm-1">
<h4>ch2</h4>
</div>
<div class="col-sm-11">
<input id="vdiv_ch2_slider" type="text" style="width:80%" data-slider-enabled="true"/>
<input id="vdiv_ch2_slider-enabled" type="checkbox" checked="true"/>Enabled
</div>
</div>
<div class="row">
<div class="col-sm-1">
<h4>ch3</h4>
</div>
<div class="col-sm-11">
<input id="vdiv_ch3_slider" type="text" style="width:80%" data-slider-enabled="true"/>
<input id="vdiv_ch3_slider-enabled" type="checkbox" checked="true"/>Enabled
</div>
</div>
<div class="row">
<div class="col-sm-1">
<h4>ch4</h4>
</div>
<div class="col-sm-11">
<input id="vdiv_ch4_slider" type="text" style="width:80%" data-slider-enabled="true"/>
<input id="vdiv_ch4_slider-enabled" type="checkbox" checked="true"/>Enabled
</div>
</div>
<div class="row">
<div class="form-group col-sm-4">
<div class="row">
<div class="col-sm-12">
<h4>オシロスコープ画面キャプチャ</h4>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<button id="captureButton" class="btn btn-info">Capture</button>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<progress id="captureProgress" value="0" max="100">progress is not supported</progress>
</div>
</div>
</div>
<div class="form-group col-sm-8">
<img id="screenCapture" width="640px" height="480px" />
</div>
</div>
<div class="row">
<div class="form-group col-sm-12">
<h4>コマンド手動送信(受信データはブラウザのコンソールに表示されます)</h4>
<textarea class="form-control" id="textToSend" rows="5" style="margin-bottom:10px;"></textarea>
<button id="send" class="btn btn-info">Send</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/11.0.2/bootstrap-slider.min.js"></script>
<script>
// parse_exponential_string('HOGE') -> null
// parse_exponential_string('2E-3') -> [ 2, -3 ]
// parse_exponential_string('5') -> [ 5, 0 ]
function parse_exponential_string(value_str) {
const match = value_str.match(/([0-9.]+)(?:E([-+]?\d+))?/);
if (match === null) {
return null;
}
let exponent = 0;
if (match[2]) {
exponent = parseInt(match[2]);
}
let mantissa = parseFloat(match[1]);
const exp_addend = Math.floor(Math.log10(mantissa));
mantissa *= Math.pow(10, -exp_addend);
exponent += exp_addend;
return [ mantissa, exponent ];
}
// index_to_value125(0, 1, -3) -> [1, 'm']
// index_to_value125(1, 1, -3) -> [2, 'm']
// index_to_value125(2 1, -3) -> [5, 'm']
// index_to_value125(9, 1, -3) -> [1, '']
// index_to_value125(1, 2, -3) -> [5, 'm']
function index_to_value125(linear_value, zero_mantissa, zero_exponent) {
const zero_125_index = {1:0, 2:1, 5:2}[zero_mantissa];
let mantissa = [1, 2, 5][(linear_value + zero_125_index) % 3];
let exponent = Math.floor((linear_value + zero_125_index) / 3) + zero_exponent;
// 2E-3 --> mantissa=2, exponent=-3
const exp_remainder = (exponent % 3 + 3) % 3;
mantissa *= Math.pow(10, exp_remainder);
const unit_name_index = 1 - (exponent - exp_remainder) / 3;
return [mantissa, ['k', '', 'm', 'u', 'n', 'p'][unit_name_index]];
}
function value125_to_index(value125, zero_mantissa, zero_exponent) {
var [ mantissa, exponent ] = value125;
const exp_addend = Math.floor(Math.log10(mantissa));
mantissa *= Math.pow(10, -exp_addend);
exponent += exp_addend;
const mantissa_index = {1:0, 2:1, 5:2}[mantissa];
const zero_mantissa_index = {1:0, 2:1, 5:2}[zero_mantissa];
return mantissa_index - zero_mantissa_index + 3 * (exponent - zero_exponent);
}
function format_tdiv(slider_value) {
const [ mantissa, unit ] = index_to_value125(slider_value, 2, -10);
return mantissa + unit + 's';
}
$('#tdiv_slider').slider({
min: 0,
max: 38,
step: 1,
tooltip: 'always',
formatter: format_tdiv,
}).on('slideStop', function( event, ui ) {
const tdiv_value = format_tdiv(event.value);
console.log('TDIV ' + tdiv_value);
g_uart.send('TDIV ' + tdiv_value + '\r\n');
}
);
function format_vdiv(slider_value) {
const [ mantissa, unit ] = index_to_value125(slider_value, 1, -3);
return mantissa + unit + 'v';
}
for (i = 1; i <= 4; i++) {
(function(i) {
$(`#vdiv_ch${i}_slider`).slider({
min: 0,
max: 10,
step: 1,
tooltip: 'always',
formatter: format_vdiv,
}).on('slideStop', function( event, ui ) {
const vdiv_value = format_vdiv(event.value);
g_uart.send(`C${i}:VDIV ${vdiv_value}\r\n`);
}
);
$(`#vdiv_ch${i}_slider-enabled`).click(function() {
$(`#vdiv_ch${i}_slider`).slider(this.checked ? 'enable' : 'disable');
g_uart.send(`C${i}:TRACE ${this.checked ? 'ON' : 'OFF'}\r\n`);
})
})(i);
}
</script>
<script>
$(function(){
//put your obniz ID
var obniz = new Obniz("OBNIZ_ID_HERE");
//during obniz connection
obniz.onconnect = async function() {
var uart = obniz.getFreeUart();
uart.start({ tx: 8, rts: 9, rx: 10, cts: 11, baud: 115200});
g_uart = uart;
//if the "Send" button is clicked
$("#send").on('click', async function(){
//send messages on UART
var message = $("#textToSend").val().trim() + '\r\n';
uart.send(message);
});
$("#captureButton").on('click', async function(){
$("#captureButton").attr('disabled', true);
$('#captureProgress').attr('value', 0);
uart.send('SCDP\r\n');
});
var bmp_receiving = false;
var bmp_data = undefined;
var bmp_recv_bytes = 0;
function get_bmp_length(bmp_data) {
return bmp_data[2 + 0] +
(bmp_data[2 + 1] << 8) +
(bmp_data[2 + 2] << 16) +
(bmp_data[2 + 3] << 24);
}
//show received messages in the left textarea
uart.onreceive = function(data, text){
if (bmp_receiving) {
bmp_data = bmp_data.concat(data);
bmp_recv_bytes += data.length;
$('#captureProgress').attr('value', bmp_recv_bytes);
const remains = get_bmp_length(bmp_data) - bmp_recv_bytes;
console.log(`receiving bmp: ${bmp_data.length} bytes received. ${remains} bytes remain.`);
if (remains <= 0) {
console.log(data);
if (remains < 0) {
bmp_data = bmp_data.slice(0, get_bmp_length(bmp_data));
}
console.log(bmp_data);
bmp_receiving = false;
$("#screenCapture").attr('src', 'data:image/bmp;base64,' + btoa(String.fromCharCode.apply(null, bmp_data)));
$("#captureButton").attr('disabled', false);
}
} else {
console.log('received: ' + text.trim(), data);
}
let match = text.match(/TDIV (\S+) S/);
if (match) {
let val = parse_exponential_string(match[1]);
$('#tdiv_slider').slider('setValue', value125_to_index(val, 2, -10));
g_uart.send('C1:TRA?\r\n');
}
match = text.match(/C(\d):TRA (ON|OFF)/);
if (match) {
const ch = parseInt(match[1]);
$(`#vdiv_ch${ch}_slider`).slider(match[2] === 'ON' ? 'enable' : 'disable');
$(`#vdiv_ch${ch}_slider-enabled`).prop('checked', match[2] === 'ON');
g_uart.send(`C${ch}:VDIV?\r\n`);
}
match = text.match(/C(\d):VDIV (\S+) V/);
if (match) {
const ch = parseInt(match[1]);
let val = parse_exponential_string(match[2]);
$(`#vdiv_ch${ch}_slider`).slider('setValue', value125_to_index(val, 1, -3));
if (ch < 4) {
g_uart.send(`C${ch+1}:TRA?\r\n`);
}
}
if (text.startsWith('BM')) {
bmp_receiving = true;
bmp_data = data;
bmp_recv_bytes = data.length;
$('#captureProgress').attr('max', get_bmp_length(bmp_data));
$('#captureProgress').attr('value', bmp_recv_bytes);
}
}
//if the "Clear" button is clickex
$("#clear").on('click', async function(){
//clear recieved messages
$("#receivedText").val("");
});
g_uart.send('\r\n');
g_uart.send('TDIV?\r\n');
}
});
</script>
</body>
</html>
※Web のフロントエンド開発の経験がほとんど無いため、ベストプラクティスに照らして良くないプログラムになっている可能性があります。ご注意ください。
スライダーバーを手軽に使うために、bootstrap-slider を使ってみました。スライダーに対し、最小値、最大値、ステップ幅、整数値から表示文字列に変換するためのフォーマッタなどを与えて、柔軟にカスタマイズできる機能が素晴らしいですね。
シリアル通信機能「UART」を obniz で使う方法は obniz Docs の UART のページ が参考になります。
遠隔操作アプリの基本は設定値をオシロスコープに送りつけることです。そのため送信が重要であって、受信は基本的には不要です。ただ、全く使わないかというとそうではありません。アプリ起動時にオシロスコープの現在状態を取得するため、および画面キャプチャのために受信を行います。
UART の初期化と送信は何も難しいところはありませんが、受信はコールバック方式ですので、ちょっと使い方に工夫が必要です。UART が何らかのデータを受け取るとコールバック onreceive が呼ばれます。
コールバック関数の中で、受信データからモードを判別して処理を分岐する、というのが基本戦略となります。例えば、時間軸の設定値を取得するコマンド「TDIV?」をオシロスコープに送ると、オシロスコープから「TDIV 2E-3 S」のような文字列が返信されてきます。これは、現在の設定値が 2E-3s/div(2ms/div)であることを意味します。ですので、onreceive の中では、文字列の先頭が「TDIV」だったら時間軸の初期値を設定する処理を呼ぶことにします。同様に「チャンネル番号:VDIV」から始まる文字列であれば、そのチャンネルに対する電圧の設定だということが分かります。
例外は画面キャプチャです。画面キャプチャは比較的大きなバイト列(BMP ファイルの中身)がオシロスコープから送信されてきます。この場合、受信が完了してから onreceive が呼ばれるわけではなく、ある程度のバイト数を受信した段階で何度も onreceive が呼ばれます。キャプチャデータはバイナリデータですから、偶然にも「TDIV」で始まるバイト列を受信してしまうことがあり得ます。単に先頭一致で処理を分岐していると、この場合に誤動作してしまいます。
そこで、画面キャプチャ中を示すフラグ bmp_receiving を利用して処理を分岐させます。画面キャプチャをオシロスコープに要求するとき(「SCDP」コマンドを送るとき)にフラグを true にします。onreceive の中では、フラグが true なら画面キャプチャ中と判断し、画像データをためていきます。画像データの末尾に来たと判断したら、フラグを false に戻し、キャプチャデータを img タグに表示します。
キャプチャデータのバイト数は、ビットマップ画像のヘッダから算出可能です。Bitmapファイルフォーマット の説明を見ると、ファイル先頭 2 バイト目から 4 バイトがファイルのバイト数であることが分かります。get_bmp_length という関数は、まさにこの情報を使ってファイルのバイト数を取り出します。
uchan の電子工作ラボ
ちょっとだけ宣伝です。先日「uchan の電子工作ラボ」という施設をオープンしました。はんだ付け設備や各種の計測器が使えるワークスペースとなっています。この記事で登場した LeCroy wavepro 950 は、このラボの備品です。利用料金や予約は公式サイトからどうぞ。uchan の電子工作ラボの公式サイト
投稿者の人気記事
-
uchan
さんが
2021/05/15
に
編集
をしました。
(メッセージ: 初版)
-
uchan
さんが
2021/05/15
に
編集
をしました。
-
uchan
さんが
2021/05/15
に
編集
をしました。
(メッセージ: uchanの電子工作ラボの宣伝を追加)
ログインしてコメントを投稿する