クラウド経由でPaPeRo iを制御する-WebSocket中継3-中継クライアント改良編

 Tornado/Heroku編の複数同時中継可能なサーバに対応し、同時にNode-RED編で必要だったラズパイの代わりにPaPeRo i本体上で動作できる中継クライアントを作成します。

中継クライアントとは

 Node-RED編の繰り返しになりますが、PaPeRo iとアプリが違うLAN上にあってどちらもインターネット側からは直接アクセスできない場合にもクラウド経由でPaPeRo iを、WebSocket制御通信レイヤで中継して制御するため、クライアント→サーバと矢印を書くとして、通常
 アプリ→PaPeRo i(WebSocketアドオンシナリオ)
であったところを
 アプリ→クラウド←中継クライアント→PaPeRo i(WebSocketアドオンシナリオ)
とすることで実現するものです。つまり中継クライアントはWebSocketクライアントとしてPaPeRo iのWebSocketアドオンシナリオとクラウドで動作するサーバに接続し、データを中継します。
 さらに、Tornado/Heroku編の複数同時中継可能なサーバに対応するため、サーバへの接続時にパペロ識別子を送信する機能を持たせます。

Python3/Tornadoで実装する

 PaPeRo i本体上で動作させるためPython3/Tornadoで実装します。WebSocketクライアントの場合、tornado.websocket.websocket_connect()をコールバック関数を指定して呼び出します。クラウド側のコネクションオープン時のコールバック関数cloud_cb()でパペロ識別子を送るメソッドself.send_id_to_relay_svr()を呼び出しています。パペロ識別子はコマンドライン引数-paperoで指定します。PaPeRo iのURLを従来通り-wssvrで、サーバのURLを-cloudで指定できる様にしています。
 接続は、クラウド側は起動時に接続しますが、パペロ側は直接接続する場合と動作が近くなるように、クラウド側から初期化メッセージ(Name=”SelectSimRobot”)を受信した時点で(もし接続済みなら一旦切断してから)接続に行く様にしています。
 また、Herokuでは使われないWebSocketは約1分で切断されてしまうため、websocket_connect()コール時にping_intervalを指定して定期的に(ここでは固定25秒)pingメッセージを送信する様に指定しています。

import os
import json
import argparse
from logging import (getLogger, basicConfig, DEBUG, INFO, WARN, ERROR,
    debug, info, warn, error, critical)

import tornado.ioloop
import tornado.websocket
import tornado.httpclient

logger = getLogger(__name__)


