toio core cubeで、飛ぶぞ(AceCombat™7で)

# 着任したばかりですまないが 

　君が搭乗する機体は[操縦桿が折れてしまった](https://blog.goo.ne.jp/flight2005/e/419826e2a4dcf1ec00f1b642a72abdc1)。幸い、toio core cubeで姿勢が取れるようになったらしい。そこで、君にはtoio core cubeで[ACECOMBAT™7](https://ace7.acecombat.jp/)を操縦してもらう。君の確かな腕が必要だ。頼んだぞ。

# トイオ、お前はRaspberry Py zero Wとエレメントを組め

　えー、本来、[ACECOMBAT™7](https://ace7.acecombat.jp/)(戦闘機を操るフライトシューティングゲームです)は下図のようなフライトスティックやゲームパッドで遊ぶものですが、今回、toio core cubeの姿勢情報やボタン操作などを使って操縦してみます。なお、ACECOMBAT™7はSteam版(PC版)を使います。

![HowToPlay.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431649/d8551e35-b26d-7d7d-451e-2ed2b9d3e2ae.png)

　toio core cubeの姿勢情報やボタン操作は Raspberry Pi Zero WのBLEで読み出します。そしてその情報をもとに、USB HIDデバイス(ゲームパッドやキーボードやマウスなど)の操作情報に変換し、PCに送り込んであげると、PC側ではあたかもUSBゲームパッドやUSBキーボードやマウスから操作されたかのようにふるまいます。

## ハードウェア
![SystemOverview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431649/4ca4df8f-b8c7-e45f-6984-9d8ce9b1e9a9.png)

PCとの接続との様子
<img alt="PCとの接続写真" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431649/a00a0337-2527-6c3d-543e-35663492cc26.jpeg" width=50% />

　写真中、PCとUSBケーブルが2本つながっているのは、一つはUSB HIDデバイス(キーボードとマウス)としての接続、もう一つはFTDIを介してRaspberry Pi Zero Wのシリアルポートにつないでいます。これはWindows PCからシリアルポートでログインしてRaspberry Pi Zero Wを操作するためです。(Raspberry Pi Zero WをローカルネットにWiFi接続しているなら、SSHでログインすればいいのでこの接続は不要)

## ソフトウェア
　Raspberry PiのUSBポートは通常は周辺機器をつなぐホストモードで動作しますが、Raspberry Pi Zeroのmicro BおよびRaspberry Pi 4BのType-CポートはUSB OTGの機能をもっており、ターゲットモード、つまりRaspberry Pi自身がUSB機器としてふるまうように設定することができます。
　ターゲットモードにするにはUSB Gadget Driverを使うのですが、今回はその設定に　https://github.com/roy-n-roy/raspi_web_console のシェルスクリプト [init_usb.sh](https://github.com/roy-n-roy/raspi_web_console/blob/master/init_usb.sh) をそのまま使わせていただきました。このシェルスクリプトを(root権限で)Raspberry Pi Zero Wで動かすと以下のデバイスファイルが生成されます。ここにレポートデータというバイナリデータを書き込むと、キーボードやマウスを操作したのと同じUSB HIDパケットがUSBポートから送出されます。Raspberry Pi ZeroのUSBポートをPCにつないでおけば、PCはキーボードやマウスを操作されたのと同じ挙動をします。

```init_usb.shを動かすと生成されるデバイスファイル
/dev/hidg0 にレポートデータを書き込むとキーボード
/dev/hidg1 にレポートデータを書き込むとマウス/デジタイザ
```

　それぞれのデバイスファイルに書き込むレポートデータの仕様は以下の通りです。

キーボード操作のレポートデータ　合計8バイト

| modifier key |  scancode 1  |  scancode 2  |  scancode 3  |  scancode 4  |  scancode 5  |scancode 6  |
| ---- | ---- |---- | ---- | ---- | ---- | ---- | ---- |
|  2bytes |  1byte  | 1byte  | 1byte  | 1byte  | 1byte  | 1byte  |

マウス操作のレポートデータ　合計10バイト

| 0x01 |ボタン5bit|X signed 16bit|Y signed 16bit| wheel signed 16bit | AC pan signed 16bit |
| ---- | ---- |---- | ---- | ---- | ---- | ---- | ---- |
|1byte|1byte|2bytes|2bytes|2bytes|2bytes|

　
　[pythonライブラリtomotoio](https://github.com/kenichi884/tomotoio/)を使って、toio core cubeの姿勢情報やボタン操作、マットの座標などを読み取り、それをキーボード、マウスの操作情報に相当するレポートデータに変換し、/dev/hidg[01]に書き込むpythonスクリプトを書いて動かします。

# 本番だ　トイオ、お前の腕を見せてもらうぞ

## 操作方法
　本来のSteam版(PC版) ACECOMBAT™7のキーアサインはこんな感じです。

![AC7HowToPlayWithKeyboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431649/9ec96c23-ddf0-62ee-66c2-1c9bcc8399bb.png)

　これに対してtoio core cubeの操作を操縦桿、スロットル、兵装切り替え、発射ボタンなどにわりあてるわけですが、いろいろ試行錯誤した結果、３つのtoio core cubeを使うことにしました。

- スロットルキューブ　スロットルとヨー(垂直尾翼) 簡易プレイマットの上で動かします。
- 兵装選択キューブ　兵装切り替え　キューブを置いた簡易カードのマスの位置で切り替えます。
- 操縦桿キューブ　天地をひっくり返して持ち、キューブの傾きで操縦桿。ボタンは発射ボタン。

　「toio™コア キューブ」（単体）付属の簡易プレイマットを敷いた中央に、「toio™コア キューブ」（単体）付属の簡易カード(折りたたんで一部のみ)を置き、その上に兵装選択キューブを置きます。その左側にスロットルキューブを置きます。操縦桿キューブは右手で持って使うので右側に置いておきます。

![P_20210606_145136.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431649/f319bad5-a0e7-caca-d1ae-349e80f06eaa.jpeg)

https://youtu.be/8HiIUsswSkI

　これ以外の操作(チャフ散布、レーダーレンジ切り替え、視点切り替えなど)もあったりするのですが、必要最小限に絞りました。

　今回作ったスクリプトは以下の通りです。動かすには、まず[tomotoioライブラリ](https://github.com/kenichi884/tomotoio/)のscan-cubes.shで、3つのtoio core cubeのBLE UUIDを採取しtoio-cubes.txtに保存します。(toio core cubeの割り当ては、順番に、操縦桿、スロットル、兵装選択になります。)
　そしてこのスクリプトをtomotoioライブラリのexamples/ディレクトリに置いてpython3で動かします。(examples/utils.pyを使うので)
　また、デバイスファイル/dev/hidg[01]にアクセスする都合上、root権限で動かす必要があります。

<details><summary>pythonスクリプトの内容はここをクリック(約200行)</summary><div>

```python:keyboard_mouse.py
import logging as log
from datetime import datetime
from functools import partial
from time import sleep

from utils import createCubes, releaseCubes, Cube
from utils import MagneticSenseType, PostureType, NotifyType
from utils import TiltEuler
from tomotoio.data import Light, Note, ToioIDType

def write_report_mouse(report):
    with open('/dev/hidg1', 'rb+') as fd:
        fd.write(report)

def write_report_keyboard(report):
    with open('/dev/hidg0', 'rb+') as fd:
        fd.write(report)
#        print(report.hex())

def make_report_mouse(btn1, btn2, btn3, btn4, btn5, x, y, wheel, pan) -> bytes:
    btnbit  = 0x00
    if(btn1 == True):
        btnbit = btnbit | 0x01
    if(btn2 == True):
        btnbit = btnbit | 0x02
    if(btn3 == True):
        btnbit = btnbit | 0x04
    if(btn4 == True):
        btnbit = btnbit | 0x08
    if(btn5 == True):
        btnbit = btnbit | 0x10
        
    return bytes([1, btnbit]) + x.to_bytes(2, byteorder='little', signed=True) +  y.to_bytes(2, byteorder='little', signed=True) + wheel.to_bytes(2, byteorder='little', signed=True) + pan.to_bytes(2, byteorder='little', signed=True)

def clip2bytes(i:int) -> int:
    if(i < -32767):
        i = -32767
    elif (i > 32767):
        i = 32767
    return int(i)

def make_report_keyboard(modifier, scancodes) -> bytes:
    return bytes([modifier, 0]) + scancodes
        
cubes = createCubes()

roll = 0
pitch = 0
yaw = 0

cube_btn0 = False
cube_btn1 = False
cube_btn2 = False

THROTTLE_CENTER_Y = 270
THROTTLE_CENTER_ANGLE = 270
throttle_y = THROTTLE_CENTER_Y
throttle_angle = THROTTLE_CENTER_ANGLE

vulcan = False
sp_weapon = False
prev_sp_weapon = False

try:
    def listener_motion(cube: Cube, notificationType: str, e):
#        log.debug("%s, %s, %s, %s", datetime.now().isoformat(), cube.name, notificationType, e)
        global roll, pitch, yaw
        if(type(e) is TiltEuler):
            roll = e.roll
            pitch  = e.pitch
            yaw  = e.yaw
#            log.debug("%s, %s, %s, %s", datetime.now().isoformat(), roll, pitch, yaw)

    def button_listener(cube: Cube, notificationType: str, e):
        global cube_btn0, cube_btn1, cube_btn2
#        log.debug("%s %s", notificationType, e)
        if(notificationType == 'Button1'):
            cube_btn1 = e
            if(cube_btn1 == True): 
                cubes[1].setLight(0, 255, 0, 0)
            else:
                cubes[1].setLightOff()
        elif(notificationType == 'Button2'):
            cube_btn2 = e
        else:
            cube_btn0 = e
            if(cube_btn0 == True): 
                cubes[0].setLight(255, 0, 0, 0)
            else:
                cubes[0].setLightOff()

    def toioid_listener(cube: Cube, notificationType: str, e):
        global throttle_y, throttle_angle, vulcan, sp_weapon
#        log.debug("%s %s", notificationType, e)
        if(notificationType == 'Throttle'):
            if(e.type == ToioIDType.POSITION):
                throttle_y = e.y
                throttle_angle = e.angle
        elif(notificationType == 'WeaponSelect'):
            if(e.type == ToioIDType.STANDARD):
                if(e.value == 3670366):
                    vulcan = False
                    sp_weapon = False
                    cubes[2].setLight(0,0, 255)
                elif(e.value == 3670305):
                    vulcan = False
                    sp_weapon = True
                    cubes[2].setLight(0,255,0)
                elif(e.value == 3670335):
                    vulcan = True
                    cubes[2].setLight(255,0,0)

    cubes[0].motion.addListener(partial(listener_motion, cubes[0], 'motiontilt'))
    cubes[0].motion.enableNotification()
    cubes[0].setConfigHighPrecisionTiltSensor(PostureType.EULER, 0.1, NotifyType.PERIODIC)
    cubes[0].button.addListener(partial(button_listener, cubes[0], 'Button0'))
    cubes[0].button.enableNotification()

    cubes[1].button.addListener(partial(button_listener, cubes[1], 'Button1'))
    cubes[1].button.enableNotification()
    cubes[1].toioID.addListener(partial(toioid_listener, cubes[1], 'Throttle'))
    cubes[1].toioID.enableNotification()
    
    cubes[2].button.addListener(partial(button_listener, cubes[2], 'Button2'))
    cubes[2].button.enableNotification()
    cubes[2].toioID.addListener(partial(toioid_listener, cubes[2], 'WeaponSelect'))
    cubes[2].toioID.enableNotification()
    
    prev_yaw = yaw
    while True:
        Ys = pitch  * 32768 / 90
        if(roll < 0) :
            Xs = roll + 180 
        else:
            Xs = roll - 180
        Xs = Xs * 32768 / 90
        Xs = -0.003 * Xs
        Ys = -0.01 * Ys
          
        if(abs(Xs) < 15) :
            Xs = 0
        if(abs(Ys) < 15) :
            Ys = 0
#        log.debug("Xs %s Ys %s cube_btn0 %s cube_btn1 %s",
#                  Xs,  Ys, cube_btn0, cube_btn1)
#        log.debug(" cube_btn0 %s cube_btn1 %s cube_btn2 %s", cube_btn0, cube_btn1, cube_btn2)
#        log.debug(" vulcan %s sp_weapon %s ", vulcan ,sp_weapon)
        if(vulcan):
            mouse_btn1 = cube_btn0
            mouse_btn2 = False
        else:
            mouse_btn1 = False
            mouse_btn2 = cube_btn0
        if(sp_weapon != prev_sp_weapon):
            mouse_btn3 = True
            prev_sp_weapon = sp_weapon
#            log.debug("change")
        else:
            mouse_btn3 = False
#        log.debug("mouse_btn %s %s %s", mouse_btn1, mouse_btn2, mouse_btn3)    
        report_mouse = make_report_mouse(mouse_btn1, mouse_btn2, 0, 0, 0, clip2bytes(Xs), clip2bytes(Ys), 0, 0)
        write_report_mouse(report_mouse)
        throttle = THROTTLE_CENTER_Y - throttle_y 
        plane_yaw = throttle_angle - THROTTLE_CENTER_ANGLE
#        log.debug(" throttle %s plane_yaw %s ", throttle ,plane_yaw)
        throttle_key = 0
        yaw_key = 0
        if(abs(throttle) > 10):
            if(throttle > 0):
                throttle_key = 26  # w
                cubes[1].setLight(255,0,0)
            else:
                throttle_key = 22  # s
                cubes[1].setLight(0,0,255)
        else:
            cubes[1].setLightOff()
        if(abs(plane_yaw) > 8):
            if(plane_yaw > 0) :
                yaw_key = 8 # e
            else:
                yaw_key = 20 # q
        scancodes = bytes()
        if(throttle_key != 0):
            scancodes = scancodes + bytes([throttle_key])
        if(yaw_key != 0):
            scancodes = scancodes + bytes([yaw_key])
        if(cube_btn2 == True):
            scancodes = scancodes + bytes([43]) # TAB change target
        if(cube_btn1 == True):
            scancodes = scancodes + bytes([3]) # 2 high G turn
        if(mouse_btn3 == True): # change sp_weapon
            scancodes = scancodes + bytes([6]) # c key
        for i in range(6 - len(scancodes)):
            scancodes = scancodes + bytes([0])
        report_keyboard = make_report_keyboard(0, scancodes)
        write_report_keyboard(report_keyboard)
        #print(scancodes.hex())
        sleep(0.02)
finally:
    releaseCubes(cubes)

```

</div>

# トイオ お前の腕なら十分やれる

## 実際の操作の様子

https://youtu.be/7GpJZcVbabw

　というわけで無事、任務を終えて[RTB](https://ejje.weblio.jp/content/return+to+base)できました。(動画ではカットしていますが、ちゃんとMission 01をクリアして帰投しています。)
　toio core cubeでも飛べます。

# 編隊長、トイオは使えますよ

　まあ、toio core cubeの傾きをマウス操作とのチューニングがまだまだいまいちでMission 01の難易度Normalをクリアするのが精一杯だったりしますが。

(注) 章のタイトルはAceCombat™️7で出てくるセリフからの引用ですので、なんだかわかんなかった人はすいません。