0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-03-02

【4】DualSenseをブラウザに接続

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

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

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

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

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


目次


コントローラー

DualSenseがあれば問題ありませんが
Amazonで3000円前後で売られている互換品を今回は使用しています。


ライブラリの選定

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

という楽観視の元、

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

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

WebHID-DS4

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

問題になったのが、このままDualSenseで利用するにあたり

  • 対DualSenseでは使えない
  • 若干修正すると、DualSenseが繋がる代わりに、ブラウザがフリーズする。

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

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

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

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

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
    })
    // 問答無用で0番目に接続
    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)
}

// ベンダーコードを調べる、実数で返ってくるので、結果を16進数に変換した値で照合する。
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です。


ブラウザに表示

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

'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で上書きして表示します。

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})`

}


DualSense入力試験

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

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




UZAYA

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?