はじめに
ィヤッフゥウ〜〜〜↑↑↑!こんにちは、配管工始めました。トコロテンです。
この記事では、Nintendo Switchのジョイコン(Joy-Con)をPython3から接続して6軸センサーを含む各種入力を取得する方法を紹介します。
以下のリポジトリにてJoy-Conのプチドライバ(?)の実装を公開しています。まだ開発を始めたばかりですがよければ開発に協力していただけると嬉しいです。ドキュメントを近いうちに整備して機能も増やしていく予定です。
https://github.com/tokoroten-lab/joycon-python
動作環境
以下の環境で動作を確認しました。
- macOS Mojave (10.14.6)
- Python (3.7.4)
- hidapi (0.7.99.post21)
Joy-Conの仕様
Joy-Conの主な仕様や各種名称は任天堂の以下の公式サイトで確認することができます。
https://www.nintendo.co.jp/hardware/switch/feature/index.html#3
また、以下のリポジトリでJoy-Conの仕様をリバースエンジニアリングを用いて解析した情報が公開されています。かなり詳しい仕様が載っているため、1度目を通しておくことをおすすめします。この記事を書くにあたって大変お世話になりました。ありがとうございます。
https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering
Joy-Conとの通信
Bluetooth/HIDで接続する
仕様確認
Joy-Conとの通信にはBluetooth/HIDを用いて接続することが可能です。
HIDのInput, Output, Featureレポートのフォーマットは以下のページにて確認できます。
https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md
サンプルプログラム
準備
PythonでBluetooth/HIDを用いた接続を利用するためにはcython-hidapiを利用しました。以下のようにしてpip
でインストールが可能です。
sudo pip install hidapi
注意点
@shksさんのpython モジュールhidapiとhidに注意。という記事にあるように、hidapi
に似たライブラリでhid
といったものがあります。間違えないように気をつけましょう。プログラム内でimportする際にはどちらもimport hid
となります。
ペアリング確認用プログラム
以下のプログラムを実行することで認識しているHIDデバイス一覧を取得できます。
この中にJoy-Conの情報があるか確認してみましょう。無い場合はJoy-Conと端末がペアリングされていないため、ペアリングしてからもう一度実行してみてください。
import hid
for device in hid.enumerate(0, 0):
for k, v in device.items():
print ('{} : {}'.format(k, v))
print ('')
私の環境では以下のようなセクションが表示されました。product_string : Joy-Con (L)
といった表記からこれがJoy-Conのデバイス情報であることがわかります。また、vendor_id
とproduct_id
がUSB ID Databaseにて調べた情報と一致していることからもわかります。
path : b'IOService:/IOResources/IOBluetoothHCIController/AppleBroadcomBluetoothHostController/IOBluetoothDevice/IO
BluetoothL2CAPChannel/IOBluetoothHIDDriver'
vendor_id : 1406
product_id : 8198
serial_number : b8-78-26-46-9b-84
release_number : 1
manufacturer_string : Unknown
product_string : Joy-Con (L)
usage_page : 1
usage : 5
interface_number : -1
ボタンデータ取得用プログラム
Joy-ConのVendor IDとProduct IDは以下の通りです。この情報を利用して接続をします。
-
Joy-Con (L)
- Vendor ID : 0x057E(1406)
- Product ID: 0x2006(8198)
-
Joy-Con (R)
- Vendor ID : 0x057E(1406)
- Product ID: 0x2007(8199)
また、Joy-ConのHIDのInputレポートのフォーマットは以下のページの通りです。
https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md#input-reports
試しにJoy-Conのボタンやスティックの入力情報を取得してみましょう。
これに対応するInputレポートはINPUT 0x3Fです。
このInputレポートを受け取るためには、まずOUTPUT 0x01のフォーマットに従ってSubcommand 0x03のサブコマンドを送信します。このサブコマンドには引数が存在し、0x3F
を指定することでJoy-Conのボタンの入力状態の変化があったときのみINPUT 0x3F
のInputレポートを発行するようになります。レポートのサイズは12バイトです。
実はJoy-Conは初期状態でInputレポートINPUT 0x3F
を送信するようになっていますが、ここではあえて設定します。なぜなら、Joy-ConのInputレポートの前回の設定が引き継がれている可能性があるためです。
import hid
import time
VENDOR_ID = 0x057E
L_PRODUCT_ID = 0x2006
R_PRODUCT_ID = 0x2007
def write_output_report(joycon_device, packet_number, command, subcommand, argument):
joycon_device.write(command
+ packet_number.to_bytes(1, byteorder='big')
+ b'\x00\x01\x40\x40\x00\x01\x40\x40'
+ subcommand
+ argument)
if __name__ == '__main__':
joycon_device = hid.device()
joycon_device.open(VENDOR_ID, L_PRODUCT_ID)
write_output_report(joycon_device, 0, b'\x01', b'\x03', b'\x3f')
while True:
print(joycon_device.read(12))
Joy-Conと端末をペアリングした状態で上のプログラムを実行してJoy-Conで何か操作をするとInputレポートの情報が標準出力に出力されます。
write_output_report(...)
メソッドにてOutputレポートを構築して送信します。
引数のpacket_number
はレポートを送るごとに0x0-0xFの範囲でインクリメントする必要があります。
プレイヤーランプ操作用プログラム
ここではJoy-Conのプレイヤーランプを用いて簡単な2進4ビットカウンタを動作させる方法を紹介します。先ほどと同じ要領でOUTPUT 0x01
レポートを送信します。サブコマンドはSubcommand 0x30を用います。また、ランプの点灯・点滅パターンを1Byteで引数として設定します。
ビット解釈
ランプの点灯・点滅パターンは各4bitずつ合わせて1Byteで表現します。
上位4bitが点滅パターンで下位4bitが点灯パターンです。
ビットが0であるとき消灯を意味し、1である場合には点灯・点滅を意味します。
従って、全点灯や全点滅に対応したビット列は以下のようになります。
- 全点灯: 0b00001111
- 全点滅: 0b11110000
では、以下のようなビット列はどのような結果を得られるでしょうか。
- 0b00010001
点灯・点滅で同じ位置に対応するビットが1になっています。このような場合には、点灯が優先されます。したがって、あるランプが点滅する条件は、対応する点滅用のビットが1であり点灯用のビットが0であることが条件です。
以上を踏まえて4bit(Joy-Conのプレイヤーランプは4個)のアップカウンタを実装してみます。
点灯バージョン
import hid
import time
VENDOR_ID = 0x057E
L_PRODUCT_ID = 0x2006
R_PRODUCT_ID = 0x2007
def write_output_report(joycon_device, packet_number, command, subcommand, argument):
joycon_device.write(command
+ packet_number.to_bytes(1, byteorder='big')
+ b'\x00\x01\x40\x40\x00\x01\x40\x40'
+ subcommand
+ argument)
if __name__ == '__main__':
joycon_device = hid.device()
joycon_device.open(VENDOR_ID, L_PRODUCT_ID)
count = 0
while True:
time.sleep(1)
write_output_report(joycon_device, count, b'\x01', b'\x30', count.to_bytes(1, byteorder='big'))
count = (count + 1) & 0xf
点滅バージョン
import hid
import time
VENDOR_ID = 0x057E
L_PRODUCT_ID = 0x2006
R_PRODUCT_ID = 0x2007
def write_output_report(joycon_device, packet_number, command, subcommand, argument):
joycon_device.write(command
+ packet_number.to_bytes(1, byteorder='big')
+ b'\x00\x01\x40\x40\x00\x01\x40\x40'
+ subcommand
+ argument)
if __name__ == '__main__':
joycon_device = hid.device()
joycon_device.open(VENDOR_ID, L_PRODUCT_ID)
count = 0
while True:
time.sleep(1)
write_output_report(joycon_device, count, b'\x01', b'\x30', (count << 4).to_bytes(1, byteorder='big'))
count = (count + 1) & 0xf
プレイヤーランプを点灯・点滅させることができました。Subcommand 0x38を利用することでHOMEボタンを光らせることもできるようです。興味のある方はぜひやってみてください。
ボタン&スティック&6軸センサーデータ取得用プログラム
Joy-ConにはボタンやXYZ軸の加速度センサーとジャイロセンサーが搭載されています。
これらのデータを取得するためには、まずセンサーを有効化した後にInputレポートの形式を変える必要があります。
まず、OUTPUT 0x01
でSubcommand 0x40に引数として1
を与えると各センサーが有効化されます。次に、OUTPUT 0x01
でSubcommand 0x03に引数として0x30
を与えると60Hzでボタン、スティック、6軸センサーの全てのデータを定期的に送信するようになります。
データ取得のみ
import hid
import time
VENDOR_ID = 0x057E
L_PRODUCT_ID = 0x2006
R_PRODUCT_ID = 0x2007
def write_output_report(joycon_device, packet_number, command, subcommand, argument):
joycon_device.write(command
+ packet_number.to_bytes(1, byteorder='big')
+ b'\x00\x01\x40\x40\x00\x01\x40\x40'
+ subcommand
+ argument)
if __name__ == '__main__':
joycon_device = hid.device()
joycon_device.open(VENDOR_ID, L_PRODUCT_ID)
# 6軸センサーを有効化
write_output_report(joycon_device, 0, b'\x01', b'\x40', b'\x01')
# 設定を反映するためには時間間隔が必要
time.sleep(0.02)
# 60HzでJoy-Conの各データを取得するための設定
write_output_report(joycon_device, 1, b'\x01', b'\x03', b'\x30')
while True:
print(joycon_device.read(49))
各データをバイト列として取得しただけではどうしようもないので実際に扱えるデータにデコードしてみましょう。
データ取得&デコード
全体のデータフォーマットはStandard input report formatにて確認できます。6軸センサーのデータフォーマットは6-Axis sensor informationにて確認できます。ドキュメント中にあるInt16LE
といった表記は符号付き16ビット整数でバイトオーダーがリトルエンディアン(一般的なバイトの並び方と逆)であることを意味しています。
また、加速度センサーから取得したデータにはオフセットが加算されています。基準値を0にした方が扱いやすいため、このオフセット分を考慮してデコードを行う必要があります。
これらのことを考慮してデータの一部をデコードするプログラムの実装例を以下に示します。
import hid
import time
VENDOR_ID = 0x057E
L_PRODUCT_ID = 0x2006
R_PRODUCT_ID = 0x2007
L_ACCEL_OFFSET_X = 350
L_ACCEL_OFFSET_Y = 0
L_ACCEL_OFFSET_Z = 4081
R_ACCEL_OFFSET_X = 350
R_ACCEL_OFFSET_Y = 0
R_ACCEL_OFFSET_Z = -4081
MY_PRODUCT_ID = L_PRODUCT_ID
def write_output_report(joycon_device, packet_number, command, subcommand, argument):
joycon_device.write(command
+ packet_number.to_bytes(1, byteorder='big')
+ b'\x00\x01\x40\x40\x00\x01\x40\x40'
+ subcommand
+ argument)
def is_left():
return MY_PRODUCT_ID == L_PRODUCT_ID
def to_int16le_from_2bytes(hbytebe, lbytebe):
uint16le = (lbytebe << 8) | hbytebe
int16le = uint16le if uint16le < 32768 else uint16le - 65536
return int16le
def get_nbit_from_input_report(input_report, offset_byte, offset_bit, nbit):
return (input_report[offset_byte] >> offset_bit) & ((1 << nbit) - 1)
def get_button_down(input_report):
return get_nbit_from_input_report(input_report, 5, 0, 1)
def get_button_up(input_report):
return get_nbit_from_input_report(input_report, 5, 1, 1)
def get_button_right(input_report):
return get_nbit_from_input_report(input_report, 5, 2, 1)
def get_button_left(input_report):
return get_nbit_from_input_report(input_report, 5, 3, 1)
def get_stick_left_horizontal(input_report):
return get_nbit_from_input_report(input_report, 6, 0, 8) | (get_nbit_from_input_report(input_report, 7, 0, 4) << 8)
def get_stick_left_vertical(input_report):
return get_nbit_from_input_report(input_report, 7, 4, 4) | (get_nbit_from_input_report(input_report, 8, 0, 8) << 4)
def get_stick_right_horizontal(input_report):
return get_nbit_from_input_report(input_report, 9, 0, 8) | (get_nbit_from_input_report(input_report, 10, 0, 4) << 8)
def get_stick_right_vertical(input_report):
return get_nbit_from_input_report(input_report, 10, 4, 4) | (get_nbit_from_input_report(input_report, 11, 0, 8) << 4)
def get_accel_x(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return (to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 13 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 14 + sample_idx * 12, 0, 8))
- (L_ACCEL_OFFSET_X if is_left() else R_ACCEL_OFFSET_X))
def get_accel_y(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return (to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 15 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 16 + sample_idx * 12, 0, 8))
- (L_ACCEL_OFFSET_Y if is_left() else R_ACCEL_OFFSET_Y))
def get_accel_z(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return (to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 17 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 18 + sample_idx * 12, 0, 8))
- (L_ACCEL_OFFSET_Z if is_left() else R_ACCEL_OFFSET_Z))
def get_gyro_x(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 19 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 20 + sample_idx * 12, 0, 8))
def get_gyro_y(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 21 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 22 + sample_idx * 12, 0, 8))
def get_gyro_z(input_report, sample_idx=0):
if sample_idx not in [0, 1, 2]:
raise IndexError('sample_idx should be between 0 and 2')
return to_int16le_from_2bytes(get_nbit_from_input_report(input_report, 23 + sample_idx * 12, 0, 8),
get_nbit_from_input_report(input_report, 24 + sample_idx * 12, 0, 8))
if __name__ == '__main__':
joycon_device = hid.device()
joycon_device.open(VENDOR_ID, MY_PRODUCT_ID)
# 6軸センサーを有効化
write_output_report(joycon_device, 0, b'\x01', b'\x40', b'\x01')
# 設定を反映するためには時間間隔が必要
time.sleep(0.02)
# 60HzでJoy-Conの各データを取得するための設定
write_output_report(joycon_device, 1, b'\x01', b'\x03', b'\x30')
while True:
input_report = joycon_device.read(49)
# ボタン
print("Button: {} {} {} {}".format("DOWN " if get_button_down(input_report) else "",
"UP " if get_button_up(input_report) else "",
"RIGHT " if get_button_right(input_report) else "",
"LEFT " if get_button_left(input_report) else ""))
# アナログスティック
print("Stick : {:8d} {:8d}".format(get_stick_left_horizontal(input_report),
get_stick_left_vertical(input_report)))
# 加速度センサー
print("Accel : {:8d} {:8d} {:8d}".format(get_accel_x(input_report),
get_accel_y(input_report),
get_accel_z(input_report)))
# ジャイロセンサー
print("Gyro : {:8d} {:8d} {:8d}".format(get_gyro_x(input_report),
get_gyro_y(input_report),
get_gyro_z(input_report)))
print()
まとめ
dekuNukemさんのリポジトリを見れば今回紹介したことよりも多くのことができるようになります。しかし、まだ解析されていない部分もいくつかあります。有識者の方がいたら教えていただけると嬉しいです。