Try   HackMD

PythonによるNintendo Switch 自動化に関する考察(1)

はじめに

プログラミング言語"Python"とワンボードマイコン"Arduino"を利用したSwitchの自動操作環境プラットフォームであるPoke-Controller (以下Poke-Con)は、有志による機能追加(Poke-Controller-modified, Poke-Controller-Modified-Extension)が積極的に取り組まれており、自動化スクリプトの開発・配布・利用も盛んです。

私自身も自動化スクリプトの実装のためPoke-Conを活用しておりますが、自動化スクリプトの実装方法や内部の設計については幾つか気になっている点があり、更なる機能追加やパフォーマンスの改善などを行う場合の障壁になるのではという懸念を抱いています。

とはいえ、その懸念点の解消を理由として現行仕様と全く異なる設計思想での再実装を提案したところで、旧仕様との互換性の観点などからコミュニティに受け入れられるかは別の話ですし、そもそもその懸念点自体の妥当性も不確かです。

そこで、まずは現行の実装を読み解くことで問題点を整理し、そのうえでPython上でSwitchの自動化スクリプトがどうあるべきか、ある種の"ぼくのかんがえるさいきょうの自動化基盤アプリ"のモックアップなども交えながら考えを纏めてみることにします。

今回は初回ということで、Poke-Conの実装で気になっているポイントを幾つかピックアップして説明します。

Poke-Conの気になるポイント

以下、Poke-Controller-Modified-Extensionを参照しつつ整理を進めてみます。

マクロスクリプトの再利用性

Poke-Conにおけるマクロスクリプトは、CommandBaseクラスを継承したPythonCommand クラスあるいはImageProcPythonCommandクラスをさらに継承した新たなクラスを定義し、その抽象メソッドであるdoメソッドを具象化する形で記述します。

また、doメソッドが長大化する場合は、必要に応じてインスタンスメソッドを定義して分割します。実際に書いてみるとこんな感じ。

class Hoge(ImageProcPythonCommand):
    NAME = 'fugafuga'
 
    def __init__(self, cam):
        super().__init__(cam)
 
    def do(self): 
        self.foo()
        
        while True:
            #通常sleep+ビジーウェイトでタイマー構築
            sleep_time = 1/(freq+5)
            self.wait(sleep_time)
            while time.perf_counter() < current_time + 1.0/freq:
                pass
            current_time = current_time + 1.0/freq

            # 画像取得
            self.img = cv2.cvtColor(self.camera.readFrame(),cv2.COLOR_BGR2HSV)
            flames_stage_pixel, arrow_pixel = self.img[150,490], self.img[535,725]
            flames_hue, arrow_hue = flames_stage_pixel[0], arrow_pixel[0]
            ...
        
    def foo(self):
        print("launch game")
        # ゲーム選択
        self.press(Button.A, wait=1.2)
        # ユーザー選択
        self.press(Button.A, wait=25)
        #(暗転)

        # 言語選択
        self.press(Button.A, wait=2.0)
        self.press(Button.A, wait=2.0)
        self.press(Button.A, wait=2.0)
        

ここで、fooメソッドを使いまわすことで、マクロスクリプトを部分的に再利用できないかを考えてみます。このとき取りうる方法としては、主に以下の二パターンが思い浮かびます。

  • メソッド自体をコピペして移植する
  • 多重継承/継承を利用する

しかしながら、いずれもあまり好ましい方法とは言えません。

前者の主な問題はメンテナンス性や可読性にあります。コピー元のコードに不具合があった場合にコピー先のあらゆるコードを修正する手間がかかるでしょうし、同じようなメソッドが乱立して見通しも悪くなります。

後者に関しても、複数のクラスを同時に継承することで、メソッド解決順序やインスタンス変数の共有に起因した予期せぬバグを引き起こしたりする可能性もあります。そもそも、継承という手段はメソッド単位での再利用という目的に対してはあまりに大がかりかつ複雑で、初心者にもやさしくありません。

総じて、再利用性の低いad-hocなメソッドが乱立する羽目になります。例え多くのマクロにみられるような汎用的な処理であっても、それらを切り出すことが難しくなってしまっているのが現状です。

複雑怪奇なクラス設計

マクロスクリプトにおけるキー入力コマンドは、PythonCommandのインスタンスメソッドとして定義されています。

