ScratchLink について

ScratchLink はScratch 3.0と連携して外部のデバイスの操作を可能にするソフトウェアである。(Scratch Japan Wikiより)

ソースはgithubで公開されており、Windows版とMac版が存在している。Windows版はC#で記述されており、ちょっと工夫すれば自前でビルドも可能なようである。ただ、残念ながらLinuxでは動作しない。

プロトコルの概要についてはgithubにドキュメントが公開されている。特にmicro:bitとの接続に興味があったが、より詳細に説明しているサイトを見つけられなかったので、Windows版でどうなっているのか調べてみた。

ScratchLink は Scratch3.0(以下、Scratchと略)でmicro:bit やMINDSTORMS EV3 を操作する際に用いるためのヘルパーアプリケーションである。micro:bit とPCとのbluetooth接続が完了していても、Scratch 3.0アプリがデバイスと直接 bluetooth でのやり取りができないため、仲介役として存在している。

Web Bluetooth APIを使って接続するという議論もあるようだが、現時点でどういう風向きなのかは知らない)

上図のようにScratchとはwebsocketで接続し、JSON-RPC を使ってやり取りしている。

通信プロトコル

通信の流れとしては、ScratchLinkとの接続→デバイスのスキャン→デバイスへの接続→センサーデータの送信要求の順となっており、その後は適宜、画面データの送信がなされている。

ScratchLinkとの接続(Scratch->ScratchLink)

microbit拡張がクリックされると、Scratchは websocket を介して ws://127.0.0.1/scratch/ble に接続を試みる。

ソース上では、scratch-vm/src/extensions/scratch3_microbit/index.js のscan()によりBLEオブジェクトが生成され、その過程で scratch-vm/src/util/scratch-link-websocket.js のopen()でwebsocketに接続する。

ScratchLink 側では、scratch-link/Windows/scratch-link/App.cs の App() においてソケットが作成され、ポート番号20111にて接続を受け入れるようになっている。

websocketで ScratchLink に接続できなかった場合には以下の画面が表示される。

デバイスのスキャン要求(Scratch->ScratchLink)

websocket への接続がうまくいったら、次は microbit デバイスのスキャンが行われる。

Scratch 側では scratch-vm/src/io/ble.js の requestPeripheral() において、以下のようなJSON-RPC 要求が ScratchLink に送られる。

{"jsonrpc":"2.0",
 "method":"discover",
 "params":{"filters":[{"services":[61445]}]},
 "id":0}

ScratchLink ではJSON-RPC 2.0が用いられており、method で指定した RPC(remote procedure call) を実行する。この場合はparams以下のディクショナリデータをパラメータとして discover を呼び出している。

デバイスのスキャン応答(ScratchLink->Scratch)

ScratchLink 側では、scratch-link/Windows/scratch-link/BLESession.cs のDidReceiveCall()において RPC のディスパッチが行われ、Discover()にて実際の処理が行われる。内容としてはBLE を通じて非同期的に microbit をスキャンし、見つかったデバイスを OnAdvertisementReceived() において Scratch に通知する。具体的には以下のような JSON-RPC 要求が Scratch に送られる。

