クラウド経由でPaPeRo iを制御する-WebSocket中継2-Tornado/Heroku編

 Node-RED編のWebSocket通信を中継する方法の課題点を改良します。

複数のPaPeRo iを制御できる様にする

 Node-RED版ではサーバプログラムは二つのWebSocketコネクションのデータを単に中継するだけだったので、一組の(PaPeRo i+中継クライアント)とパペロアプリの中継しかできませんでした。複数の(PaPeRo i+中継クライアント)とパペロアプリの組を中継できる様にするために、以下の様な方法をとることにします。

(1) URLはNode-RED版同様、すべての中継クライアントはwss://サーバアドレス/ws/papero、すべてのパペロアプリはwss://サーバアドレス/ws/controllerに接続する
(2) 中継クライアントは接続した直後にサーバにパペロの識別子を登録する
(3) パペロアプリは接続先wss://サーバアドレス/ws/controllerへの接続時にシミュレータIDとしてパペロの識別子を指定する
(4) サーバプログラムはパペロの識別子が一致する/ws/paperoと/ws/controllerのコネクション同士を中継する

 これで複数組の中継が可能となり、またパペロの識別子を知っていてそれを登録する通信プロトコルをしゃべれるものでなければ既存の通信を邪魔できなくなるので、wss://サーバアドレス/ws/paperoにWebSocketで接続されただけで通信が阻害されてしまうNode-RED版に比べてだいぶマシと言えると思います。

サーバをPython3/Tornadoで実装する

 このサーバプログラムをTornadoを使ってPython3で実装します。TornadoでWebSocketサーバを作るためにはtornado.websocket.WebSocketHandlerを継承したサブクラスを作りon_message等のメソッドをオーバーライドします。
(1) サブクラスの初期化
 WebSocketサーバにWebSocketクライアントから接続があるごとにサブクラスのオブジェクトが作られます。オブジェクトの初期化は__init__()ではなく、initialize()で行います。ここでは引数で渡された種別(typ)とパペロ識別子を保存しています。種別は中継クライアント=’p’、パペロアプリ(controller)=’c’と決めます。また、パペロ識別子とコネクションの対応を、それぞれ中継クライアントごととパペロアプリごとに登録するための辞書をクラス変数として保持します(papero_dic, controller_dic)。

import json
import argparse
from logging import (getLogger, basicConfig, DEBUG, INFO, WARN, ERROR,)
import tornado.ioloop
import tornado.web
import tornado.websocket

logger = getLogger(__name__)

class WsHandler(tornado.websocket.WebSocketHandler):
    def initialize(self, typ, papero_id=None):
        logger.info('({}.{}) self={}'.format(typ, papero_id, self))
        self.typ = typ
        self.papero_id = papero_id

    papero_dic = {}
    controller_dic = {}

 引数(typ)は、順序が入れ替わりますが以下の様にWebSocketサーバー起動時に渡すことができます。パペロ識別子は渡していないのでデフォルトのNoneとなります。なお引数–portでポート番号を指定できる様にしています。

if __name__ == '__main__':
    basicConfig(format='%(asctime)-15s %(module)s %(funcName)s %(levelname)s %(message)s')
    logger = getLogger()  # root logger
    logger.setLevel(DEBUG)
    logger.info('start')
    ap = argparse.ArgumentParser()
    ap.add_argument('--port', type=int, help='port number', default=8888)
    args = ap.parse_args()
    port = args.port
    app = tornado.web.Application([
        (r"/ws/controller", WsHandler, dict(typ='c', )),
        (r"/ws/papero", WsHandler, dict(typ='p', )),
    ])
    app.listen(port)
    tornado.ioloop.IOLoop.current().start()

(2) コネクションopen/close
 WsHandlerクラスの定義に戻ります。接続時に、Node-RED版の中継クライアントとも接続できるようにパペロ識別子””に自分を対応づけてクラス変数の辞書に登録します。
close時には辞書から自分を削除します。

    def open(self):
        self.set_papero_id('')
        logger.info('({}.{}) self={}'.format(self.typ, self.papero_id, self))

    def my_dic(self):
        return self.papero_dic if self.typ == 'p' else self.controller_dic

    def is_papero(self):
        return self.typ == 'p'

    def set_papero_id(self, pid):
        self.papero_id = pid
        dic = self.my_dic()
        logger.info('({}) papero_id=<{}>'.format(self.typ, pid))
        dic[pid] = self

    def on_close(self):
        logger.info('({}.{}) '.format(self.typ, self.papero_id, self))
        self.del_me(self)

    def del_me(self, cli):
        dic = self.my_dic()
        pid = cli.papero_id
        if pid in dic:
            if dic[pid] == cli:
                logger.info('({}.{}) obj={}'.format(self.typ, pid, cli))
                dic.pop(pid)

