Help us understand the problem. What is going on with this article?

Joy-ConにPythonからBluetooth接続をして6軸センサーと入力情報を取得する

はじめに

ィヤッフゥウ〜〜〜↑↑↑!こんにちは、配管工始めました。トコロテンです。
この記事では、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と端末がペアリングされていないため、ペアリングしてからもう一度実行してみてください。

devices_list.py
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_idproduct_idUSB 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レポートの前回の設定が引き継がれている可能性があるためです。

joycon_read_test.py
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個)のアップカウンタを実装してみます。

点灯バージョン
joycon_player_lamp.py
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
点滅バージョン
joycon_player_lamp.py
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 0x01Subcommand 0x40に引数として1を与えると各センサーが有効化されます。次に、OUTPUT 0x01Subcommand 0x03に引数として0x30を与えると60Hzでボタン、スティック、6軸センサーの全てのデータを定期的に送信するようになります。

データ取得のみ
joycon_sensors.py
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にした方が扱いやすいため、このオフセット分を考慮してデコードを行う必要があります。
これらのことを考慮してデータの一部をデコードするプログラムの実装例を以下に示します。

a
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さんのリポジトリを見れば今回紹介したことよりも多くのことができるようになります。しかし、まだ解析されていない部分もいくつかあります。有識者の方がいたら教えていただけると嬉しいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした