Edited at

Python で軽量サーバを作って Scratch 2 のHTTP拡張を待ち受けてみる

More than 1 year has passed since last update.


なぜ軽量 HTTP サーバがいるの?



  • Scratch 2 offline版 では JSON 形式のファイルから追加ブロックを読み込むことができます。

  • ブロックは Scratch の外にある HTTP サーバに GET リクエストを投げます。

  • GET を待ち受けるヘルパー (Helper app) を立ち上げておけば、Scratch の機能を拡張していくことができます。

  • ただ、Scratch のループなどから大量に届くリクエストにいちいちスレッドを生成するのはオーバヘッドが大きそうです。

  • そこで、シングルスレッドで GET リクエストを並列処理してみることにします。

なお、ここでの解説の実例として、音声合成や音声認識を Scratch 2 オフライン版で用いる方法を以下にまとめます。

- Scratch 2.0 オフラインで使える音声対話ロボット/音声対話システム用ブロック

また、以下の説明に出てくるコードを github に置きます。

- https://github.com/memakura/scratch2-extension


Scratch 2 でブロックを追加する s2e ファイル

Scratch Extension のページ にある解説 PDF を読むと、.s2e という拡張子で JSON 形式のファイルを作成し読み込むとブロックの追加ができると書かれています。(拡張子は実際には何でもいいですが .s2e を使いましょう、ということらしいです。)以下では、この解説に挙げられている例を使って試してみます。


test.s2e

{ "extensionName": "Extension Example",

"extensionPort": 12345,
"blockSpecs": [
[" ", "beep", "playBeep"],
[" ", "set beep volume to %n", "setVolume", 5],
["r", "beep volume", "volume"],
]
}

これを test.s2e というファイルに保存しておきます。Scratch で シフト を押しながら [ファイル] を選ぶと、[実験的なHTTP拡張を読み込み] が表示されるようになるので、そこで保存した test.s2e を選んで読み込むと、確かにブロックは追加されます!

extension_example_empty-s.png

あれ? Extension Example の横にある丸が赤いですね。

解説 PDF によると、Scratch は秒 30 回ぐらいの頻度でポーリングのリクエスト GET /poll をヘルパーに送っているのですが、それに対して反応がないと赤くなるそうです。ヘルパーからの反応があれば緑になるとのこと。


Python で軽量 HTTP サーバ


その1: GET /poll に反応する


  • まずは GET /poll に対して何かしら返すようにしてみます。

  • この例では 12345番ポートと通信するようなので、12345 番で待ち受ける HTTP サーバを作ってみます。

  • 非同期 I/O ライブラリ asyncio をベースとした aiohttp を使えば簡単に軽量 HTTP サーバを作れます。

  • なお、Python 3.5 からは async や await といった C# などでもおなじみの修飾子を使えるようになりました。以下は、Python 3.5 を前提とします。

$ pip install aiohttp で aiohttp をインストールしておき、次のプログラム (testhelper.py) を作成します。


testhelper.py

from aiohttp import web

async def handle_poll(request):
return web.Response(text="OK")

app = web.Application()
app.router.add_get('/poll', handle_poll)

web.run_app(app, host='127.0.0.1', port=12345)


実行してみます。

> python testhelper.py

======== Running on http://127.0.0.1:12345 ========
(Press CTRL+C to quit)

さて、Scratch の画面を見てみると・・あ、緑になりました!

extension_example_polling-s.png

念のため bash から nc で GET してみます。

$ nc localhost 12345

GET /poll HTTP/1.1

これでリターンを押すと・・・

HTTP/1.1 200 OK

Content-Type: text/plain; charset=utf-8
Content-Length: 2
Date: Tue, 19 Sep 2017 13:41:21 GMT
Server: Python/3.5 aiohttp/2.1.0

OK

たしかに aiohttp が返事をしているようです。


その2: Scratch からコマンドを送る(コマンドブロック)

まず先ほどの復習を兼ねて beep ブロックを実装してみましょう。Scratch からヘルパーへコマンドを送るブロックを「コマンドブロック」と呼びます。s2e を見ると、これは playBeep というコマンド名に関連付けられていることが分かります。このとき、GET /playBeep がヘルパーに送信されるので、ヘルパーではこれを受けて何か処理すればよいです。\007 でベルを鳴らしてみます。

async def handle_beep(request):

print("play beep!")
print("\007")
return web.Response(text="OK")

# 省略

app.router.add_get('/playBeep', handle_beep)

web.run_app(app, host='127.0.0.1', port=12345)