例えば、指定された時間だけキー入力を実行するpressメソッドの実装は以下のようになっています。

class PythonCommand(CommandBase.Command):
    ...
    
    @abstractclassmethod
    def do(self):
        pass

    def do_safe(self, ser: Sender):
        if self.keys is None:
            self.keys = KeyPress(ser)
    ...
    
    def press(self, buttons: Button | Hat | Stick | Direction, duration: float = 0.1, wait : float = 0.1):
        self.keys.input(buttons)
        self.wait(duration)
        self.keys.inputEnd(buttons)
        self.wait(wait)
        self.checkIfAlive()
    ...    

何やら見慣れないKeyPressインスタンス(keys)を使ってアレコレしていますね。どうやらこれは外からSenderインスタンスが与えられる形で初期化されているようです。

さらに実装を追ってみましょう。KeyPressの中身を覗いてinputメソッドとinputEndメソッドを抜粋してみます。

class KeyPress:

    def input(self, btns: Button | Hat | Stick | Direction, ifPrint=True):
        self._pushing = dict(self.format.format)
        if not isinstance(btns, list):
            btns = [btns]

        for btn in self.holdButton:
            if not btn in btns:
                btns.append(btn)

        self.format.setButton([btn for btn in btns if type(btn) is Button])
        self.format.setHat([btn for btn in btns if type(btn) is Hat])
        self.format.setAnyDirection([btn for btn in btns if type(btn) is Direction])

        self.ser.writeRow(self.format.convert2str())
        self.input_time_0 = time.perf_counter()
        
    def inputEnd(self, btns: Button | Hat | Stick | Direction, ifPrint=True, unset_hat=True):
        # self._logger.debug(f"input end: {btns}")
        self.pushing2 = dict(self.format.format)

        self.ed = time.perf_counter()
        if not isinstance(btns, list):
            btns = [btns]
        # self._logger.debug(btns)

        # get tilting direction from angles
        tilts = []
        for dir in [btn for btn in btns if type(btn) is Direction]:
            tiltings = dir.getTilting()
            for tilting in tiltings:
                tilts.append(tilting)
        # self._logger.debug(tilts)

        self.format.unsetButton([btn for btn in btns if type(btn) is Button])
        if unset_hat:
            self.format.unsetHat()
        self.format.unsetDirection(tilts)
        self.ser.writeRow(self.format.convert2str())

すごいわちゃわちゃしていますね。

どうやらinputメソッドは、入力情報のフォーマットを行うSendFormatインスタンス(self.format)に対して各キー入力を与えてシリアル通信用文字列を生成し、それをSenderインスタンス(self.ser)のwriteRowメソッドに与えて通信を行っているようですね。

キー入力の一連の処理をシーケンス図っぽく整理してみるとこんな感じ。

Created with Raphaël 2.2.0PythonCommandPythonCommandKeyPressKeyPressSendFormatSendFormatSenderSenderinput(buttons:List<Button|Hat|Direction>)setButton(btns:List<Button>), setHat(btns:List<Hat>), setAnyDirection(btn:List<Direction>)convert2str()msgwriterow(msg)wait(duration:float)inputEnd(buttons:List<Button|Hat|Direction>)unsetButton(btns:List<Button>), unsetHat(btns:List<Hat>), unsetAnyDirection(btn:List<Direction>)convert2str()msgwriterow(msg)

KeyPressPythonCommandから独立したクラスになっているのが少し奇妙に思えます。わざわざKeyPressを介してSendFormatの状態を操作し、Senderで通信を行う理由が見いだせません。

また、各クラスの持つ状態についても気になる点が見受けられます。

KeyPress, SendFormat, Senderクラスの__init__メソッドを覗いてみましょう。

KeyPress
def __init__(self, ser: Sender):
        self._logger = getLogger(__name__)
        self._logger.addHandler(NullHandler())
        self._logger.setLevel(DEBUG)
        self._logger.propagate = True

        self.q = queue.Queue()
        self.ser = ser
        self.format = SendFormat()
        self.holdButton = []
        self.btn_name2 = ['LEFT', 'RIGHT', 'UP', 'DOWN', 'UP_LEFT', 'UP_RIGHT', 'DOWN_LEFT', 'DOWN_RIGHT']

        self.pushing_to_show = None
        self.pushing = None
        self.pushing2 = None
        self._pushing = None
        self._chk_neutral = None
        self.NEUTRAL = dict(self.format.format)

        self.input_time_0 = time.perf_counter()
        self.input_time_1 = time.perf_counter()
        self.inputEnd_time_0 = time.perf_counter()
        self.was_neutral = True
