# PythonによるNintendo Switch 自動化に関する考察(1) ## はじめに プログラミング言語"Python"とワンボードマイコン"Arduino"を利用したSwitchの自動操作環境プラットフォームである[**Poke-Controller**](https://github.com/KawaSwitch/Poke-Controller) (以下**Poke-Con**)は、有志による機能追加([**Poke-Controller-modified**](https://github.com/Moi-poke/Poke-Controller-Modified), [**Poke-Controller-Modified-Extension**](https://github.com/futo030/Poke-Controller-Modified-Extension))が積極的に取り組まれており、自動化スクリプトの開発・配布・利用も盛んです。 私自身も自動化スクリプトの実装のためPoke-Conを活用しておりますが、自動化スクリプトの実装方法や内部の設計については幾つか気になっている点があり、更なる機能追加やパフォーマンスの改善などを行う場合の障壁になるのではという懸念を抱いています。 とはいえ、その懸念点の解消を理由として現行仕様と全く異なる設計思想での再実装を提案したところで、旧仕様との互換性の観点などからコミュニティに受け入れられるかは別の話ですし、そもそもその懸念点自体の妥当性も不確かです。 そこで、まずは現行の実装を読み解くことで問題点を整理し、そのうえでPython上でSwitchの自動化スクリプトがどうあるべきか、ある種の"ぼくのかんがえるさいきょうの自動化基盤アプリ"のモックアップなども交えながら考えを纏めてみることにします。 今回は初回ということで、Poke-Conの実装で気になっているポイントを幾つかピックアップして説明します。 ## Poke-Conの気になるポイント 以下、[**Poke-Controller-Modified-Extension**](https://github.com/futo030/Poke-Controller-Modified-Extension)を参照しつつ整理を進めてみます。 ### マクロスクリプトの再利用性 Poke-Conにおけるマクロスクリプトは、`CommandBase`クラスを継承した`PythonCommand` クラスあるいは`ImageProcPythonCommand`クラスをさらに継承した新たなクラスを定義し、その抽象メソッドである`do`メソッドを具象化する形で記述します。 また、`do`メソッドが長大化する場合は、必要に応じてインスタンスメソッドを定義して分割します。実際に書いてみるとこんな感じ。 ```python 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`メソッドの実装は以下のようになっています。 ```python 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`メソッドを抜粋してみます。 ```python 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`メソッドに与えて通信を行っているようですね。 キー入力の一連の処理をシーケンス図っぽく整理してみるとこんな感じ。 ```sequence PythonCommand -> KeyPress: input(buttons:List<Button|Hat|Direction>) KeyPress -> SendFormat: setButton(btns:List<Button>), \nsetHat(btns:List<Hat>), \nsetAnyDirection(btn:List<Direction>) KeyPress -> SendFormat: convert2str() SendFormat --> KeyPress : msg KeyPress -> Sender: writerow(msg) PythonCommand --> PythonCommand: wait(duration:float) PythonCommand -> KeyPress: inputEnd(buttons:List<Button|Hat|Direction>) KeyPress -> SendFormat: unsetButton(btns:List<Button>), \nunsetHat(btns:List<Hat>), \nunsetAnyDirection(btn:List<Direction>) KeyPress -> SendFormat: convert2str() SendFormat --> KeyPress : msg KeyPress -> Sender: writerow(msg) ``` `KeyPress`は`PythonCommand`から独立したクラスになっているのが少し奇妙に思えます。わざわざ`KeyPress`を介して`SendFormat`の状態を操作し、`Sender`で通信を行う理由が見いだせません。 また、各クラスの持つ状態についても気になる点が見受けられます。 `KeyPress`, `SendFormat`, `Sender`クラスの`__init__`メソッドを覗いてみましょう。 :::spoiler `KeyPress` ```python 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 ``` ::: :::spoiler `SendFormat` ```python 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 ``` ::: :::spoiler `Sender` ```python 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"] ``` ::: <br> `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`内の以下の処理にあります。 ```python 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`上で専用のメソッドを定義したうえで、そこを経由する形でログ出力するべきだと考えます。