{"jsonrpc":"2.0"
 "method":"didDiscoverPeripheral"
 "params":{"name": <Advertisement.LocalName>,
           "rssi": <RawSignalStrengthInDBm>,
           "peripheralId": <BluetoothAddress>}

paramsのnameに指定するのはデバイスの名前で任意のものとなる。rssiは信号強度で負の値がshortで入る。(先のドキュメントに詳細が書かれている)peripheralIdにはmicrobitのbluetoothアドレスが64bit非負整数で入る。

Scratch 側では、scratch-vm/src/io/ble.js の didReceiveCall() にてRPCのディスパッチが行われている。送られてきた didDiscoverPeripheral の対応として、見つかったデバイスが登録され、アプリ画面上にそのデバイスが表示される。

デバイスとの接続(Scratch->ScratchLink->Scratch)

ユーザーが「接続する」ボタンを押すと、そのデバイスに接続要求が行われる。

Scratch 側では scratch-vm/src/extensions/scratch3_microbit/index.js の connect() が起動されて、そのまま scratch-vm/src/io/ble.js の connectPeripheral() において以下のような JSON-RPC 要求が ScratchLink に送られる。

{"jsonrpc":"2.0",
 "method":"connect",
 "params":{"peripheralId":<BluetoothAddress>},
 "id":1}

ここで、paramsのperipheralIdには、デバイスのスキャン時で得られたmicrobitのbluetoothアドレスが入る。

ScratchLink 側では scratch-link/Windows/scratch-link/BLESession.cs の Connect() で、microbit との接続を行っている。

microbitとのBLE接続ができたら、JSON-RPC 応答を返す。JSON-RPC 応答は以下のようなものである。(接続できなかった場合は、エラーのJSON-RPC 応答を返す。ここでは省略)

{"jsonrpc":"2.0","id":1,"result":null}

Scratch 側では JSON-RPC 応答を受け取ったところで以下のような画面となる。

センサーデータの送信要求(Scratch->ScratchLink)

microbitとの接続が済むと、すぐにmicrobitのセンサーデータ(ボタン、タッチ、加速度センサ等)の要求が行われる。

Scratch 側では scratch-vm/src/extensions/scratch3_microbit/index.js の _onConnect() から、scratch-vm/src/io/ble.js の read() が呼ばれ、そこで以下のような JSON-RPC 要求が送られる。

{"jsonrpc":"2.0",
 "method":"read",
 "params":{"serviceId":61445,
           "characteristicId":"5261da01-fa7e-42ab-850b-7c80220097cc",
           "startNotifications":true},
 "id":2}

BLE通信ではデバイスの機能を利用する場合は serviceId と characteristicId を指定する(らしい)ここでは、microbit のセンサーデータを取得するために上記の値を指定する。

また、定期的に情報を送るよう startNotifications を true にしている。

ScratchLink 側では、scratch-link/Windows/scratch-link/BLESession.cs の Read() で RPC のparamsを受け取り、microbit に対してセンサーデータが変わるたびに通知するように設定する。

センサーデータの送信(ScratchLink->Scratch)

microbitのセンサーデータが変化すると、scratch-link/Windows/scratch-link/BLESession.cs の OnValueChanged() が呼ばれ、そこでScratchへ JSON-RPC 要求が送られる。

{"jsonrpc":"2.0",
 "method":"characteristicDidChange",
 "params":{"serviceId":61445,
           "characteristicId":"5261da01-fa7e-42ab-850b-7c80220097cc",
           "encoding":"base64",
           "message":<B64Message>},
 "id":2}

methodにはcharacteristicDidChangeを指定する。また、メッセージは10バイトのバイト列をBase64形式でエンコードした文字列を指定する。各バイトの意味は以下の通り。

加速度センサーの値は符号付きの16ビット整数である。また、ジェスチャーのビット列の意味は以下の通り。(例えば、「振られた」が有効ならば値は 0b001 となる)

Scratch 側では、scratch-vm/src/extensions/scratch3_microbit/index.js の _onMessage() で受け取った RPC を処理し、得られたセンサーデータを内部状態に反映させる。

いくつか注意点がある。

  • 4.5秒以内の間隔で送信し続けないとデバイスの接続が解除される。
  • アプリで表示される値は、加速度センサのデータの10分の1値が使われる。

画面データの送信(Scratch->ScratchLink)

アプリで「表示する」「画面を消す」ブロックをクリックすると、microbitのディスプレイに文字や画像が表示される。これらも、ScratchLink に対してJSON-RPC 要求を送ることで実行されている。

Scratch 側では、scratch-vm/src/extensions/scratch3_microbit/index.js の send() から、scratch-vm/src/io/ble.js の write() が呼ばれ、以下のような JSON-RPC 要求が作られる。

{"jsonrpc":"2.0",
 "method":"write",
 "params":{"serviceId":61445,
           "characteristicId":"5261da02-fa7e-42ab-850b-7c80220097cc",
           "message":<B64Message>,
           "encoding":"base64",
           "withResponse":true},
 "id":3}

methodにはwriteを指定する。また、メッセージはバイト列をBase64形式でエンコードした文字列を指定する。

バイト列の1バイト目が 0x81 の場合は2バイト目以降に文字列を指定する(スクロール表示される)。また 0x82 の場合は2バイト目から6バイト目まで各5ビットのバイナリデータを指定する(それぞれのビットがピクセルに対応して表示される)。

ScratchLink 側では、scratch-link/Windows/scratch-link/BLESession.cs の Write() により microbit にデータを送る。

以上、ScratchLink の動作やプロトコルの詳細について説明した。

Pythonでのサンプル

上記のプロトコルを実装すれば、どのような言語でも ScratchLink と同様のことはできる。プロトコルの確認に使った Python のサンプルコードを以下に示す。(gist

from websocket_server import WebsocketServer
import json
import base64
import time
from threading import Timer

connecting = False

def update(client,server):
    while connecting:
        print("sending {}->{}".format(client,server))
        b64 = base64.b64encode(bytes([0,123,1,0,1,1,1,1,1,1])).decode('utf-8')
        response = '{"jsonrpc":"2.0","method":"characteristicDidChange","params":{"serviceId":61445,"characteristicId":"5261da01-fa7e-42ab-850b-7c80220097cc","encoding":"base64","message":"' + b64 + '"}}'
        server.send_message(client, response)
        time.sleep(1)
    print("Update exited")

def new_client(client, server):
    print("New client connected: %d" % client['id'])

def client_left(client, server):
    global connecting
    print("Client disconnected: %d" % client['id'])
    connecting = False

def message_received(client, server, message):
    global connecting
    if len(message) > 200:
        message = message[:200]+'..'
    print("Client(%d) said: %s" % (client['id'], message))
    dict = json.loads(message)
    if dict['method'] == 'discover':
        id = dict['id']
        response = '{"jsonrpc":"2.0","id":'+str(id)+',"result":null}'
        server.send_message(client, response)
        response = '{"jsonrpc":"2.0","method":"didDiscoverPeripheral","params":{"name":"python","rssi":-70,"peripheralId":65536}}'
        server.send_message(client, response)
    if dict['method'] == 'connect':
        id = dict['id']
        response = '{"jsonrpc":"2.0","id":'+str(id)+',"result":null}'
        server.send_message(client, response)
    if dict['method'] == 'read':
        connecting = True
        timer = Timer(1, update, (client, server))
        timer.start()
    if dict['method'] == 'write':
        message = base64.b64decode(dict['params']['message'])
        if message[0] == 0x81:
            print('Say "'+message[1:].decode('utf-8')+'"')
        elif message[0] == 0x82:
            for x in list(message[1:]):
                print(format(x,"05b")[::-1])
        id = dict['id']
        response = '{"jsonrpc":"2.0","id":'+str(id)+',"result":null}'
        server.send_message(client, response)

PORT=20111
server = WebsocketServer(port = PORT)
server.set_fn_new_client(new_client)
server.set_fn_client_left(client_left)
server.set_fn_message_received(message_received)
server.run_forever()

このコードでは websocket の通信のために python-websocket-server を用いている。(上記コードもそのサンプルを改造したもの)あらかじめ以下のようにインストールしておく。

pip install websocket-server

先のコードを適当なファイル名(例えば scratchlink.py )で保存しておき、以下のように実行する。

python scratchlink.py

起動した状態のままスクラッチのサイトにアクセスし、「作る」を選んで左下の「拡張機能を追加」から「micro:bit」を選ぶと「python」という名前のデバイスが表示されるので接続する。

センサーデータは固定値を送っているだけなので変化しないが、表示ブロックをクリックすれば、その内容が scratchlink.py を起動したコンソールに表示される(はず)。