SendFormat
def __init__(self):

    self._logger = getLogger(__name__)
    self._logger.addHandler(NullHandler())
    self._logger.setLevel(DEBUG)
    self._logger.propagate = True

    # This format structure needs to be the same as the one written in Joystick.c
    self.format = OrderedDict([
        ('btn', 0),  # send bit array for buttons
        ('hat', Hat.CENTER),
        ('lx', center),
        ('ly', center),
        ('rx', center),
        ('ry', center),
    ])

    self.L_stick_changed = False
    self.R_stick_changed = False
    self.Hat_pos = Hat.CENTER
Sender
def __init__(self, is_show_serial: tk.BooleanVar, if_print: bool = True):
    self.ser = None
    self.is_show_serial = is_show_serial

    self._logger = getLogger(__name__)
    self._logger.addHandler(NullHandler())
    self._logger.setLevel(DEBUG)
    self._logger.propagate = True

    self.before = None
    self.L_holding = False
    self._L_holding = None
    self.R_holding = False
    self._R_holding = None
    self.is_print = if_print
    self.time_bef = time.perf_counter()
    self.time_aft = time.perf_counter()
    self.Buttons = ["Stick.RIGHT", "Stick.LEFT",
                    "Button.Y", "Button.B", "Button.A", "Button.X",
                    "Button.L", "Button.R",
                    "Button.ZL", "Button.ZR",
                    "Button.MINUS", "Button.PLUS",
                    "Button.LCLICK", "Button.RCLICK",
                    "Button.HOME", "Button.CAPTURE", ]
    self.Hat = ["TOP", "TOP_RIGHT",
                "RIGHT", "BTM_RIGHT",
                "BTM", "BTM_LEFT",
                "LEFT", "TOP_LEFT",
                "CENTER"]

KeyPressはどのボタンがホールドされているか(self.holdButton)、SendFormatはL/Rスティックの入力に変更があったか(self.L_stick_changed/self.R_stick_changed)、現在の十字キーの入力(self.Hat_pos) を状態として持っています。キー入力の状態が各クラスに分散しており、誰が何の状態を持つのかの境界があやふやです。

こうした複雑怪奇な設計の元で新たな機能追加や改修を行うのにはしばしば困難を伴います。

ログ出力

Poke-Conにおいて、現在のループ回数や条件分岐の結果、画像認識結果といった情報をログウィンドウに出力するにはprint関数を利用します。この処理は一見すると何の変哲もない処理に見えますが、実のところ極めて奇妙な振る舞いをしています。

print関数は与えられた文字列を任意のテキストストリームに出力する関数です。通常はデフォルト引数として標準出力(sys.stdout)をテキストストリームとして実行します。言い換えれば、テキストストリームとして何らかのオブジェクトを指定しない限り、print関数と全く無関係のログウィンドウに何かが出力されることはあり得ません。

この奇妙な振る舞いの原因はWindow.py内の以下の処理にあります。

   
class PokeControllerApp():
    ...
    def switchStdoutDestination(self):
        val = self.stdout_destination.get()
        if val == '1':
            sys.stdout = StdoutRedirector(self.text_area_1)
            print("standard output destination is switched.")
            Command.stdout_destination = val
        elif val == '2':
            sys.stdout = StdoutRedirector(self.text_area_2)
            print("standard output destination is switched.")
            Command.stdout_destination = val
    ...

つまり、標準出力オブジェクトそのものをファイルオブジェクトと同等の振る舞いをするユーザー定義オブジェクトで上書きしている訳です。一種のダックタイピングですね。

とはいえ、print関数でログ出力をしたいという目先の簡単のためだけに標準出力をリダイレクトしてしまうというのは余りに大がかりな印象を受けます。GUI上に何かしらを表示したいということであれば、キー入力処理などと同様に、PythonCommandあるいはImageProcPythonCommand上で専用のメソッドを定義したうえで、そこを経由する形でログ出力するべきだと考えます。