Scratch 2 で beep ブロックを押すと、確かに音が出ます。

次に、値を送るコマンドブロック set beep volume to (...) を実装してみます。以下の例では vol に値が入るため、request.match_info['vol'] で取り出せます。

async def handle_setvolume(request):

volume = int(request.match_info['vol'])
if volume >= 0 and volume <= 10:

print("set volume= " + str(volume))
else:
print("out of range: " + str(volume))
return web.Response(text="OK")

# 省略

app.router.add_get('/setVolume/{vol}', handle_setvolume)

web.run_app(app, host='127.0.0.1', port=12345)

0 から 10 の値を入れてブロックを押す場合とそれ以外の値を入れた場合とで表示が変わります。(int() の前にisinteger()のチェックも必要ですが省略します。)


その3: Scratch で値を受けとる(レポーターブロック)

最後にヘルパーから Scratch へ値を返してみます。Scratch では、この値を受け取れるブロックのことをレポーターブロックと呼び、数値や文字列を受け取る場合、s2e ファイルでは "r" を付けています。一方、真偽値を受け取る場合は "b" を付けて区別します。

レポーターブロックに値を返すには、GET /poll に対するレスポンスを使います。beep volume は "volume" という名前に関連づけられているので、GET /poll に対し、volume 10 と返せば、レポーターブロックに値が入ります。

以下では set beep volume to (...) で指定した値をそのまま返すことにします。ここまでのコード全体を示します。


testhelper.py

from aiohttp import web

volume = 0

async def handle_poll(request):
text = "volume " + str(volume) + "\n"
return web.Response(text=text)

async def handle_beep(request):
print("play beep!")
print("\007")
return web.Response(text="OK")

async def handle_setvolume(request):
global volume # 忘れずに
tmp_volume = int(request.match_info['vol']) # いったん別の変数へ
if tmp_volume >= 0 and tmp_volume <= 10:
volume = tmp_volume
print("set volume= " + str(volume))
else:
print("out of range: " + str(tmp_volume))
return web.Response(text="OK")

app = web.Application()
app.router.add_get('/poll', handle_poll)
app.router.add_get('/playBeep', handle_beep)
app.router.add_get('/setVolume/{vol}', handle_setvolume)

web.run_app(app, host='127.0.0.1', port=12345)


もうそろそろクラスにして、self.volume のようにアクセスしたほうがよさそうですね。クラスにするとこんな感じでしょうか。

Scratch で試してみます。まず、レポーターブロック beep volume の横にチェックを入れて値を表示しておきます。次に、コマンドブロック set beep volume to (...) に数字を入れて実行(ブロックをクリック)してみると、レポーターブロック beep volume の値がコマンドブロックで指定した値に変化するのが分かります。

command_and_reporter.png

複数レポーターブロックがある場合は、それぞれ改行コード 0A で区切りながらレスポンスを返すようにします。


その他: 待つコマンドブロックなど

コマンドブロックには実行を待つブロックもあり、s2e の中で最初に "w" を付けて指定します。このブロックを実装するには、コマンドが実行中であることを Scratch 側に伝える必要があります。

たとえば set beep volume to (...)"w" を付けると、GET /setVolume/5 であったものが、GET /setVolume/2574/5 のように、コマンドIDが追加されます(2574は例です。実際にはコマンドのリクエストごとに値が異なります)。このコマンドID を、 poll に対するレスポンスに加えることで Scratch 側にどのブロックが実行中であるかを伝えます。このときの加え方は _busy 2574 という形式にします。複数のコマンドIDを返す必要があればスペースで区切って指定します。

他にも、ブロックで値を入力させずにプルダウンから選ばせるようにするやり方、文字列の受け取り、リセットの方法、cross-domain policy など色々な話題が解説 PDFでは触れられています。


おわりに

以上の方法はもしかすると Scratch 3 までのつなぎかもしれませんが(Scratch 3 のオフライン版やその拡張方法がどうなるかよく知りませんが)、Python だと OpenCV や機械学習、数値計算など、いろいろなライブラリがすぐに使えるため、かなり自由に拡張できそうです。

もちろん Node.js でも同じような非同期 I/O の軽量サーバは立てられるので、Python のモジュールにこだわらないのであれば JavaScript で書いてもよさそうです。(こちらの記事にまとめてみました

Scratch 3 でも HTTP拡張を残したほうがよいなら(そして現状でもし削除される方向なら)、Scratch 3 の方を変えてしまうのもありかもしれません。