class WsRelay(object):
    def __init__(self, cloud_url, papero_url, papero_id):
        self.cloud_url = cloud_url
        self.papero_url = papero_url
        self.papero_id = papero_id
        # self.connect_to_papero()
        if cloud_url.startswith('wss') and os.path.exists('/Extension'):
            url = tornado.httpclient.HTTPRequest(url=cloud_url,
                                                 # ca_certs='/etc/ssl/certs/ca-certificates.crt',
                                                 ca_certs='/Extension/local/etc/ssl/certs/ca-certificates.crt',
                                                 )
            self.connect_to_cloud(url)
        else:
            self.connect_to_cloud(cloud_url)
        self.papero_sock = None
        self.cloud_sock = None
        self.pending_to_papero = None

    def connect_to_papero(self):
        self.papero_con = tornado.websocket.websocket_connect(self.papero_url,
                                                              callback=self.papero_cb,
                                                              on_message_callback=self.papero_msg)

    def connect_to_cloud(self, url):
        self.cloud_con = tornado.websocket.websocket_connect(url,
                                                             callback=self.cloud_cb,
                                                             on_message_callback=self.cloud_msg)
                                                             ping_interval=25)

    def send_msg(self, sock, dat, binary=False):
        if sock is not None and dat is not None and 0 < len(dat):
            sock.write_message(dat, binary=binary)

    def papero_cb(self, future):
        try:
            self.papero_sock = future.result()
            logger.info('papero connected')
            if self.pending_to_papero is not None:
                self.send_msg(self.papero_sock, self.pending_to_papero)
                self.pending_to_papero = None
        except Exception as e:
            logger.error('papero connect error: {}'.format(e))
            raise e

    def cloud_cb(self, future):
        try:
            self.cloud_sock = future.result()
            logger.info('cloud connected')
            self.send_id_to_relay_svr(self.cloud_sock)
        except Exception as e:
            logger.error('papero connect error: {}'.format(e))
            raise e

    def papero_msg(self, dat):
        if dat is None:
            logger.info('closed')
            self.papero_sock = None
            if self.pending_to_papero is not None:
                self.connect_to_papero()
        else:
            logger.debug('rcv from papero: {}'.format(dat))
            self.send_msg(self.cloud_sock, dat)

    def cloud_msg(self, dat):
        if dat is None:
            logger.info('closed')
            self.cloud_sock = None
            tornado.ioloop.IOLoop.instance().stop()
        else:
            logger.debug('rcv from cloud: {}'.format(dat))
            mdic = json.loads(dat)
            if mdic.get('Name') == 'SelectSimRobot':
                self.pending_to_papero = dat
                if self.papero_sock is not None:
                    self.papero_sock.close()
                else:
                    self.connect_to_papero()
            else:
                self.send_msg(self.papero_sock, dat)

    def send_dic(self, sock, mdic):
        dat = json.dumps(mdic)
        sock.write_message(dat)

    def send_id_to_relay_svr(self, sock):
        if 0 == len(self.papero_id):
            logger.info('skip to send RegPaperoID.')
            return
        mdic = dict(Destination="RelayServer", Name='RegPaperoID', PaperoID=self.papero_id)
        logger.info('send RegPaperoID: {}'.format(mdic))
        self.send_dic(sock, mdic)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('-cloud', type=str, help='cloud url',
                    # required=True,
                    default='wss://paperoi-relay-server.herokuapp.com/ws/papero',
                    )
    ap.add_argument('-wssvr', type=str,
                    # default='wss://smilerobo.com:8000/papero',
                    default='ws://192.168.1.1:8088/papero',
                    help='papro url. ex. ws://192.168.1.1:8088/papero or wss://smilerobo.com:8000/papero',
                    )
    ap.add_argument('-papero', type=str,
                    required=True,
                    help='papro id')
    args= ap.parse_args()
    papero_url = args.wssvr
    cloud_url = args.cloud
    papero_id = args.papero
    print('papero url:{}\ncloud url:{}'.format(papero_url, cloud_url))
    relaycli = WsRelay(cloud_url, papero_url, papero_id)
    tornado.ioloop.PeriodicCallback(relaycli.send_keep_alive, 30*1000).start()
    tornado.ioloop.IOLoop.instance().start()


if __name__ == '__main__':
    basicConfig(format='%(asctime)-15s %(levelname)s %(module)s %(funcName)s %(message)s')
    logger = getLogger()  # root logger
    logger.setLevel(DEBUG)
    # logger.setLevel(INFO)
    main()

これをwsrelaycli.pyとします。

動作確認

(1) Tornado/Heroku編のサーバを起動します。
(2) PythonをインストールしたPaPeRo iに、最新のLinuxの/etc/sslをコピーします。場所は任意ですがwsrelaycli.pyと一致させる必要があり、ここでは/Extension/local/etc/sslとしています。
(3) PaPeRo iでインターネットへ接続できることを確認します。
(4) PaPeRo iの時刻を合わせます。時刻がずれているとクラウドへの接続ができません。

# date 2018.2.28-10:20

(5) PaPeRo iで中継クライアントwsrelaycli.pyを起動します。-wssvrでパペロのURL、-cloudでサーバURL(/ws/papero)、-paperoで任意のパペロ識別子を指定します。

# python3 wsrelaycli.py -wssvr ws://0.0.0.0:8088/papero -cloud wss://paperoi-relay-server.herokuapp.com/ws/papero -papero 12345678

 但し-cloudには実際は(1)のアドレスを指定してください。
(6) Pythonをインストールしたホストマシンでパペロのアプリを起動します。-wssvrにサーバURL(/ws/controller)、-simに中継クライアントの-paperoと同じ文字列を指定します(pypapero.Papero()呼び出し時に引数arg_ws_server_addrとsimulator_idに渡すようにアプリを作成)。

$ python3 xxxx.py -wssvr wss://paperoi-relay-server.herokuapp.com/ws/controller -sim 12345678

 なお実機ではなくシミュレータでも動作確認できますが、その場合パペロ識別子は任意ではなくシミュレータIDに合わせる必要があります。
これを複数組実行し、複数台のPaPeRo iをクラウド経由で制御できました。但しHerokuにWebsocketコネクションを切断されない様に、パペロアプリにも定期的にキープアライブパケット(send_get_led_status等が良いと思います)を送信する機能を実装する必要があります。
 また、PaPeRo iに3G/LTEドングルを接続することで、LANが無い客先環境でもこの方法で制御することができます。