(3) データ受信
 受信時には中継クライアントによるパペロ識別子登録メッセージ、パペロアプリによる初期化メッセージであればパペロ識別子を登録しなおします。また、相手側のテーブルで自分と同じパペロ識別子を探し、コネクションが存在すればそこに受信したデータを送信します。但し中継クライアントから受信した”Destination”が”RelayServer”となっているメッセージはブロックします。

    def on_message(self, msg):
        pid = self.papero_id
        try:
            mdic = json.loads(msg)
        except Exception as e:
            return
        logger.debug('({}.{}) mdic["Name"]={}'.format(self.typ, self.papero_id, mdic['Name']))
        if self.is_papero():
            dst_dic = self.controller_dic
            # papero側(中継クライアント)は最初にパペロ識別子メッセージ(中継しない)を送ってくる
            # {"Destination": "RelayServer", "Name":"RegPaperoID", "PaperoID":"xxxx"}
            if mdic.get('Destination') == 'RelayServer':
                dst_dic = None  # 転送しない
                if mdic.get('Name') == 'RegPaperoID':
                    newid = mdic.get('PaperoID')
                    if newid is not None:
                        if pid is not None:
                            logger.info('delete my old papero_id(p): <{}>'.format(pid))
                            self.papero_dic.pop(pid)
                        pid = newid
                        self.set_papero_id(pid)
        else:
            dst_dic = self.papero_dic
            # controller側は通常のSelectSimRobotメッセージ(中継する)のSimulatorIDをIDとして使用する
            # {"Name":"SelectSimRobot","RobotName":"","SimulatorID":"xxxx"}
            if mdic.get('Name') == 'SelectSimRobot':
                newid = mdic.get('SimulatorID')
                if newid is not None:
                    if pid is not None:
                        logger.info('delete my old papero_id(c): <{}>'.format(pid))
                        self.controller_dic.pop(pid)
                    pid = newid
                    self.set_papero_id(pid)
        if dst_dic is None:
            return
        if pid not in dst_dic:
            return
        self.send_msg(dst_dic[pid], msg, mdic['Name'])

    def send_msg(self, dst, msg, name):
        logger.debug('({}.{}) msg["Name"]={}'.format(self.typ, self.papero_id, name))
        dst.write_message(msg)

 サーバプログラムはこの1ファイルだけです。(wsrelaysvr.pyとします)

サーバをHerokuで動かす

 Herokuも無期限無償(但しクレジットカード登録をしないと月550時間まで)だったので今回はこちらで試してみました。Herokuではgitでアプリを管理すると言うことなのでUbuntu16.04を使いました。
 python3.6が必要とのことなので、pyenvをインストール(方法は他をご参照ください)した上でpyenvからインストールします。また、Python用のチュートリアルに従いpipenvをインストールします。

$ pyenv install 3.6.4
$ pyenv local 3.6.4
$ pip3 install pipenv

同じくチュートリアルに従いHeroku CLIをインストールします。herokuコマンドが使えるようになりますので、アカウントを作った上でログインします。

$ heroku login

ファイルをそろえる

 必要なファイルがいくつかあるのでチュートリアルを参考に以下の様にしてみました。理解が不完全なのでちょっとした間違いがあるかも知れませんが問題無く動作しましたので良しとしています。新しくディレクトリを作ってこれらのファイルを作成してください。

(1) Pipfile

[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true

[packages]
tornado = "*"

[requires]
python_version = "3.6"

(2) Procfile

web: python wsrelaysvr.py --port=$PORT

 $PORTで渡される指定されたポート番号を使わねばならない仕組みで値は毎回変わります。アクセス時にはこのポートではなくポート指定なしとします。

(3) app.json

{
    "name": "WebSocket Relay Server for PaPeRo i"
}

(4) requirements.txt

tornado==4.5.3

(5) runtime.txt

python-3.6.4

(6) wsrelaysvr.py
 上記

Heroku実行手順

(1) gitの初期化とコミット

 ファイル一式を置いたディレクトリで実行します。

$ git init
$ git add *
$ git commit -m "first commit"

(2) Herokuアプリの生成

$ heroku create

 ここで生成されたアプリ名、URLが表示されます。アプリ名を指定することもできます。

(3) デプロイ

$ git push heroku master

(4) ログの確認

$ heroku logs -t

(5) 状態の確認

$ heroku ps

(6) サーバアプリの停止

$ heroku ps:scale web=0

(7) サーバアプリの起動

$ heroku ps:scale web=1

 このサーバに対応したパペロ識別子を送る事ができる中継クライアントは、次回ご紹介する予定です。


0