先日公開した、Chromebook・Windows・Mac・各種スマホで使えるwebUSBの技術を用いたプログラミング教材について、技術的な解説・カスタマイズ紹介の第3回目です。今回は中級~上級編の内容をお伝えします。
教材自体の作り方・導入方法はこちら↓
改良編第1回はこちら↓。本編では省略されたアナログ入力や気圧センサの使い方、Chromebookでの開発方法などを紹介しています。
改良編第2回はこちら↓。モータの追加、プログラミングアプリ側の改造方法などを紹介しています。
第3回目では、教材の通信メッセージの書式や、iOSでのUSB通信の実装方法など、端末との通信に関する仕様について説明していきます。
通信メッセージの書式説明
本教材の通信メッセージは、テキストベースで行われており、書式はVS-RC003HVなどにみられるVstone製品の仕様に合わせています。具体的には、下記のような仕様となっています。
- プロンプト文字は「>」
- コマンドとして存在するのはメモリマップやEEPROMに対する読み書きのみ
- コマンドは特定文字1文字+アドレス+パラメータ+改行コードの組み合わせ
- 数値は全て16進数表記
具体的なコマンドは下記の5種類です。書式説明で書いている_は半角スペースを表し、コマンド・アドレス・パラメータの間に必ず入れる必要があります。また、アドレス・パラメータは必ず二桁区切りで記述します。WとP、RとGは対象がメモリマップかEEPROMかの違いのみで、書式は同じです。EEPROMは先頭にメモリマップの初期値、続いてプログラムの実行データが記録されています。
- W(メモリマップへの書き込み)
書式:W_(アドレス)_(パラメータ)\r\n
例:LED1の明るさを100、LED2の明るさを200にする
W 0f 64c8\r\n - R(メモリマップからの読み込み)
書式:R_(アドレス)_(読み出すサイズ)\r\n
例:センサ1~4の現在地を読み出す
R 0b 04\r\n - P(EEPROMへの書き込み)
書式:P_(アドレス)_(パラメータ)\r\n - G(EEPROMからの読み込み)
書式:G_(アドレス)_(読み出すサイズ)\r\n - F(現在のメモリマップ内容を初期値に設定)
F\r\n
教材にメッセージを送信すると、同じ文字列がエコーバックで返信されます。\r\nの改行コードを送信すると、これまで送信したコマンドが処理され、結果に応じて返信が返ります。書き込みを行うW・Pのコマンドは、プロンプト(>)のみが返信され、読み込みを行うR・Gのコマンドは、読みだしたパラメータが二桁区切りの16進数で返信されます。Fコマンドは「flash memmap.」と言うメッセージが返信されます。また、受信したメッセージを教材が解読できなかった場合「error:undefined cmd or fmt.」という文字列が返信されます。
iOSで自作ハードウェアと通信する方法
iOSは、色々なことに対して非常に厳しい制限が課されているのは周知の通りです。自作マイコンでよく使われる通信方式はUSB接続かbluetoothですが、どちらも使用制限が厳しいようで、多くのケースではまだ制限が緩めのBLE(bluetooth low energy)が使われるようです。
あとはHTTP(web)通信も利用でき、こちらが一番制限が緩くて実装は容易ですが、ハードウェアのグレードが上がってしまう(ネットワーク機能が必要)・レスポンスがUSB・bluetoothに比べると劣る、などデメリットもあります。
いずれにしても無線ベースの通信が主流で、今回の教材のようなUSBによる有線接続はかなり事例が少ないようです(そもそも、標準の接続ポートはLightningコネクタで、USBですらありません)。今回の教材でもこれに倣って無線対応するのが、技術的には楽なのかもしれませんが、なるべくコストを低く抑えることを考えて、敢えてこれ以上積極的なハードウェアの拡張を行わずに実現する方法を考えました。
また、無線だと接続状態が目で見えないため、うまく通信できない時の原因確認が大変だったり、複数の組み合わせが存在する状況では混信が発生したりして、学校で授業を行う際には忌避されることが多いため、有線接続のモデルケースとなれるように頑張ってUSB対応を目指しました。
iOSが使えるUSBデバイスを調べる
さて、iOSはUSB接続が制限されているとはいえ、別売りのLightning<->USB変換コネクタケーブルを使うことで、デジカメをつないで写真を見たり、キーボード・マウス・ゲームパッドと言った汎用的なUSB機器をつないで利用することが可能なようです。このようなキーボードやマウスはUSB-HIDと呼ばれて規格が決まっており、Arduino LeonardやPro Microでも簡単に作ることができます。最初は、この仕組みを利用して、マウスやキーボードに偽装して通信させることが出来ないか試してみました。
しかし、調べてみるとうまい具合に双方向に通信できるデバイスと言うものが無く、例えばキーボード・マウス・ゲームパッドならいずれもUSB機器→スマホの情報伝達経路のみで、スマホ→USB機器の情報伝達経路を持つものが無さそうでした。
一応、ゲームパッドには、パッド→スマホのボタン入力の他に、スマホ→パッド方向で振動用モータを動かす情報が送信できるようでしたが、それに関する情報や実装例が少なく、対応が困難な気配が感じられました。
仕方ないので他に使えそうなデバイスを探してみると、MIDIデバイスが見つかりました。iPhone/iPadに接続したMIDIキーボードから演奏情報を記録する等の実例があります。
https://appleroid.com/apple/ios-midi-interface-how-to-use/
MIDIは、一般的に電子楽器を制御するための規格ですが、スマホ→デバイス(MIDIファイルの再生)、デバイス→スマホ(キーボードからの演奏情報記録)等、双方向の通信が想定されているため、前述のキーボードやマウスよりは双方向通信に適していそうです。また、電子楽器以外に舞台装置を動かす用途にも使われることがあるようで、iOSでの利用では無いですが、自作ロボットをMDIデバイス化して動かすことをしている事例もいくつかあるようでした。
心配なのは、MIDI規格はかなり古いもので、更に現在MIDI機器は一般ではそこまで使われていないため、iOSがいつまでMDIのサポートを続けるか、ということです。
ちなみに開発当時には思いつかなかったのですが、Arduinoを有線LANデバイスとして接続させる方法があったようです。
https://qiita.com/get_itchy_feet/items/36a8f9fa5983fb11ac6a
これはこれで、IPアドレスの設定などトラブルの要因となりそうな所があるのが怖いですが、MIDIデバイスよりは本来の汎用的な双方向通信という用途に近く、また今後のサポート含めて確実性が高いので、これを使う方法もありかもしれません。
MIDI信号での通信方法を考える
MIDIデバイスを使うと決まったので、MIDI機器でやり取りされるメッセージの仕様を確認していきたいと思います。MIDIメッセージは、大きく分けて固定長のチャンネルメッセージ、可変長のシステムメッセージの二種類が存在するようです。
教材自体の通信メッセージは可変長なので、ここはシステムメッセージ一択なのですが、実際に実装してみると、教材→スマホの方向の通信においてシステムメッセージを使用すると、なぜかiOSのアプリがフリーズしてしまう問題が発生し、解決できませんでした。仕方ないので、今回はスマホ→教材の通信はシステムメッセージ、教材→スマホの通信にはチャンネルメッセージを複数組み合わせて固定長のメッセージを再現する、といういささかアンバランスな方法で実現したいと思います。
具体的なMIDIメッセージの使用としては、1byteずつの配列変数で表現され、システムメッセージは「0xf0」で始まり「0xf7」で終わるもの、チャンネルメッセージは4byteのパケットで命令の種類によってコマンド部とデータ部のサイズが変動するようです。
また、上記のメッセージ開始・終了などあらかじめ定義されたものを除き、1byte中の下位7bit(0~127)の範囲の数値のみ扱える規定になってます。今回は元々半角英数の文字列で表現される通信コマンドをMIDIメッセージに置き換える用途であり、1文字を表現するASCIIコードは0~127の範囲に全て収まるので、通信メッセージを含めるのには差し支えなさそうです。もし何らかの仕様拡張を行う場合は、この制限に抵触しないように注意してください。
iOSでのMIDIデバイスの使用準備
通信を始める前に、まずはiOS上のアプリからMIDI機器を使うための準備を行う必要があります。iOSアプリでMIDIを使う事例を探すと、下記のような記事を見つけました。
https://qiita.com/yohki/items/cb8820026730c7729a2e
先に伝えたようにMIDI機器は現在一般的でないため、有効な情報量が少ないですが、参考ページなどによると比較的最近の開発環境においても、公式がちゃんとライブラリを準備してくれているようです。
iOSのアプリ用のライブラリはCoreMIDIと言う名前で、swiftのソースコードでは下記のようにimportを記述して利用できるようにします。
import CoreMIDI
MIDIデバイスを開く手順については、ほぼ前述のweb記事の通りで、MIDIGetNumberOfSources()で一つ以上のデバイスが見つかったらそのデバイス名を取得し、名前を元にMIDIClientを作成します。MIDIInputPortやMIDIEndPortなどの設定も含めて、下記のように処理しています。下記ソースではfindMIDISources()メソッドでMIDI機器が一つ以上見つかったら、connectMIDIClient()メソッドで見つかった機器の番号(見つかった順)を指定することでデバイスをオープンします。
var midiClient: MIDIClientRef=0; var inPort:MIDIPortRef = 0; var outPort:MIDIPortRef = 0; var midiendpoint:MIDIEndpointRef = 0; var isConnect:Bool = false; //MIDI機器との接続状態を示すフラグ var sourceName = [String]() //見つかったMIDI機器の名称リスト var delegate: MIDIManagerDelegate? //現在接続されたMIDI機器をリストアップして、名称を配列(sourceName)に格納する func findMIDISources() { sourceName.removeAll() numberOfSources = MIDIGetNumberOfSources() os_log("%i Device(s) found", numberOfSources) for i in 0 ..< numberOfSources { let src = MIDIGetSource(i) var cfStr: Unmanaged<CFString>? let err = MIDIObjectGetStringProperty(src, kMIDIPropertyName, &cfStr) if err == noErr { if let str = cfStr?.takeRetainedValue() as String? { sourceName.append(str) os_log("Device #%i: %s", i, str) } } } } //指定のデバイス番号のMIDI機器に接続するメソッド // index:接続するMIDI機器の番号 func connectMIDIClient(_ index: Int) { if !isConnect && 0 <= index && index < sourceName.count { // Create MIDI Client let name = NSString(string: sourceName[index]) var client = MIDIClientRef() var err = MIDIClientCreateWithBlock(name, &client, onMIDIStatusChanged) if err != noErr { os_log(.error, "Failed to create client") return } os_log("MIDIClient created") // Create MIDI Input Port let portName = NSString("inputPort") inPort = MIDIPortRef() err = MIDIInputPortCreateWithBlock(client, portName, &inPort, onMIDIMessageReceived) if err != noErr { os_log("Failed to create input port") return } os_log("MIDIInputPort created") // Connect MIDIEndpoint to MIDIInputPort let src = MIDIGetSource(index) err = MIDIPortConnectSource(inPort, src, nil) if err != noErr { os_log("Failed to connect MIDIEndpoint") return } os_log("MIDIEndpoint connected to InputPort") midiClient = client; //output port create err = MIDIOutputPortCreate(client, portName, &outPort); if err != noErr { os_log(.error, "Failed to create output port") return } os_log("output MIDIClient created") midiendpoint = MIDIGetDestination(index); err = MIDIPortConnectSource(outPort, midiendpoint, nil) isConnect=true; } }
このソースでは、教材からMIDIメッセージが送信された場合、及びMIDIデバイスの状態が変化した場合に、それぞれイベントコールバックの要領で特定のメソッドを呼び出すようにしています。後者は教材が取り外されたりしたことを検出できないか入れてみた処理ですが、実際にはあまり役に立たないようです。
//MIDI機器の接続状態が変化したときに呼び出されるメソッド func onMIDIStatusChanged(message: UnsafePointer<MIDINotification>) { os_log("MIDI Status changed!") } //MIDI機器からメッセージを受信したときに呼び出されるメソッド func onMIDIMessageReceived(message: UnsafePointer<MIDIPacketList>, srcConnRefCon: UnsafeMutableRawPointer?) { os_log("MIDI Message recieved!") //以降、受信したメッセージの解読処理を実装します。 }
一度開いたMIDIデバイスをクローズする場合は下記のようにします。
func disconnect(){ if isConnect { MIDIPortDisconnectSource(inPort, midiendpoint) MIDIPortDisconnectSource(outPort, midiendpoint) MIDIClientDispose(midiClient); inPort = 0; outPort = 0; midiendpoint = 0; midiClient = 0; isConnect=false; } }
具体的な通信処理・メッセージ解読はこれから説明しますが、ひとまずMIDIデバイスを使用する準備はこれで完了です。
ArduinoでのMIDIデバイスの使用準備
併せて、Arduino側のMIDI制御処理についても説明します。ArduinoをMIDI機器として動かす方法については、下記のwebページなどが参考になります。
概要としては、「MIDIUSB.h」というヘッダファイルをインクルードし、実際に関連処理を実行する際は「MidiUSB」というクラスを用います。送受信するメッセージのデータ構造は「midiEventPacket_t」という構造体で定義されており、この構造体に送信データを構築して送信したり、この構造体に展開された受信データから実際のメッセージを読み取ったりします。
midiEventPacket_tは4byteの構造体(header 1byte+data 3byte)で、コマンドの種類によって各byteに代入する数値を決めます。例えば音を止める「ノートオフ」のコマンドは後半2byteが完全に自由な値として使えるので、下記のようにすると0x30(‘0’),0x61(‘a’)の二つの文字を送信するのにも使えます。
#define CMD1 (0x09) #define CMD2 (0x90) midiEventPacket_t noteOn = {CMD1, CMD2, 0x30, 0x61}; MidiUSB.sendMIDI(noteOn); MidiUSB.flush();
Arduino上でMDIメッセージを受信するのはMidiUSB.read()を使用し、下記のように戻り値をmidiEventPacket_t構造体で受け取ります。
midiEventPacket_t midi_out = MidiUSB.read();
スマホ→教材方向の通信(システムメッセージ)
MIDIのシステムメッセージの基本的な書式は、先にも書きましたが「0xf0」から始まって「0xf7」で終わるというものです。その間には、本来は制御パラメータ以外にMIDI機器や製造メーカーなどの特定のIDといった情報なども含めますが、今回はそれを全く無視して、最初と最後以外を全てパラメータとしてメッセージを構築します。
このように通常のMIDIメッセージと異なる変な作り方をしているため、実際のMIDI機器をつないでこのアプリを使うとMIDI機器が変なメッセージを受け取って異常動作を引き起こす可能性があるため、本教材以外のMIDI機器をつないで使わないでください。
iOSアプリ側でシステムメッセージを作成して教材に送信する処理は、下記のように実装しています。実のところ、可変長のシステムメッセージも送信の際にはmidiEventPacket_t構造体のように固定長(header:1byteとdata:3byte)に分解して送信しています。
//送信メッセージをMIDIシステムメッセージに変換して送信するメソッド // mes:送信するメッセージの文字列 func midisend(_ mes:String){ if isConnect { var buf = [UInt8](mes.utf8); //文字列をbyte単位の配列に変換 buf.insert(0xf0, at: 0); //冒頭に0xf0を挿入 buf.append(0xf7); //末尾に0xf7を追加 switch(buf.count%3){ //MIDIメッセージのサイズは3の倍数にそろえる必要があるため、必要に応じて末尾(0xf7)を追加 case 1: buf.append(0xf7); buf.append(0xf7); break; case 2: buf.append(0xf7); break; default: break; } //データを3byteずつ送信 for i in stride(from: 0, to: buf.count, by: 3) { var packet = MIDIPacket(); packet.timeStamp = mach_absolute_time(); packet.length=3; packet.data.0 = buf[i]; packet.data.1 = buf[i+1]; packet.data.2 = buf[i+2]; var list = MIDIPacketList(numPackets: 1, packet: packet); let osstatus = MIDISend(outPort, midiendpoint, &list); } } }
教材側で、iOSからのメッセージを受け取って解読する処理は下記のようになっています。基本的にすべてのメッセージはmidiEventPacket_t構造体の4byte区切りで受信するので、受け取ったメッセージからシステムメッセージの開始(0xf0)を検知し、それが見つかったらシステムメッセージの終了(0xf7)を受信するまで受信用のバッファにメッセージを蓄積していきます。なお、midiEventPacket_tの4byteのうち先頭のheaderはデータが含まれていないため、残り3byteの内容で開始・終了判定やメッセージの蓄積をしていきます。
midiEventPacket_t midi_out; //メッセージ送受信に使うmidiEventPacket_t構造体 bool flgSysExRemain = false; //システムメッセージが継続しているかどうかのフラグ //受信メッセージのバッファと現在の読み込み位置 #define RECV_BUFF_LEN (256) char rbuff[RECV_BUFF_LEN]; int r_index=0; bool recv_midi() { while(1){ midi_out = MidiUSB.read(); //メッセージを読み出し // headerが0以外なら有効なMIDIメッセージとみなして解読する if(midi_out.header !=0){ // システムメッセージの受信途中の場合 if (flgSysExRemain){ //終端(0xf7)の出現を確認しつつ、バッファに受信文字を入れていく if((midi_out.byte1 != 0xF7) && (midi_out.byte2 != 0xF7) && (midi_out.byte3 != 0xF7)){ rbuff[r_index++] = midi_out.byte1; rbuff[r_index++] = midi_out.byte2; rbuff[r_index++] = midi_out.byte3; }else if((midi_out.byte1 != 0xF7) && (midi_out.byte2 != 0xF7) && (midi_out.byte3 == 0xF7)){ rbuff[r_index++] = midi_out.byte1; rbuff[r_index++] = midi_out.byte2; flgSysExRemain = false; return true; }else if((midi_out.byte1 != 0xF7) && (midi_out.byte2 == 0xF7)){ rbuff[r_index++] = midi_out.byte1; flgSysExRemain = false; return true; }else if((midi_out.byte1 == 0xF7)){ flgSysExRemain = false; return true; } } // システムメッセージの開始(0xf0)を判定する else if(midi_out.byte1 == 0xF0) // System Exclusive { r_index=0; memset(rbuff,0,sizeof(rbuff)); if((midi_out.byte2 != 0xF7)){ rbuff[r_index++] = midi_out.byte2; rbuff[r_index++] = midi_out.byte3; flgSysExRemain = true; }else{ // This pattern (F0 F7) will not occur, ManufactureID and data should be exist between F0 and F7 rbuff[r_index++] = midi_out.byte2; return true; } } } else break; } return false; }
教材→スマホ方向の通信(チャンネルメッセージ)
MIDIのチャンネルメッセージは、midiEventPacket_t構造体1個分=4byte分の固定長のメッセージです。コマンドによってコマンド部とパラメータ部は変わるようです。今回はチャンネルメッセージで一番基本的な「ノートオフ(音を止める)」コマンドを偽装して使います。
ノートオフコマンドは、4byte中先頭の2byteがコマンドに相当します。実際には一部パラメータを含みますが、コマンドとの混在なのでここではコマンド部とします。後半2byteに、送信したいパラメータを格納して送ります。
Arduino側で、スマホに対してメッセージを送信する処理は下記のようになります。String型で作成されたメッセージをbyte単位に分解して、2byteごとにノートオフコマンドに乗せて送信しています。
本来のメッセージには末尾に改行(‘\n’や’\r’)を付けてメッセージ終端を区別していますが、MIDIメッセージはそれ以外に0x00を末尾とみなすようにします(これはMIDIメッセージの使用ではなく独自規定なので、iOSアプリ側の解読処理とすり合わせておきます)。そのため、送信メッセージの末尾に内容が0x00のデータを2byteを付け足して、確実に末尾のデータが送信されるようにしています。
void midi_send(String mes) { int len=mes.length()+mes.length()%2; //メッセージの文字数。0x00に相当する2byteを追加 byte data[len]; memset(data,0,sizeof(data)); mes.getBytes(data,mes.length()+1); //String型のメッセージをbyte型の配列に展開 //2byte(2文字)ずつMIDIメッセージ化して送信 for(int i=0;i<len;i+=2){ byte msb=data[i],lsb=0; lsb=data[i+1]; midiEventPacket_t noteOn = {0x09, 0x90, msb&0x7f, lsb&0x7f}; MidiUSB.sendMIDI(noteOn); MidiUSB.flush(); } }
教材から送信したメッセージをiOSアプリ側で受信する処理は下記のようになっています。先に説明した通り、コールバック方式でonMIDIMessageReceivedメソッドが呼び出されるようになっており、引数のメッセージを分解してコマンドの種類を確認し、コマンドが0x90(ノートオフ)だったら、後半2byteをメッセージとして蓄積していきます。そして、後半2byteに終端を表す0x00が登場したら、そこでメッセージ終端とみなして蓄積したメッセージの解読処理に移ります。
//ViewControllerに受信バッファを送るためのdelegate protocol MIDIManagerDelegate { func onRecv(rbuf:String) } ・・・ var delegate: MIDIManagerDelegate? var recvbuff: Array<UInt8>=Array(); func onMIDIMessageReceived(message: UnsafePointer<MIDIPacketList>, srcConnRefCon: UnsafeMutableRawPointer?) { let packetList: MIDIPacketList = message.pointee let n = packetList.numPackets var packet = packetList.packet for _ in 0 ..< n { // コマンドを確認 let mes: UInt8 = packet.data.0 & 0xF0 let ch: UInt8 = packet.data.0 & 0x0F if mes == 0x90 { //メッセージ部分2byteを代入。 let msb: UInt8 = packet.data.1 & 0xFF let lsb: UInt8 = packet.data.2 & 0xFF recvbuff.append(msb); //1byteを受信バッファに追加 if msb != 0x00 {recvbuff.append(lsb);} //2byteも0x00で無ければ受信バッファに追加 //もしメッセージに0x00が含まれたら終端とみなして処理をする if (msb == 0x00 || lsb == 0x00) { //受信メッセージを文字列にエンコード let rbuf = String(bytes:recvbuff,encoding: .utf8); //javascriptに転送する処理へのdelegate(ViewControllerを介して送信する) DispatchQueue.main.async { self.delegate?.onRecv(rbuf: (rbuf ?? "") ) } recvbuff = Array(); } } let packetPtr = MIDIPacketNext(&packet) packet = packetPtr.pointee } }
実際のiOSアプリでは、メッセージの解読処理はアプリ自体ではなくアプリ上で動かしている本来のjavascriptのプログラミングアプリに差し戻して、そこで解読させています。javascriptに受信メッセージを差し戻す処理はViewController側で行い、delegateでその処理を呼び出すようにしています。
外部アプリとの連携
Windows・iOSでは、プログラミングに専用のアプリを利用しますが、このアプリはいずれもブラウザ単体で実施できない教材との通信処理を肩代わりするためだけに導入されています。通信方法がブラウザの外に切り出されていて、更に環境によってその方法が異なると言うことをブラウザ内のアプリでも把握できるように、ブラウザ側のアプリにもそれに関する実装が組み込まれています。
具体的には、robot.js内の下記の箇所がそれに相当します。set_windows_appmode及びset_ios_appmodeのメソッドは、それぞれのアプリから制御されることを指定するもので、各アプリでページを読み込み完了したときに一度実行されます。
外部アプリからの制御モードに切り替わると、教材との通信処理が完全にjavascriptの外に切り出されるため、教材との通信状態が変化したら、アプリからset_isconnect_winメソッドを実行してON/OFFの状態を切り替え、教材からメッセージを受信したらparserecvcmdコマンドメソッドを実行してjavascrpt側に受信メッセージを戻しています。教材へのメッセージ送信については、別途メソッドを持たずjavascriptの送信メソッド内に直接実装されています。
//Windowsからの制御を行うフラグ設定 function set_windows_appmode(flag){ if(flag==true) ROBOT.app_platrofm_mode=APP_PLATFORM.WINDOWS; else ROBOT.app_platrofm_mode=APP_PLATFORM.NORMAL; console.log("set_windows_appmode"); return "true"; } //iOSからの制御を行うフラグ設定 function set_ios_appmode(flag){ if(flag==true) ROBOT.app_platrofm_mode=APP_PLATFORM.IOS; else ROBOT.app_platrofm_mode=APP_PLATFORM.NORMAL; console.log(`set_ios_appmode`); return "true"; } //Windows・iOS上で教材と通信中かを記録するフラグ設定 function set_isconnect_win(flag){ ROBOT.isconnect_win=flag; console.log("set_isconnect_win"); return "true"; } //Windows・iOS上で受信したメッセージをjavascriptのアプリに呼び戻す function parserecvcmd(buf){ ROBOT.parserecvcmd(buf); return "true"; }
Windowsアプリにおけるjavascriptとの通信処理は、WebView2のコンポーネントを利用して行っています。WebView2を使って内部のjavascriptとやり取りする例は下記のwebページなどで公開されています。
https://qiita.com/NagaJun/items/baf00494e0841a5e767e
iOSアプリにおけるjavascriptとの通信処理は、WKWebViewのコンポーネントを利用して行っています。WKWebViewを使って内部のjavascriptとやり取りする例は下記のwebページなどで公開されています。
次回予告
そろそろネタが尽きてきました。次回は、教材本体に書き込んだ実行コードに関する解説をしたいと思います。