【4】DualSenseをブラウザに接続
ラジコン側のモーターの駆動に必要なコンポーネントが
揃ったはずなので、次はコントローラー側を作っていきます。
常識的に考えれば
RapsberryPI積んでるんだから
Bluetoothで直接DualSnseを繋いで
そのまま操作すればいいじゃないかと
そう思われるかもしれませんが
そんな事では、ロマン感が不足する可能性があります。
本体に積んだ映像をブラウザで見ながら操作する。
という条件を付与することで
ブラウザにDualSenseを繋ぐ事の正当性が保たれるわけです。
目次
- 【1】Raspberry pi の、GPIOをTypescriptから操作
- 【2】DCモーターをPWMで速度制御
- 【3】サーボモーターを制御
- 【4】DualSenseをブラウザに接続
- 【5】DualSenseの情報をRaspberryPiに飛ばす
- 【6】DualSenseのチャタリング?問題対応
- 【7-1】RaspberryPiから低遅延で映像を飛ばす
- 【7-2】RaspberryPiから低遅延で映像を飛ばす
- 【8】ThreeJSでVRもどきを作成
- 【9-1】iPhoneの加速度から頭の向きをVRに反映
- 【9-2】スマホVRゴーグル向けデザインに変える
- 【10】ラジコン本体の製作
- 【11】パワーアップとバッテリー問題の解決
コントローラー
DualSenseがあれば問題ありませんが
Amazonで3000円前後で売られている互換品を今回は使用しています。
ライブラリの選定
WebHIDの仕様を見ながら、0から書いてもそんな手間は無いんじゃないのか?
そう思いましたが、何か地雷がある気がしなくもない。
ベンダーコード、各コントローラーボタンとバイナリ値のマッピング
この辺の情報収集だけでも、時間がかかりそう。
コントローラーをWebHIDで繋いだ先人は、いっぱい居るであろう。
という楽観視の元、
- 適当なWebHIDに対応したnpmライブラリを探す。
- この時、DualShockとDualSenseに違いはないだろうと勘違いしていた。
この2点の思い違いで進めて、見つけたライブラリ
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では、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。