ブラウザから操作可能なラジコンを作る【４】

## 【４】DualSenseをブラウザに接続

ラジコン側のモーターの駆動に必要なコンポーネントが
揃ったはずなので、次はコントローラー側を作っていきます。

常識的に考えれば
RapsberryPI積んでるんだから
Bluetoothで直接DualSnseを繋いで
そのまま操作すればいいじゃないかと

そう思われるかもしれませんが
そんな事では、ロマン感が不足する可能性があります。

本体に積んだ映像をブラウザで見ながら操作する。
という条件を付与することで

ブラウザにDualSenseを繋ぐ事の正当性が保たれるわけです。

<br></br>
## 目次
* [【１】Raspberry pi の、GPIOをTypescriptから操作](https://qiita.com/Yasukosan/items/1bf9480c10de7d69c2a4)
* [【２】DCモーターをPWMで速度制御](https://qiita.com/Yasukosan/items/291c76c10be135fd7595)
* [【３】サーボモーターを制御](https://qiita.com/Yasukosan/items/e81c9c9d0159b32e5893)
* [【４】DualSenseをブラウザに接続](https://qiita.com/Yasukosan/items/bf3a085145db84a48823)
* [【５】DualSenseの情報をRaspberryPiに飛ばす](https://qiita.com/Yasukosan/items/400883be94751966221a)
* [【６】DualSenseのチャタリング？問題対応](https://qiita.com/Yasukosan/items/7e52609699b4b71196ca)
* [【７-１】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/98426f1ba4dea4c9ec5f)
* [【７-２】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/2246c8a32602ca77ace9)
* [【８】ThreeJSでVRもどきを作成](https://qiita.com/Yasukosan/items/e15e8fd002cf49e3d04d)
* [【９-１】iPhoneの加速度から頭の向きをVRに反映](https://qiita.com/Yasukosan/items/9b4333770baea4110361)
* [【９-２】スマホVRゴーグル向けデザインに変える](https://qiita.com/Yasukosan/items/7f1bb7a9d95cb76f8117)
* [【１０】ラジコン本体の製作](https://qiita.com/Yasukosan/items/0d37c1df5b5d6c02257b)
* 【１１】パワーアップとバッテリー問題の解決

<br></br>
## コントローラー
DualSenseがあれば問題ありませんが
Amazonで３０００円前後で売られている互換品を今回は使用しています。
<img
    src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147738/fa55a308-a6a4-4298-a047-e85f08654756.jpeg"
    width="320"
/>


<br></br>
## ライブラリの選定

WebHIDの仕様を見ながら、０から書いてもそんな手間は無いんじゃないのか？
そう思いましたが、何か地雷がある気がしなくもない。
ベンダーコード、各コントローラーボタンとバイナリ値のマッピング
この辺の情報収集だけでも、時間がかかりそう。
コントローラーをWebHIDで繋いだ先人は、いっぱい居るであろう。

という楽観視の元、

* 適当なWebHIDに対応したnpmライブラリを探す。
* この時、DualShockとDualSenseに違いはないだろうと勘違いしていた。

この２点の思い違いで進めて、見つけたライブラリ

[WebHID-DS4](https://github.com/TheBITLINK/WebHID-DS4)

WebHID-DS4自体には何ら罪はないのと
思いっきり参考になったので感謝いっぱいです。

問題になったのが、このままDualSenseで利用するにあたり
* 対DualSenseでは使えない
* 若干修正すると、DualSenseが繋がる代わりに、ブラウザがフリーズする。

しょうがないので、このライブラリのソースコードを元に

* フリーズの原因になっていた、コントローラー側にデータを送る処理を全削除
* 何に使ってるか分からないBuffer処理を全削除
* ArrayBufferとコントローラーのマッピングがズレている？のを修正
* コントローラーの入力をreportにまとめる形式から、callback関数に丸投げに変更

今回必要になる、コントローラーからの入力以外
不要 + 互換性がないと思われるコードの削除
以上を行うと、３０００行ほどあったライブラリが
２００行ほどに圧縮されます。

圧縮された結果が以下です。

```typescript
type DualShock4Interface = {
    Disconnected: string,
    USB: 'usb' | 'bt',
    Bluetooth: 'bt' | 'usb'
}
const DualShock4Interface = {
    Disconnected: 'none',
    USB: 'usb',
    Bluetooth: 'bt'
}

const VenderFilter = [
    // Official Sony Controllers
    { vendorId: 0x054C, productId: 0x0BA0 },
    { vendorId: 0x054C, productId: 0x05C4 },
    { vendorId: 0x054C, productId: 0x09CC },
    { vendorId: 0x054C, productId: 0x05C5 },
    // Razer Raiju
    { vendorId: 0x1532, productId: 0x1000 },
    { vendorId: 0x1532, productId: 0x1007 },
    { vendorId: 0x1532, productId: 0x1004 },
    { vendorId: 0x1532, productId: 0x1009 },
    // Nacon Revol
    { vendorId: 0x146B, productId: 0x0D01 },
    { vendorId: 0x146B, productId: 0x0D02 },
    { vendorId: 0x146B, productId: 0x0D08 },
    // Other third party controllers
    { vendorId: 0x0F0D, productId: 0x00EE },
    { vendorId: 0x7545, productId: 0x0104 },
    { vendorId: 0x2E95, productId: 0x7725 },
    { vendorId: 0x11C0, productId: 0x4001 },
    { vendorId: 0x0C12, productId: 0x57AB },
    { vendorId: 0x0C12, productId: 0x0E16 },
    { vendorId: 0x0F0D, productId: 0x0084 },
    //{ vendorId: 0x1356, productId: 0x2508 },
    { vendorId: 0x054C, productId: 0x09CC },
]

/**
 * Default / Initial State
 * @ignore
 */
const defaultState = {
    interface: DualShock4Interface.Disconnected,
    battery: 0,
    charging: false,
    axes: {
        leftStickX: 0,
        leftStickY: 0,
        rightStickX: 0,
        rightStickY: 0,
        l2: 0,
        r2: 0,
        accelX: 0,
        accelY: 0,
        accelZ: 0,
        gyroX: 0,
        gyroY: 0,
        gyroZ: 0
    },
    buttons: {
        triangle: false,
        circle: false,
        cross: false,
        square: false,
        dPadUp: false,
        dPadRight: false,
        dPadDown: false,
        dPadLeft: false,
        l1: false,
        l2: false,
        l3: false,
        r1: false,
        r2: false,
        r3: false,
        options: false,
        share: false,
        playStation: false,
        touchPadClick: false
    },
    touchpad: {
        touches: []
    },
    timestamp: -1
};

export type DualSenseStateType = typeof defaultState

let state: DualSenseStateType = defaultState
let device: any = null
let callback: (
    state: DualSenseStateType,
    data: DataView
) => void = () => {}

export const init = async (
    _callback: (state: DualSenseStateType, data: DataView) => void
) => {
    state = defaultState
    callback = null

    // WebHIDに対応しているか判定
    if (!('hid' in navigator) || !navigator.hid) {
        throw new Error('WebHID not supported by browser or not available.')
    }

    // デバイスに接続済みか判定
    if (device && device.opened)
        return

    // デバイスに接続開始
    const devices = await navigator.hid.requestDevice({
        filters: VenderFilter
    })
    // 問答無用で０番目に接続
    device = devices[0]
    await device.open()

    // コールバック関数を格納
    callback = _callback;
    // デバイスからの入力イベントに関数を割り当て
    device.oninputreport = (e) => processControllerReport(e)
}

const processControllerReport = (report) => {
    // リポートからArrayBuggerが入ったdata部分を取得
    const { data } = report
    // この処理多分要らん
    if (state.interface === DualShock4Interface.Disconnected) {
        state.interface = DualShock4Interface.Bluetooth
        device.receiveFeatureReport(0x02)
        return
    }
    // タイムスタンプ取得
    state.timestamp = report.timeStamp

    if (
        state.interface === DualShock4Interface.Bluetooth
        && report.reportId === 1
    ) {
        // ArrayBufferをキーマッピング関数に投げる
        updateState(new DataView(data.buffer, 0))
    }
}

// マッピング処理
const updateState = (data: DataView) => {

    // Update thumbsticks
    state.axes.leftStickX = normalizeThumbstick(data.getUint8(0))
    state.axes.leftStickY = normalizeThumbstick(data.getUint8(1))
    state.axes.rightStickX = normalizeThumbstick(data.getUint8(2))
    state.axes.rightStickY = normalizeThumbstick(data.getUint8(3))
    // Update main buttons
    const buttons1 = data.getUint8(4)
    state.buttons.triangle = !!(buttons1 & 0x80)
    state.buttons.circle = !!(buttons1 & 0x40)
    state.buttons.cross = !!(buttons1 & 0x20)
    state.buttons.square = !!(buttons1 & 0x10)
    // Update D-Pad
    const dPad = buttons1 & 0x0F
    state.buttons.dPadUp = dPad === 7 || dPad === 0 || dPad === 1
    state.buttons.dPadRight = dPad === 1 || dPad === 2 || dPad === 3
    state.buttons.dPadDown = dPad === 3 || dPad === 4 || dPad === 5
    state.buttons.dPadLeft = dPad === 5 || dPad === 6 || dPad === 7
    // Update additional buttons
    const buttons2 = data.getUint8(5)
    state.buttons.l1 = !!(buttons2 & 0x01)
    state.buttons.r1 = !!(buttons2 & 0x02)
    state.buttons.l2 = !!(buttons2 & 0x04)
    state.buttons.r2 = !!(buttons2 & 0x08)
    state.buttons.share = !!(buttons2 & 0x10)
    state.buttons.options = !!(buttons2 & 0x20)
    state.buttons.l3 = !!(buttons2 & 0x40)
    state.buttons.r3 = !!(buttons2 & 0x80)
    const buttons3 = data.getUint8(6)
    state.buttons.playStation = !!(buttons3 & 0x01)
    state.buttons.touchPadClick = !!(buttons3 & 0x02)
    // Update Triggers
    state.axes.l2 = normalizeTrigger(data.getUint8(7))
    state.axes.r2 = normalizeTrigger(data.getUint8(8))

    // マッピング結果をコールバック関数に渡す
    if (callback) callback(state, data)
}

// ベンダーコードを調べる、実数で返ってくるので、結果を１６進数に変換した値で照合する。
const checkVenderCode = async () => {
    // ベンダーID検出用の試験コード
    let _devices = await navigator.hid.getDevices()
    _devices.forEach((dev) => {
        console.log(`HID: ${dev.vendorId}`)
    })
}

const normalizeThumbstick = (
    input: number,
    deadZone: number = 0
): number => {
    const rel = (input - 128) / 128
    if (Math.abs(rel) <= deadZone)
        return 0
    return Math.min(1, Math.max(-1, rel))
}

const normalizeTrigger = (
    input: number,
    deadZone: number = 0
): number => {
    return Math.min(1, Math.max(deadZone, input / 255))
}
```
USB接続も本来はサポートしていますが

**線が繋がる ＝ ダサい**

を合言葉に、Bluetoothだけに絞っています。

細かい解説は、大変なので極力端折りますが
* WebHIDAPIを呼び出す
* ベンダーフィルターと一致するデバイスを要求
* デバイスに接続
* 入力イベント待機
* 入力情報から各ボタンのステータスを取得
* コールバック関数に取得したステータスを渡す

以上を満たせればOKです。

<br></br>
## ブラウザに表示

実際に、DualSenseの入力をブラウザに出力するのに
なくても良いですが、１クッションラッパー関数を挟みたいので
下記のようなものを今回作っています。
ファイル名は「ds4.service.ts」で保存しています。

```typescript
'use client'

import { init as DualSense, DualSenseStateType } from './_helper/webhid_ds'

export const ButtonStatus: { [key: string]: HTMLElement | null } = {
    triangle: null,
    circle: null,
    cross: null,
    square: null,
    dPadUp: null,
    dPadDown: null,
    dPadLeft: null,
    dPadRight: null,
    l1: null,
    l2: null,
    l3: null,
    r1: null,
    r2: null,
    r3: null,
    leftStickX: null,
    leftStickY: null,
    rightStickX: null,
    rightStickY: null,
    leftAxes: null,
    rightAxes: null,

    logs: null,
}

export const init = async (
    next: (
        state: DualSenseStateType,
        data: DataView
    ) => void,
) => {
    DualSense(next)
}
```


ラッパー関数からDualSenseへの接続を呼び出し
コントローラーの入力を、DOMへ出力します。
デザイン部分は、下記と一致するDOMがいれば何でも良いので
実際に使っている環境に合わせて、何かしら用意してください。

|ボタン|DOMのID|
|--|--|
|△ボタン|triangle|
|◯ボタン|circle|
|☓ボタン|triangle|
|□ボタン|square|
|上キー|up|
|下キー|down|
|左キー|left|
|右キー|right|
|L1トリガー|leftTriger1|
|L2トリガー|leftTriger2|
|L3トリガー|leftTriger3|
|R1トリガー|rightTrigger1|
|R2トリガー|rightTrigger2|
|R3トリガー|rightTrigger3|
|Lトリガーアナログ|leftAxes|
|Rトリガーアナログ|rightAxes|
|Leftスティック|leftStick|
|Rightスティック|rightStick|


document.getElementByIDで、DOM要素を取得し
コールバック関数で
* デジタル入力のOn/OffのBoolean
* アナログボタンの座標情報

それぞれを、取得したDOM要素に、innerTextで上書きして表示します。 


```typescript
import { init } from "_lib/ds4.service"
import { ButtonStatus } from "_lib/ds4.service"
export const controlDispatch = async (
): Promise<ReturnSuccess<string> | ReturnError> => {

    initDS4()
    init(buttonSend)

    return {
        status: true,
        message: ''
    }
}

const BS = ButtonStatus

export const initDS4 = (): void => {
    BS.triangle = document.getElementById('triangle') as HTMLDivElement
    BS.circle = document.getElementById('circle') as HTMLDivElement
    BS.cross = document.getElementById('cross') as HTMLDivElement
    BS.square = document.getElementById('square') as HTMLDivElement
    BS.dPadUp = document.getElementById('up') as HTMLDivElement
    BS.dPadDown = document.getElementById('down') as HTMLDivElement
    BS.dPadLeft = document.getElementById('left') as HTMLDivElement
    BS.dPadRight = document.getElementById('right') as HTMLDivElement
    BS.l1 = document.getElementById('leftTrigger1') as HTMLDivElement
    BS.l2 = document.getElementById('leftTrigger2') as HTMLDivElement
    BS.l3 = document.getElementById('leftTrigger3') as HTMLDivElement
    BS.r1 = document.getElementById('rightTrigger1') as HTMLDivElement
    BS.r2 = document.getElementById('rightTrigger2') as HTMLDivElement
    BS.r3 = document.getElementById('rightTrigger3') as HTMLDivElement
    BS.leftAxes = document.getElementById('leftAxes') as HTMLDivElement
    BS.rightAxes = document.getElementById('rightAxes') as HTMLDivElement
    BS.leftStick = document.getElementById('leftStick') as HTMLDivElement
    BS.rightStick = document.getElementById('rightStick') as HTMLDivElement
    BS.gyroX = document.getElementById('gyroX') as HTMLDivElement
    BS.gyroY = document.getElementById('gyroY') as HTMLDivElement
    BS.gyroZ = document.getElementById('gyroZ') as HTMLDivElement
    BS.accelX = document.getElementById('accelX') as HTMLDivElement
    BS.accelY = document.getElementById('accelY') as HTMLDivElement
    BS.accelZ = document.getElementById('accelZ') as HTMLDivElement
    BS.touchX = document.getElementById('touchX') as HTMLDivElement
    BS.touchY = document.getElementById('touchY') as HTMLDivElement

    BS.logs = document.getElementById('logs') as HTMLDivElement
}

const exportStatus = (
    state: DualSenseStateType,
    data: DataView,
) => {

    if (state === null) return
    //if (controller === null) return
    
    ButtonStatus.logs!.innerHTML = String(data.getUint8(29));

    ButtonStatus.triangle!.innerText = state.buttons.triangle ? 'Pressed' : 'Not Pressed'
    ButtonStatus.circle!.innerText = state.buttons.circle ? 'Pressed' : 'Not Pressed'
    ButtonStatus.cross!.innerText = state.buttons.cross ? 'Pressed' : 'Not Pressed'
    ButtonStatus.square!.innerText = state.buttons.square ? 'Pressed' : 'Not Pressed'

    ButtonStatus.dPadUp!.innerText = state.buttons.dPadUp ? 'Pressed' : 'Not Pressed'
    ButtonStatus.dPadDown!.innerText = state.buttons.dPadDown ? 'Pressed' : 'Not Pressed'
    ButtonStatus.dPadLeft!.innerText = state.buttons.dPadLeft ? 'Pressed' : 'Not Pressed'
    ButtonStatus.dPadRight!.innerText = state.buttons.dPadRight ? 'Pressed' : 'Not Pressed'

    ButtonStatus.l1!.innerText = state.buttons.l1 ? 'Pressed' : 'Not Pressed'
    ButtonStatus.l2!.innerText = state.buttons.l2 ? 'Pressed' : 'Not Pressed'
    ButtonStatus.l3!.innerText = state.buttons.l3 ? 'Pressed' : 'Not Pressed'
    ButtonStatus.r1!.innerText = state.buttons.r1 ? 'Pressed' : 'Not Pressed'
    ButtonStatus.r2!.innerText = state.buttons.r2 ? 'Pressed' : 'Not Pressed'
    ButtonStatus.r3!.innerText = state.buttons.r3 ? 'Pressed' : 'Not Pressed'

    ButtonStatus.leftAxes!.innerText = `(${state.axes.l2})`
    ButtonStatus.rightAxes!.innerText = `(${state.axes.r2})`

    ButtonStatus.leftStick!.innerText = `(${state.axes.leftStickX}, ${state.axes.leftStickY})`
    ButtonStatus.rightStick!.innerText = `(${state.axes.rightStickX}, ${state.axes.rightStickY})`

}
```


<br></br>
## DualSense入力試験

映像には映っていませんが、デバイスの選択画面が１クッション入ります。
デバイス選択後、入力に対応するDOM要素に「Pressed」の文字が
「Not Pressed」と、入れ替わりで入ります。

同時押しにも対応出来るますので
同時押して複数のDOMが同時に切り替わることも確認してください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/kRXjEs4edQA?si=eeBsaHiRDU4Xz5NE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


<br></br>
<br></br>
# UZAYA
Uzayaでは、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。