feat(input-gamepad): add controller input packages (#2395)
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# `@proj-airi/input-gamepad-vueuse`
|
||||
|
||||
This package provides Vue composables for `@proj-airi/input-gamepad`.
|
||||
It owns the monitor lifecycle and exposes readonly reactive controller state.
|
||||
|
||||
## Use the package
|
||||
|
||||
```ts
|
||||
import { useStandardGamepad } from '@proj-airi/input-gamepad-vueuse'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const gamepad = useStandardGamepad()
|
||||
|
||||
whenever(gamepad.buttons.faceBottom, () => {
|
||||
console.info('The bottom face button is pressed.')
|
||||
})
|
||||
|
||||
whenever(gamepad.pressed('leftShoulder', 'dpadLeft'), () => {
|
||||
console.info('The shortcut is pressed.')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
console.info(gamepad.sticks.left.value, gamepad.values.rightTrigger.value)
|
||||
})
|
||||
```
|
||||
|
||||
The composable starts after its Vue scope mounts. It stops when that scope is disposed.
|
||||
Use `pause()` and `resume()` when a mounted feature must stop input temporarily.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use this package in Vue browser and Electron renderer code.
|
||||
Use `@proj-airi/input-gamepad` in framework-independent code.
|
||||
|
||||
This package exposes standard buttons, sticks, and analog triggers.
|
||||
It does not expose motion sensors, touchpads, lights, or adaptive triggers.
|
||||
Use a device-specific package for these features.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@proj-airi/input-gamepad-vueuse",
|
||||
"type": "module",
|
||||
"version": "0.12.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Vue composables for AIRI gamepad input",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/input-gamepad-vueuse"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"README.md",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@proj-airi/input-gamepad": "workspace:^",
|
||||
"@vueuse/core": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import { useStandardGamepad } from './index'
|
||||
|
||||
const neutralButton: GamepadButton = Object.freeze({
|
||||
pressed: false,
|
||||
touched: false,
|
||||
value: 0,
|
||||
})
|
||||
|
||||
describe('useStandardGamepad', () => {
|
||||
it('exposes reactive buttons, values, sticks, and combinations', () => {
|
||||
let gamepads: readonly (Gamepad | null)[] = [createGamepad({
|
||||
axes: [0.6, 0, 0, 0],
|
||||
buttons: {
|
||||
0: { pressed: true, touched: true, value: 1 },
|
||||
4: { pressed: true, touched: true, value: 1 },
|
||||
6: { pressed: true, touched: true, value: 0.75 },
|
||||
},
|
||||
})]
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
let nextFrameId = 0
|
||||
const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId))
|
||||
const scope = effectScope()
|
||||
const gamepad = scope.run(() => useStandardGamepad({
|
||||
cancelFrame,
|
||||
getGamepads: () => gamepads,
|
||||
requestFrame(callback) {
|
||||
const frameId = ++nextFrameId
|
||||
frames.set(frameId, callback)
|
||||
return frameId
|
||||
},
|
||||
}))
|
||||
|
||||
if (!gamepad)
|
||||
throw new Error('The composable did not start in the effect scope.')
|
||||
|
||||
const shortcut = gamepad.pressed('leftShoulder', 'faceBottom')
|
||||
expect(gamepad.isSupported.value).toBe(true)
|
||||
expect(gamepad.isActive.value).toBe(true)
|
||||
|
||||
runNextFrame(frames)
|
||||
|
||||
expect(gamepad.isConnected.value).toBe(true)
|
||||
expect(gamepad.family.value).toBe('playstation')
|
||||
expect(gamepad.buttons.faceBottom.value).toBe(true)
|
||||
expect(gamepad.values.leftTrigger.value).toBe(0.75)
|
||||
expect(gamepad.sticks.left.value.x).toBeCloseTo(0.545)
|
||||
expect(shortcut.value).toBe(true)
|
||||
|
||||
gamepads = []
|
||||
runNextFrame(frames)
|
||||
|
||||
expect(gamepad.isConnected.value).toBe(false)
|
||||
expect(gamepad.buttons.faceBottom.value).toBe(false)
|
||||
expect(gamepad.values.leftTrigger.value).toBe(0)
|
||||
expect(shortcut.value).toBe(false)
|
||||
|
||||
scope.stop()
|
||||
expect(cancelFrame).toHaveBeenCalledOnce()
|
||||
expect(gamepad.isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('pauses and resumes the polling loop', () => {
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
let nextFrameId = 0
|
||||
const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId))
|
||||
const scope = effectScope()
|
||||
const gamepad = scope.run(() => useStandardGamepad({
|
||||
cancelFrame,
|
||||
getGamepads: () => [],
|
||||
requestFrame(callback) {
|
||||
const frameId = ++nextFrameId
|
||||
frames.set(frameId, callback)
|
||||
return frameId
|
||||
},
|
||||
}))
|
||||
|
||||
if (!gamepad)
|
||||
throw new Error('The composable did not start in the effect scope.')
|
||||
|
||||
gamepad.pause()
|
||||
expect(gamepad.isActive.value).toBe(false)
|
||||
expect(cancelFrame).toHaveBeenCalledOnce()
|
||||
|
||||
gamepad.resume()
|
||||
expect(gamepad.isActive.value).toBe(true)
|
||||
expect(frames.size).toBe(1)
|
||||
|
||||
scope.stop()
|
||||
expect(cancelFrame).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
function createGamepad(options: {
|
||||
axes?: readonly number[]
|
||||
buttons?: Readonly<Partial<Record<number, GamepadButton>>>
|
||||
} = {}): Gamepad {
|
||||
const buttons = Array.from({ length: 17 }, (_, index): GamepadButton => options.buttons?.[index] ?? neutralButton)
|
||||
|
||||
return {
|
||||
axes: options.axes ?? [0, 0, 0, 0],
|
||||
buttons,
|
||||
connected: true,
|
||||
id: 'DualSense Wireless Controller',
|
||||
index: 0,
|
||||
mapping: 'standard',
|
||||
timestamp: 1,
|
||||
vibrationActuator: {
|
||||
playEffect: async () => 'complete',
|
||||
reset: async () => 'complete',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runNextFrame(frames: Map<number, FrameRequestCallback>): void {
|
||||
const next = frames.entries().next().value
|
||||
if (!next)
|
||||
throw new Error('The monitor did not schedule a frame.')
|
||||
|
||||
const [frameId, callback] = next
|
||||
frames.delete(frameId)
|
||||
callback(performance.now())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useStandardGamepad } from './use-standard-gamepad'
|
||||
export type {
|
||||
StandardGamepadButtonRefs,
|
||||
StandardGamepadValueRefs,
|
||||
UseStandardGamepadReturn,
|
||||
} from './use-standard-gamepad'
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
GamepadFamily,
|
||||
StandardGamepadButtonName,
|
||||
StandardGamepadMonitorOptions,
|
||||
StandardGamepadSnapshot,
|
||||
StandardGamepadStickState,
|
||||
} from '@proj-airi/input-gamepad'
|
||||
import type { ComputedRef, DeepReadonly, ShallowRef } from 'vue'
|
||||
|
||||
import {
|
||||
isGamepadApiSupported,
|
||||
standardGamepadButtonNames,
|
||||
StandardGamepadMonitor,
|
||||
} from '@proj-airi/input-gamepad'
|
||||
import { tryOnMounted, tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, readonly, shallowRef } from 'vue'
|
||||
|
||||
const neutralStick: StandardGamepadStickState = Object.freeze({ x: 0, y: 0 })
|
||||
|
||||
/** Reactive pressed states for all standard gamepad buttons. */
|
||||
export type StandardGamepadButtonRefs = Readonly<Record<StandardGamepadButtonName, ComputedRef<boolean>>>
|
||||
|
||||
/** Reactive analog values for all standard gamepad buttons. */
|
||||
export type StandardGamepadValueRefs = Readonly<Record<StandardGamepadButtonName, ComputedRef<number>>>
|
||||
|
||||
/** Reactive state and lifecycle controls for one selected standard gamepad. */
|
||||
export interface UseStandardGamepadReturn {
|
||||
/** True when the selected gamepad is connected. */
|
||||
readonly isConnected: ComputedRef<boolean>
|
||||
/** True while the composable owns a polling loop. */
|
||||
readonly isActive: DeepReadonly<ShallowRef<boolean>>
|
||||
/** True when the Gamepad API or an injected reader is available. */
|
||||
readonly isSupported: ComputedRef<boolean>
|
||||
/** The controller family used for printed button labels. */
|
||||
readonly family: ComputedRef<GamepadFamily>
|
||||
/** The latest normalized state. */
|
||||
readonly snapshot: DeepReadonly<ShallowRef<StandardGamepadSnapshot | undefined>>
|
||||
/** Digital pressed states by physical button position. */
|
||||
readonly buttons: StandardGamepadButtonRefs
|
||||
/** Analog values by physical button position. */
|
||||
readonly values: StandardGamepadValueRefs
|
||||
/** Normalized thumbstick positions. */
|
||||
readonly sticks: Readonly<{
|
||||
left: ComputedRef<StandardGamepadStickState>
|
||||
right: ComputedRef<StandardGamepadStickState>
|
||||
}>
|
||||
/** Pauses the polling loop. The latest snapshot remains available. */
|
||||
readonly pause: () => void
|
||||
/** Returns a reactive state that is true while all specified buttons are pressed. */
|
||||
readonly pressed: (...buttons: StandardGamepadButtonName[]) => ComputedRef<boolean>
|
||||
/** Starts the polling loop when the Gamepad API is available. */
|
||||
readonly resume: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides reactive state for one browser gamepad with the W3C standard mapping.
|
||||
*
|
||||
* The composable owns its monitor until the current Vue scope is disposed.
|
||||
* Use the dependency options only for an alternate browser runtime or a test adapter.
|
||||
*/
|
||||
export function useStandardGamepad(options?: StandardGamepadMonitorOptions): UseStandardGamepadReturn {
|
||||
const snapshot = shallowRef<StandardGamepadSnapshot>()
|
||||
const isActive = shallowRef(false)
|
||||
const monitor = new StandardGamepadMonitor(options)
|
||||
const stopListening = monitor.onSnapshot((nextSnapshot) => {
|
||||
snapshot.value = nextSnapshot
|
||||
})
|
||||
|
||||
const isSupported = computed(() => options?.getGamepads !== undefined || isGamepadApiSupported())
|
||||
const isConnected = computed(() => snapshot.value !== undefined)
|
||||
const family = computed(() => snapshot.value?.family ?? 'unknown')
|
||||
const buttons = createButtonRefs(snapshot)
|
||||
const values = createValueRefs(snapshot)
|
||||
const sticks = Object.freeze({
|
||||
left: computed(() => snapshot.value?.leftStick ?? neutralStick),
|
||||
right: computed(() => snapshot.value?.rightStick ?? neutralStick),
|
||||
})
|
||||
|
||||
function pause(): void {
|
||||
if (!isActive.value)
|
||||
return
|
||||
|
||||
monitor.stop()
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
function resume(): void {
|
||||
if (!isSupported.value || isActive.value)
|
||||
return
|
||||
|
||||
monitor.start()
|
||||
isActive.value = true
|
||||
}
|
||||
|
||||
function pressed(...buttonNames: StandardGamepadButtonName[]): ComputedRef<boolean> {
|
||||
return computed(() => buttonNames.length > 0 && buttonNames.every(name => buttons[name].value))
|
||||
}
|
||||
|
||||
tryOnMounted(resume)
|
||||
tryOnScopeDispose(() => {
|
||||
stopListening()
|
||||
pause()
|
||||
})
|
||||
|
||||
return {
|
||||
buttons,
|
||||
family,
|
||||
isActive: readonly(isActive),
|
||||
isConnected,
|
||||
isSupported,
|
||||
pause,
|
||||
pressed,
|
||||
resume,
|
||||
snapshot: readonly(snapshot),
|
||||
sticks,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
function createButtonRefs(
|
||||
snapshot: Readonly<ShallowRef<StandardGamepadSnapshot | undefined>>,
|
||||
): StandardGamepadButtonRefs {
|
||||
return Object.freeze(Object.fromEntries(
|
||||
standardGamepadButtonNames.map(name => [name, computed(() => snapshot.value?.buttons[name].pressed ?? false)]),
|
||||
)) as StandardGamepadButtonRefs
|
||||
}
|
||||
|
||||
function createValueRefs(
|
||||
snapshot: Readonly<ShallowRef<StandardGamepadSnapshot | undefined>>,
|
||||
): StandardGamepadValueRefs {
|
||||
return Object.freeze(Object.fromEntries(
|
||||
standardGamepadButtonNames.map(name => [name, computed(() => snapshot.value?.buttons[name].value ?? 0)]),
|
||||
)) as StandardGamepadValueRefs
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"tsdown.config.ts",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
platform: 'browser',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
# `@proj-airi/input-gamepad`
|
||||
|
||||
This package reads controllers through the browser Gamepad API. It accepts controllers that expose the W3C `standard` mapping.
|
||||
|
||||
The package converts browser button indices to physical names. For example, button `0` becomes `faceBottom`. The printed label can be `×`, `A`, or `B`.
|
||||
|
||||
## Use the package
|
||||
|
||||
```ts
|
||||
import { StandardGamepadMonitor } from '@proj-airi/input-gamepad'
|
||||
|
||||
const monitor = new StandardGamepadMonitor({ deadzone: 0.12 })
|
||||
const stopListening = monitor.onSnapshot((snapshot) => {
|
||||
if (!snapshot)
|
||||
return
|
||||
|
||||
console.info(snapshot.leftStick, snapshot.buttons.faceBottom)
|
||||
})
|
||||
|
||||
monitor.start()
|
||||
|
||||
// Run these operations when the feature closes.
|
||||
stopListening()
|
||||
monitor.stop()
|
||||
```
|
||||
|
||||
## When to use it
|
||||
|
||||
Use this package directly in framework-independent browser code. It supports buttons, sticks, and analog triggers.
|
||||
|
||||
In Vue code, use `@proj-airi/input-gamepad-vueuse`. It owns the polling lifecycle and exposes readonly reactive state.
|
||||
|
||||
Do not use this package for motion sensors, touchpads, lights, or adaptive triggers. Use a device-specific adapter for these features.
|
||||
|
||||
The package ignores controllers without the `standard` mapping. This rule prevents device-specific button indices from entering application code.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@proj-airi/input-gamepad",
|
||||
"type": "module",
|
||||
"version": "0.12.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Standard Gamepad API input for AIRI",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/input-gamepad"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"README.md",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
createStandardGamepadSnapshot,
|
||||
detectGamepadFamily,
|
||||
getGamepadButtonLabel,
|
||||
isGamepadApiSupported,
|
||||
StandardGamepadMonitor,
|
||||
} from './standard-gamepad'
|
||||
export type {
|
||||
GamepadFamily,
|
||||
StandardGamepadButtonName,
|
||||
StandardGamepadButtonState,
|
||||
StandardGamepadMonitorOptions,
|
||||
StandardGamepadSnapshot,
|
||||
StandardGamepadSnapshotListener,
|
||||
StandardGamepadSnapshotOptions,
|
||||
StandardGamepadStickState,
|
||||
} from './types'
|
||||
export { standardGamepadButtonNames } from './types'
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createStandardGamepadSnapshot,
|
||||
detectGamepadFamily,
|
||||
getGamepadButtonLabel,
|
||||
StandardGamepadMonitor,
|
||||
} from './index'
|
||||
|
||||
const neutralButton: GamepadButton = Object.freeze({
|
||||
pressed: false,
|
||||
touched: false,
|
||||
value: 0,
|
||||
})
|
||||
|
||||
describe('createStandardGamepadSnapshot', () => {
|
||||
it('normalizes the standard axes and button positions', () => {
|
||||
const gamepad = createGamepad({
|
||||
axes: [0.6, 0, -1, 1],
|
||||
buttons: {
|
||||
0: { pressed: true, touched: true, value: 1 },
|
||||
6: { pressed: true, touched: true, value: 0.75 },
|
||||
14: { pressed: true, touched: true, value: 1 },
|
||||
},
|
||||
id: 'DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
|
||||
})
|
||||
|
||||
const snapshot = createStandardGamepadSnapshot(gamepad, { deadzone: 0.2 })
|
||||
|
||||
expect(snapshot.family).toBe('playstation')
|
||||
expect(snapshot.leftStick.x).toBeCloseTo(0.5)
|
||||
expect(snapshot.leftStick.y).toBe(0)
|
||||
expect(snapshot.rightStick.x).toBeCloseTo(-Math.SQRT1_2)
|
||||
expect(snapshot.rightStick.y).toBeCloseTo(Math.SQRT1_2)
|
||||
expect(snapshot.buttons.faceBottom.pressed).toBe(true)
|
||||
expect(snapshot.buttons.leftTrigger.value).toBe(0.75)
|
||||
expect(snapshot.buttons.dpadLeft.pressed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects devices without the standard mapping', () => {
|
||||
expect(() => createStandardGamepadSnapshot(createGamepad({ mapping: '' }))).toThrowError(
|
||||
'The gamepad does not use the standard mapping.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gamepad labels', () => {
|
||||
it('detects common controller families', () => {
|
||||
expect(detectGamepadFamily('Xbox Wireless Controller')).toBe('xbox')
|
||||
expect(detectGamepadFamily('Nintendo Switch Joy-Con (L/R)')).toBe('nintendo')
|
||||
expect(detectGamepadFamily('054c DualShock 4')).toBe('playstation')
|
||||
expect(detectGamepadFamily('Generic USB Gamepad')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('uses printed face labels without changing physical positions', () => {
|
||||
expect(getGamepadButtonLabel('playstation', 'faceBottom')).toBe('×')
|
||||
expect(getGamepadButtonLabel('xbox', 'faceBottom')).toBe('A')
|
||||
expect(getGamepadButtonLabel('nintendo', 'faceBottom')).toBe('B')
|
||||
expect(getGamepadButtonLabel('playstation', 'faceRight')).toBe('○')
|
||||
})
|
||||
})
|
||||
|
||||
describe('standardGamepadMonitor', () => {
|
||||
it('keeps one selected standard gamepad until it disconnects', () => {
|
||||
let gamepads: readonly (Gamepad | null)[] = [
|
||||
createGamepad({ connected: true, index: 0, mapping: '' }),
|
||||
createGamepad({ connected: true, index: 1, id: 'Xbox Wireless Controller' }),
|
||||
]
|
||||
let nextFrameId = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const cancelFrame = vi.fn((frameId: number) => frames.delete(frameId))
|
||||
const monitor = new StandardGamepadMonitor({
|
||||
cancelFrame,
|
||||
getGamepads: () => gamepads,
|
||||
requestFrame(callback) {
|
||||
const frameId = ++nextFrameId
|
||||
frames.set(frameId, callback)
|
||||
return frameId
|
||||
},
|
||||
})
|
||||
const listener = vi.fn()
|
||||
monitor.onSnapshot(listener)
|
||||
|
||||
monitor.start()
|
||||
runNextFrame(frames)
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1, family: 'xbox' }))
|
||||
|
||||
gamepads = [
|
||||
createGamepad({ connected: true, index: 0, id: 'Nintendo Switch Pro Controller' }),
|
||||
createGamepad({ connected: true, index: 1, id: 'Xbox Wireless Controller' }),
|
||||
]
|
||||
runNextFrame(frames)
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 1, family: 'xbox' }))
|
||||
|
||||
gamepads = [createGamepad({ connected: true, index: 0, id: 'Nintendo Switch Pro Controller' })]
|
||||
runNextFrame(frames)
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ index: 0, family: 'nintendo' }))
|
||||
|
||||
gamepads = []
|
||||
runNextFrame(frames)
|
||||
expect(listener).toHaveBeenLastCalledWith(undefined)
|
||||
|
||||
monitor.stop()
|
||||
expect(cancelFrame).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
function createGamepad(options: {
|
||||
axes?: readonly number[]
|
||||
buttons?: Readonly<Record<number, GamepadButton>>
|
||||
connected?: boolean
|
||||
id?: string
|
||||
index?: number
|
||||
mapping?: GamepadMappingType
|
||||
} = {}): Gamepad {
|
||||
const buttons = Array.from({ length: 17 }, (_, index): GamepadButton => options.buttons?.[index] ?? neutralButton)
|
||||
|
||||
return {
|
||||
axes: options.axes ?? [0, 0, 0, 0],
|
||||
buttons,
|
||||
connected: options.connected ?? true,
|
||||
id: options.id ?? 'Standard Gamepad',
|
||||
index: options.index ?? 0,
|
||||
mapping: options.mapping ?? 'standard',
|
||||
timestamp: 1,
|
||||
vibrationActuator: {
|
||||
playEffect: async () => 'complete',
|
||||
reset: async () => 'complete',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runNextFrame(frames: Map<number, FrameRequestCallback>): void {
|
||||
const next = frames.entries().next().value
|
||||
if (!next)
|
||||
throw new Error('The monitor did not schedule a frame.')
|
||||
|
||||
const [frameId, callback] = next
|
||||
frames.delete(frameId)
|
||||
callback(performance.now())
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
GamepadFamily,
|
||||
StandardGamepadButtonName,
|
||||
StandardGamepadButtonState,
|
||||
StandardGamepadMonitorOptions,
|
||||
StandardGamepadSnapshot,
|
||||
StandardGamepadSnapshotListener,
|
||||
StandardGamepadSnapshotOptions,
|
||||
StandardGamepadStickState,
|
||||
} from './types'
|
||||
|
||||
const standardButtonIndices = Object.freeze({
|
||||
dpadDown: 13,
|
||||
dpadLeft: 14,
|
||||
dpadRight: 15,
|
||||
dpadUp: 12,
|
||||
faceBottom: 0,
|
||||
faceLeft: 2,
|
||||
faceRight: 1,
|
||||
faceTop: 3,
|
||||
leftShoulder: 4,
|
||||
leftStick: 10,
|
||||
leftTrigger: 6,
|
||||
rightShoulder: 5,
|
||||
rightStick: 11,
|
||||
rightTrigger: 7,
|
||||
select: 8,
|
||||
start: 9,
|
||||
}) satisfies Readonly<Record<StandardGamepadButtonName, number>>
|
||||
|
||||
const neutralButton: StandardGamepadButtonState = Object.freeze({
|
||||
pressed: false,
|
||||
touched: false,
|
||||
value: 0,
|
||||
})
|
||||
|
||||
const familyButtonLabels: Readonly<Record<GamepadFamily, Readonly<Record<StandardGamepadButtonName, string>>>> = {
|
||||
nintendo: createButtonLabels({
|
||||
faceBottom: 'B',
|
||||
faceLeft: 'Y',
|
||||
faceRight: 'A',
|
||||
faceTop: 'X',
|
||||
leftShoulder: 'L',
|
||||
leftTrigger: 'ZL',
|
||||
rightShoulder: 'R',
|
||||
rightTrigger: 'ZR',
|
||||
}),
|
||||
playstation: createButtonLabels({
|
||||
faceBottom: '×',
|
||||
faceLeft: '□',
|
||||
faceRight: '○',
|
||||
faceTop: '△',
|
||||
leftShoulder: 'L1',
|
||||
leftTrigger: 'L2',
|
||||
rightShoulder: 'R1',
|
||||
rightTrigger: 'R2',
|
||||
}),
|
||||
unknown: createButtonLabels({}),
|
||||
xbox: createButtonLabels({
|
||||
faceBottom: 'A',
|
||||
faceLeft: 'X',
|
||||
faceRight: 'B',
|
||||
faceTop: 'Y',
|
||||
leftShoulder: 'LB',
|
||||
leftTrigger: 'LT',
|
||||
rightShoulder: 'RB',
|
||||
rightTrigger: 'RT',
|
||||
}),
|
||||
}
|
||||
|
||||
/** Returns true when the current browser exposes the Gamepad API. */
|
||||
export function isGamepadApiSupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers the printed controller family from a browser gamepad identifier.
|
||||
* The result changes labels only. It does not change standard button positions.
|
||||
*
|
||||
* @example
|
||||
* detectGamepadFamily('Xbox Wireless Controller')
|
||||
* // => 'xbox'
|
||||
*/
|
||||
export function detectGamepadFamily(id: string): GamepadFamily {
|
||||
const normalizedId = id.toLowerCase()
|
||||
if (normalizedId.includes('xbox') || normalizedId.includes('xinput') || normalizedId.includes('045e'))
|
||||
return 'xbox'
|
||||
if (normalizedId.includes('nintendo') || normalizedId.includes('joy-con') || normalizedId.includes('057e'))
|
||||
return 'nintendo'
|
||||
if (
|
||||
normalizedId.includes('dualsense')
|
||||
|| normalizedId.includes('dualshock')
|
||||
|| normalizedId.includes('playstation')
|
||||
|| normalizedId.includes('wireless controller')
|
||||
|| normalizedId.includes('054c')
|
||||
) {
|
||||
return 'playstation'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** Returns the printed label for one physical standard-gamepad button position. */
|
||||
export function getGamepadButtonLabel(family: GamepadFamily, button: StandardGamepadButtonName): string {
|
||||
return familyButtonLabels[family][button]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a browser gamepad that uses the W3C standard mapping.
|
||||
*
|
||||
* @example
|
||||
* createStandardGamepadSnapshot(gamepad, { deadzone: 0.12 })
|
||||
* // => { leftStick: { x: 0, y: 0 }, buttons: { ... } }
|
||||
*/
|
||||
export function createStandardGamepadSnapshot(
|
||||
gamepad: Gamepad,
|
||||
options?: StandardGamepadSnapshotOptions,
|
||||
): StandardGamepadSnapshot {
|
||||
if (gamepad.mapping !== 'standard')
|
||||
throw new Error('The gamepad does not use the standard mapping.')
|
||||
|
||||
const deadzone = options?.deadzone ?? 0.12
|
||||
if (!Number.isFinite(deadzone) || deadzone < 0 || deadzone >= 1)
|
||||
throw new Error('The gamepad deadzone must be from 0 to less than 1.')
|
||||
|
||||
return {
|
||||
buttons: createButtonStates(gamepad.buttons),
|
||||
family: detectGamepadFamily(gamepad.id),
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
leftStick: applyRadialDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0, deadzone),
|
||||
rightStick: applyRadialDeadzone(gamepad.axes[2] ?? 0, gamepad.axes[3] ?? 0, deadzone),
|
||||
timestamp: gamepad.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the browser Gamepad API and keeps one standard controller selected.
|
||||
* The monitor keeps the selected index until that controller disconnects.
|
||||
*/
|
||||
export class StandardGamepadMonitor {
|
||||
readonly #cancelFrame: (handle: number) => void
|
||||
readonly #deadzone: number | undefined
|
||||
readonly #getGamepads: () => readonly (Gamepad | null)[]
|
||||
readonly #listeners = new Set<StandardGamepadSnapshotListener>()
|
||||
readonly #requestFrame: (callback: FrameRequestCallback) => number
|
||||
#frameHandle: number | undefined
|
||||
#latestSnapshot: StandardGamepadSnapshot | undefined
|
||||
#selectedIndex: number | undefined
|
||||
|
||||
constructor(options?: StandardGamepadMonitorOptions) {
|
||||
this.#cancelFrame = options?.cancelFrame ?? (handle => cancelAnimationFrame(handle))
|
||||
this.#deadzone = options?.deadzone
|
||||
this.#getGamepads = options?.getGamepads ?? (() => {
|
||||
if (!isGamepadApiSupported())
|
||||
throw new Error('The Gamepad API is not available in this browser.')
|
||||
return navigator.getGamepads()
|
||||
})
|
||||
this.#requestFrame = options?.requestFrame ?? (callback => requestAnimationFrame(callback))
|
||||
}
|
||||
|
||||
/** The latest connected snapshot. This value remains available after `stop()`. */
|
||||
get latestSnapshot(): StandardGamepadSnapshot | undefined {
|
||||
return this.#latestSnapshot
|
||||
}
|
||||
|
||||
/** Returns true while the monitor owns a scheduled animation frame. */
|
||||
get running(): boolean {
|
||||
return this.#frameHandle !== undefined
|
||||
}
|
||||
|
||||
/** Adds a snapshot listener and returns its cleanup function. */
|
||||
onSnapshot(listener: StandardGamepadSnapshotListener): () => void {
|
||||
this.#listeners.add(listener)
|
||||
return () => this.#listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Starts polling. Repeated calls do not create duplicate loops. */
|
||||
start(): void {
|
||||
if (this.#frameHandle !== undefined)
|
||||
return
|
||||
this.#frameHandle = this.#requestFrame(this.#poll)
|
||||
}
|
||||
|
||||
/** Stops polling. The latest snapshot remains available for diagnostics. */
|
||||
stop(): void {
|
||||
if (this.#frameHandle === undefined)
|
||||
return
|
||||
this.#cancelFrame(this.#frameHandle)
|
||||
this.#frameHandle = undefined
|
||||
this.#selectedIndex = undefined
|
||||
}
|
||||
|
||||
readonly #poll = (): void => {
|
||||
this.#frameHandle = this.#requestFrame(this.#poll)
|
||||
const gamepads = this.#getGamepads()
|
||||
const selected = this.#findSelectedGamepad(gamepads)
|
||||
if (!selected) {
|
||||
if (this.#latestSnapshot) {
|
||||
this.#latestSnapshot = undefined
|
||||
this.#emit(undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = createStandardGamepadSnapshot(selected, { deadzone: this.#deadzone })
|
||||
this.#latestSnapshot = snapshot
|
||||
this.#emit(snapshot)
|
||||
}
|
||||
|
||||
#emit(snapshot: StandardGamepadSnapshot | undefined): void {
|
||||
for (const listener of this.#listeners)
|
||||
listener(snapshot)
|
||||
}
|
||||
|
||||
#findSelectedGamepad(gamepads: readonly (Gamepad | null)[]): Gamepad | undefined {
|
||||
if (this.#selectedIndex !== undefined) {
|
||||
const selected = gamepads.find(gamepad => gamepad?.index === this.#selectedIndex)
|
||||
if (selected?.connected && selected.mapping === 'standard')
|
||||
return selected
|
||||
}
|
||||
|
||||
const next = gamepads.find(gamepad => gamepad?.connected && gamepad.mapping === 'standard') ?? undefined
|
||||
this.#selectedIndex = next?.index
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
function applyRadialDeadzone(x: number, y: number, deadzone: number): StandardGamepadStickState {
|
||||
const clampedX = clampAxis(x)
|
||||
const clampedY = clampAxis(y)
|
||||
const inputMagnitude = Math.hypot(clampedX, clampedY)
|
||||
if (inputMagnitude <= deadzone)
|
||||
return { x: 0, y: 0 }
|
||||
|
||||
const magnitude = Math.min(1, inputMagnitude)
|
||||
const outputMagnitude = (magnitude - deadzone) / (1 - deadzone)
|
||||
const scale = outputMagnitude / inputMagnitude
|
||||
return {
|
||||
x: clampedX * scale,
|
||||
y: clampedY * scale,
|
||||
}
|
||||
}
|
||||
|
||||
function clampAxis(value: number): number {
|
||||
if (!Number.isFinite(value))
|
||||
return 0
|
||||
return Math.min(1, Math.max(-1, value))
|
||||
}
|
||||
|
||||
function createButtonStates(buttons: ReadonlyArray<GamepadButton>): Readonly<Record<StandardGamepadButtonName, StandardGamepadButtonState>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(standardButtonIndices).map(([name, index]) => [name, readButton(buttons[index])]),
|
||||
) as Record<StandardGamepadButtonName, StandardGamepadButtonState>
|
||||
}
|
||||
|
||||
function readButton(button: GamepadButton | undefined): StandardGamepadButtonState {
|
||||
if (!button)
|
||||
return neutralButton
|
||||
return {
|
||||
pressed: button.pressed,
|
||||
touched: button.touched,
|
||||
value: Math.min(1, Math.max(0, button.value)),
|
||||
}
|
||||
}
|
||||
|
||||
function createButtonLabels(
|
||||
overrides: Partial<Record<StandardGamepadButtonName, string>>,
|
||||
): Readonly<Record<StandardGamepadButtonName, string>> {
|
||||
return {
|
||||
dpadDown: 'D-pad ↓',
|
||||
dpadLeft: 'D-pad ←',
|
||||
dpadRight: 'D-pad →',
|
||||
dpadUp: 'D-pad ↑',
|
||||
faceBottom: 'Bottom',
|
||||
faceLeft: 'Left',
|
||||
faceRight: 'Right',
|
||||
faceTop: 'Top',
|
||||
leftShoulder: 'LB',
|
||||
leftStick: 'LS',
|
||||
leftTrigger: 'LT',
|
||||
rightShoulder: 'RB',
|
||||
rightStick: 'RS',
|
||||
rightTrigger: 'RT',
|
||||
select: 'Select',
|
||||
start: 'Start',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** A controller family inferred from the browser gamepad identifier. */
|
||||
export type GamepadFamily = 'nintendo' | 'playstation' | 'unknown' | 'xbox'
|
||||
|
||||
/** Physical button positions in the W3C standard gamepad layout. */
|
||||
export const standardGamepadButtonNames = [
|
||||
'dpadDown',
|
||||
'dpadLeft',
|
||||
'dpadRight',
|
||||
'dpadUp',
|
||||
'faceBottom',
|
||||
'faceLeft',
|
||||
'faceRight',
|
||||
'faceTop',
|
||||
'leftShoulder',
|
||||
'leftStick',
|
||||
'leftTrigger',
|
||||
'rightShoulder',
|
||||
'rightStick',
|
||||
'rightTrigger',
|
||||
'select',
|
||||
'start',
|
||||
] as const
|
||||
|
||||
/** A physical button position in the W3C standard gamepad layout. */
|
||||
export type StandardGamepadButtonName = typeof standardGamepadButtonNames[number]
|
||||
|
||||
/** One browser button value in a standard gamepad snapshot. */
|
||||
export interface StandardGamepadButtonState {
|
||||
readonly pressed: boolean
|
||||
readonly touched: boolean
|
||||
/** The analog button value in the range from 0 to 1. */
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
/** One normalized thumbstick. Both axes use the range from -1 to 1. */
|
||||
export interface StandardGamepadStickState {
|
||||
readonly x: number
|
||||
/** Positive values point down, as defined by the Gamepad API. */
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** A normalized snapshot from a browser gamepad with the `standard` mapping. */
|
||||
export interface StandardGamepadSnapshot {
|
||||
readonly buttons: Readonly<Record<StandardGamepadButtonName, StandardGamepadButtonState>>
|
||||
readonly family: GamepadFamily
|
||||
readonly id: string
|
||||
readonly index: number
|
||||
readonly leftStick: StandardGamepadStickState
|
||||
readonly rightStick: StandardGamepadStickState
|
||||
/** The browser timestamp for the source gamepad state. */
|
||||
readonly timestamp: number
|
||||
}
|
||||
|
||||
/** Options for standard gamepad normalization. */
|
||||
export interface StandardGamepadSnapshotOptions {
|
||||
/** Radial thumbstick deadzone in the range from 0 to less than 1. @default 0.12 */
|
||||
readonly deadzone?: number
|
||||
}
|
||||
|
||||
/** Runtime dependencies and normalization settings for {@link StandardGamepadMonitor}. */
|
||||
export interface StandardGamepadMonitorOptions extends StandardGamepadSnapshotOptions {
|
||||
/** Cancels one scheduled frame. @default cancelAnimationFrame */
|
||||
readonly cancelFrame?: (handle: number) => void
|
||||
/** Returns the current browser gamepad slots. @default navigator.getGamepads */
|
||||
readonly getGamepads?: () => readonly (Gamepad | null)[]
|
||||
/** Schedules the next input sample. @default requestAnimationFrame */
|
||||
readonly requestFrame?: (callback: FrameRequestCallback) => number
|
||||
}
|
||||
|
||||
/** Receives the selected controller state, or `undefined` after it disconnects. */
|
||||
export type StandardGamepadSnapshotListener = (snapshot: StandardGamepadSnapshot | undefined) => void
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"tsdown.config.ts",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
platform: 'browser',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
# `@proj-airi/input-playstation-dualsense-5`
|
||||
|
||||
This package reads and writes Sony PlayStation 5 DualSense reports through WebHID. It has no UI code.
|
||||
|
||||
The package supports the standard DualSense controller with product ID `0x0ce6`. It supports USB and Bluetooth reports.
|
||||
|
||||
## Use the package
|
||||
|
||||
Call `requestDualSenseDevice()` from a user action. WebHID requires a user action before it shows the device chooser.
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDefaultDualSenseOutputState,
|
||||
DualSenseController,
|
||||
requestDualSenseDevice,
|
||||
} from '@proj-airi/input-playstation-dualsense-5'
|
||||
|
||||
const device = await requestDualSenseDevice()
|
||||
if (!device)
|
||||
throw new Error('No DualSense device was selected.')
|
||||
|
||||
const controller = new DualSenseController(device)
|
||||
const stopInput = controller.onInputReport((report) => {
|
||||
console.info(report.state.sticks.left)
|
||||
})
|
||||
|
||||
await controller.open()
|
||||
|
||||
const output = createDefaultDualSenseOutputState()
|
||||
await controller.sendOutput({
|
||||
...output,
|
||||
lightbar: { red: 124, green: 178, blue: 232 },
|
||||
})
|
||||
|
||||
stopInput()
|
||||
await controller.close()
|
||||
```
|
||||
|
||||
Use `getGrantedDualSenseDevices()` to find devices that already have permission. Use `onDualSenseConnectionChange()` to observe connect and disconnect events.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use this package when a Chromium renderer needs raw DualSense input, motion sensors, touch points, LEDs, rumble, or adaptive triggers.
|
||||
|
||||
Do not use this package for a generic gamepad. Use the Gamepad API when standard buttons and axes are sufficient.
|
||||
|
||||
WebHID requires a secure context. The host browser must support WebHID.
|
||||
|
||||
## Reference
|
||||
|
||||
- https://github.com/nondebug/dualsense
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@proj-airi/input-playstation-dualsense-5",
|
||||
"type": "module",
|
||||
"version": "0.12.0-beta.1",
|
||||
"private": true,
|
||||
"description": "WebHID input and output support for the PlayStation 5 DualSense controller",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/input-playstation-dualsense-5"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"README.md",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/w3c-web-hid": "catalog:"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
DualSenseConnectionType,
|
||||
DualSenseControllerLifecycle,
|
||||
DualSenseInputReport,
|
||||
DualSenseInputReportListener,
|
||||
DualSenseOutputState,
|
||||
} from './types'
|
||||
|
||||
import { parseDualSenseInputReport } from './input-report'
|
||||
import { buildDualSenseOutputReport } from './output-report'
|
||||
import {
|
||||
detectDualSenseConnectionType,
|
||||
isDualSenseDevice,
|
||||
} from './web-hid'
|
||||
|
||||
/**
|
||||
* Owns one DualSense WebHID device and converts its raw reports to typed state.
|
||||
* The controller does not schedule output reports. The caller controls the send rate.
|
||||
*/
|
||||
export class DualSenseController {
|
||||
readonly #connectionType: DualSenseConnectionType
|
||||
readonly #device: HIDDevice
|
||||
readonly #inputReportListeners = new Set<DualSenseInputReportListener>()
|
||||
#latestInputReport: DualSenseInputReport | undefined
|
||||
#lifecycle: DualSenseControllerLifecycle = 'closed'
|
||||
#outputSequenceNumber = 1
|
||||
|
||||
constructor(device: HIDDevice) {
|
||||
if (!isDualSenseDevice(device))
|
||||
throw new Error('The HID device is not a standard DualSense controller.')
|
||||
|
||||
this.#device = device
|
||||
this.#connectionType = detectDualSenseConnectionType(device)
|
||||
}
|
||||
|
||||
/** The transport detected from the HID report descriptor. */
|
||||
get connectionType(): DualSenseConnectionType {
|
||||
return this.#connectionType
|
||||
}
|
||||
|
||||
/** The WebHID device owned by this controller. */
|
||||
get device(): HIDDevice {
|
||||
return this.#device
|
||||
}
|
||||
|
||||
/** The most recent parsed report. This value remains available after `close()`. */
|
||||
get latestInputReport(): DualSenseInputReport | undefined {
|
||||
return this.#latestInputReport
|
||||
}
|
||||
|
||||
/** The current controller lifecycle state. */
|
||||
get lifecycle(): DualSenseControllerLifecycle {
|
||||
return this.#lifecycle
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the HID device and starts input report handling.
|
||||
* Bluetooth controllers also receive feature report `0x05` to enable extended reports.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
if (this.#lifecycle === 'open')
|
||||
return
|
||||
if (this.#lifecycle !== 'closed')
|
||||
throw new Error(`Cannot open a DualSense controller while it is ${this.#lifecycle}.`)
|
||||
|
||||
this.#lifecycle = 'opening'
|
||||
try {
|
||||
if (!this.#device.opened)
|
||||
await this.#device.open()
|
||||
if (this.#connectionType === 'bluetooth')
|
||||
await this.#device.receiveFeatureReport(0x05)
|
||||
this.#device.addEventListener('inputreport', this.#handleInputReport)
|
||||
this.#lifecycle = 'open'
|
||||
}
|
||||
catch (openError) {
|
||||
this.#device.removeEventListener('inputreport', this.#handleInputReport)
|
||||
this.#lifecycle = 'closed'
|
||||
if (!this.#device.opened)
|
||||
throw openError
|
||||
|
||||
try {
|
||||
await this.#device.close()
|
||||
}
|
||||
catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[openError, closeError],
|
||||
'The DualSense controller failed to open and close.',
|
||||
)
|
||||
}
|
||||
throw openError
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops input report handling and closes the HID device. */
|
||||
async close(): Promise<void> {
|
||||
if (this.#lifecycle === 'closed')
|
||||
return
|
||||
if (this.#lifecycle !== 'open')
|
||||
throw new Error(`Cannot close a DualSense controller while it is ${this.#lifecycle}.`)
|
||||
|
||||
this.#lifecycle = 'closing'
|
||||
this.#device.removeEventListener('inputreport', this.#handleInputReport)
|
||||
try {
|
||||
if (this.#device.opened)
|
||||
await this.#device.close()
|
||||
}
|
||||
finally {
|
||||
this.#lifecycle = 'closed'
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds an input report listener and returns its cleanup function. */
|
||||
onInputReport(listener: DualSenseInputReportListener): () => void {
|
||||
this.#inputReportListeners.add(listener)
|
||||
return () => this.#inputReportListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Builds and sends one output report with the specified controller state. */
|
||||
async sendOutput(output: DualSenseOutputState): Promise<void> {
|
||||
if (this.#lifecycle !== 'open')
|
||||
throw new Error('Open the DualSense controller before you send an output report.')
|
||||
|
||||
const report = buildDualSenseOutputReport(
|
||||
this.#connectionType,
|
||||
output,
|
||||
this.#outputSequenceNumber,
|
||||
)
|
||||
this.#outputSequenceNumber = report.nextSequenceNumber
|
||||
await this.#device.sendReport(report.reportId, report.data)
|
||||
}
|
||||
|
||||
readonly #handleInputReport = (event: HIDInputReportEvent): void => {
|
||||
if (event.device !== this.#device || this.#lifecycle !== 'open')
|
||||
return
|
||||
|
||||
const report = parseDualSenseInputReport(this.#connectionType, event.reportId, event.data)
|
||||
if (!report)
|
||||
return
|
||||
|
||||
this.#latestInputReport = report
|
||||
for (const listener of this.#inputReportListeners)
|
||||
listener(report)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export { DualSenseController } from './controller'
|
||||
export { parseDualSenseInputReport } from './input-report'
|
||||
export {
|
||||
buildDualSenseOutputReport,
|
||||
createDefaultDualSenseOutputState,
|
||||
} from './output-report'
|
||||
export type {
|
||||
DualSenseBatteryState,
|
||||
DualSenseButtonState,
|
||||
DualSenseConnectionEvent,
|
||||
DualSenseConnectionListener,
|
||||
DualSenseConnectionType,
|
||||
DualSenseControllerLifecycle,
|
||||
DualSenseDpadState,
|
||||
DualSenseInputReport,
|
||||
DualSenseInputReportListener,
|
||||
DualSenseInputState,
|
||||
DualSenseMotionState,
|
||||
DualSenseOutputReport,
|
||||
DualSenseOutputState,
|
||||
DualSenseStickState,
|
||||
DualSenseTouchPoint,
|
||||
DualSenseTriggerEffect,
|
||||
DualSenseTriggerFeedback,
|
||||
DualSenseTriggerState,
|
||||
DualSenseVector3,
|
||||
} from './types'
|
||||
export {
|
||||
detectDualSenseConnectionType,
|
||||
dualSenseProductId,
|
||||
dualSenseVendorId,
|
||||
getGrantedDualSenseDevices,
|
||||
isDualSenseDevice,
|
||||
isWebHidSupported,
|
||||
onDualSenseConnectionChange,
|
||||
requestDualSenseDevice,
|
||||
} from './web-hid'
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseDualSenseInputReport } from './input-report'
|
||||
|
||||
function dataViewFromHex(hex: string): DataView {
|
||||
const bytes = Uint8Array.from(
|
||||
hex.trim().split(/\s+/).map(value => Number.parseInt(value, 16)),
|
||||
)
|
||||
return new DataView(bytes.buffer)
|
||||
}
|
||||
|
||||
describe('parseDualSenseInputReport', () => {
|
||||
it('parses the published neutral USB report', () => {
|
||||
const data = dataViewFromHex(`
|
||||
7e 81 84 84 00 00 4b 08 00 00 00 ac 0a af 14 f2
|
||||
ff 0a 00 f2 ff b8 ff ff 1d 9e 08 da 8f e8 ae 1b
|
||||
fc 3e 00 26 f9 7f 87 0b bd 09 09 00 00 00 00 00
|
||||
92 a0 e8 ae 29 08 00 b0 7e c8 76 f8 cc a2 2b
|
||||
`)
|
||||
|
||||
const report = parseDualSenseInputReport('usb', 0x01, data)
|
||||
|
||||
expect(report?.data).toHaveLength(63)
|
||||
expect(report?.state.sequenceNumber).toBe(0x4B)
|
||||
expect(report?.state.sticks.left.x).toBeCloseTo(-0.0118, 3)
|
||||
expect(report?.state.sticks.left.y).toBeCloseTo(0.0118, 3)
|
||||
expect(report?.state.triggers.left.value).toBe(0)
|
||||
expect(report?.state.triggers.right.value).toBe(0)
|
||||
expect(report?.state.dpad).toEqual({ down: false, left: false, right: false, up: false })
|
||||
expect(report?.state.buttons.cross).toBe(false)
|
||||
expect(report?.state.motion).not.toBeNull()
|
||||
expect(report?.state.battery?.charging).toBe(true)
|
||||
expect(report?.state.battery?.full).toBe(true)
|
||||
expect(report?.state.battery?.levelPercent).toBe(100)
|
||||
})
|
||||
|
||||
it('parses an extended Bluetooth report', () => {
|
||||
const bytes = new Uint8Array(77)
|
||||
const data = new DataView(bytes.buffer)
|
||||
bytes[1] = 0xFF
|
||||
bytes[2] = 0x00
|
||||
bytes[3] = 0x80
|
||||
bytes[4] = 0x7F
|
||||
bytes[5] = 0x80
|
||||
bytes[6] = 0xFF
|
||||
bytes[8] = 0x21
|
||||
bytes[9] = 0x0D
|
||||
bytes[10] = 0x07
|
||||
data.setUint32(12, 0x12345678, true)
|
||||
data.setInt16(16, -1234, true)
|
||||
data.setInt16(18, 2345, true)
|
||||
data.setInt16(20, -3456, true)
|
||||
data.setInt16(22, 4567, true)
|
||||
data.setInt16(24, -5678, true)
|
||||
data.setInt16(26, 6789, true)
|
||||
bytes[33] = 0x05
|
||||
bytes[34] = 0x34
|
||||
bytes[35] = 0x12
|
||||
bytes[36] = 0x56
|
||||
bytes[37] = 0x87
|
||||
bytes[42] = 0x13
|
||||
bytes[43] = 0x05
|
||||
bytes[53] = 0x24
|
||||
bytes[54] = 0x08
|
||||
|
||||
const report = parseDualSenseInputReport('bluetooth', 0x31, data)
|
||||
|
||||
expect(report?.state.sticks.left).toEqual({ x: 1, y: -1 })
|
||||
expect(report?.state.dpad).toEqual({ down: false, left: false, right: true, up: true })
|
||||
expect(report?.state.buttons.cross).toBe(true)
|
||||
expect(report?.state.buttons.mute).toBe(true)
|
||||
expect(report?.state.triggers.left.pressed).toBe(true)
|
||||
expect(report?.state.triggers.left.value).toBeCloseTo(0.502, 3)
|
||||
expect(report?.state.triggers.left.feedback).toEqual({ active: false, state: 5 })
|
||||
expect(report?.state.triggers.right.feedback).toEqual({ active: true, state: 3 })
|
||||
expect(report?.state.timestamp).toBe(0x12345678)
|
||||
expect(report?.state.motion?.gyroscope).toEqual({ x: -1234, y: 2345, z: -3456 })
|
||||
expect(report?.state.motion?.accelerometer).toEqual({ x: 4567, y: -5678, z: 6789 })
|
||||
expect(report?.state.touchPoints?.[0]).toEqual({ active: true, id: 5, x: 0x234, y: 0x561 })
|
||||
expect(report?.state.touchPoints?.[1]?.active).toBe(false)
|
||||
expect(report?.state.battery).toEqual({ charging: true, full: true, levelPercent: 50 })
|
||||
})
|
||||
|
||||
it('parses the compact Bluetooth report without unavailable sensors', () => {
|
||||
const data = dataViewFromHex('7d 7e 83 82 08 00 00 00 00')
|
||||
|
||||
const report = parseDualSenseInputReport('bluetooth', 0x01, data)
|
||||
|
||||
expect(report?.state.motion).toBeNull()
|
||||
expect(report?.state.touchPoints).toBeNull()
|
||||
expect(report?.state.battery).toBeNull()
|
||||
expect(report?.state.buttons.mute).toBe(false)
|
||||
expect(report?.state.dpad).toEqual({ down: false, left: false, right: false, up: false })
|
||||
})
|
||||
|
||||
it('ignores unsupported report shapes', () => {
|
||||
expect(parseDualSenseInputReport('unknown', 0x01, new DataView(new ArrayBuffer(63)))).toBeUndefined()
|
||||
expect(parseDualSenseInputReport('usb', 0x31, new DataView(new ArrayBuffer(77)))).toBeUndefined()
|
||||
expect(parseDualSenseInputReport('usb', 0x01, new DataView(new ArrayBuffer(62)))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
DualSenseButtonState,
|
||||
DualSenseConnectionType,
|
||||
DualSenseDpadState,
|
||||
DualSenseInputReport,
|
||||
DualSenseInputState,
|
||||
DualSenseTouchPoint,
|
||||
DualSenseTriggerFeedback,
|
||||
} from './types'
|
||||
|
||||
const usbInputReport01Size = 63
|
||||
const bluetoothInputReport01Size = 9
|
||||
const bluetoothInputReport31Size = 77
|
||||
|
||||
interface ExtendedInputOffsets {
|
||||
accelerometer: number
|
||||
axes: number
|
||||
battery: number
|
||||
buttons: number
|
||||
feedback: number
|
||||
gyroscope: number
|
||||
sensorTimestamp: number | null
|
||||
sequenceNumber: number | null
|
||||
timestamp: number
|
||||
touchPoints: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one WebHID input report from a standard DualSense controller.
|
||||
* The function returns `undefined` when the report ID or byte length is not supported.
|
||||
*
|
||||
* @example
|
||||
* const state = parseDualSenseInputReport('bluetooth', 0x01, reportData)
|
||||
* // => { reportId: 1, state: { sticks: { ... } }, ... }
|
||||
*/
|
||||
export function parseDualSenseInputReport(
|
||||
connectionType: DualSenseConnectionType,
|
||||
reportId: number,
|
||||
reportData: DataView,
|
||||
): DualSenseInputReport | undefined {
|
||||
let state: DualSenseInputState | undefined
|
||||
|
||||
if (connectionType === 'usb' && reportId === 0x01 && reportData.byteLength === usbInputReport01Size) {
|
||||
state = parseExtendedInputReport(reportData, {
|
||||
accelerometer: 21,
|
||||
axes: 0,
|
||||
battery: 52,
|
||||
buttons: 7,
|
||||
feedback: 41,
|
||||
gyroscope: 15,
|
||||
sensorTimestamp: 27,
|
||||
sequenceNumber: 6,
|
||||
timestamp: 11,
|
||||
touchPoints: 32,
|
||||
})
|
||||
}
|
||||
else if (connectionType === 'bluetooth' && reportId === 0x01 && reportData.byteLength === bluetoothInputReport01Size) {
|
||||
state = parseCompactBluetoothInputReport(reportData)
|
||||
}
|
||||
else if (connectionType === 'bluetooth' && reportId === 0x31 && reportData.byteLength === bluetoothInputReport31Size) {
|
||||
state = parseExtendedInputReport(reportData, {
|
||||
accelerometer: 22,
|
||||
axes: 1,
|
||||
battery: 53,
|
||||
buttons: 8,
|
||||
feedback: 42,
|
||||
gyroscope: 16,
|
||||
sensorTimestamp: null,
|
||||
sequenceNumber: null,
|
||||
timestamp: 12,
|
||||
touchPoints: 33,
|
||||
})
|
||||
}
|
||||
|
||||
if (!state)
|
||||
return undefined
|
||||
|
||||
const data = new Uint8Array(reportData.buffer, reportData.byteOffset, reportData.byteLength).slice()
|
||||
return { connectionType, data, reportId, state }
|
||||
}
|
||||
|
||||
function parseCompactBluetoothInputReport(report: DataView): DualSenseInputState {
|
||||
const buttons0 = report.getUint8(4)
|
||||
const buttons1 = report.getUint8(5)
|
||||
const buttons2 = report.getUint8(6)
|
||||
|
||||
return {
|
||||
battery: null,
|
||||
buttons: parseButtons(buttons0, buttons1, buttons2, false),
|
||||
dpad: parseDpad(buttons0),
|
||||
motion: null,
|
||||
sensorTimestamp: null,
|
||||
sequenceNumber: null,
|
||||
sticks: parseSticks(report, 0),
|
||||
timestamp: null,
|
||||
touchPoints: null,
|
||||
triggers: {
|
||||
left: {
|
||||
feedback: null,
|
||||
pressed: hasBit(buttons1, 2),
|
||||
value: normalizeTriggerAxis(report.getUint8(7)),
|
||||
},
|
||||
right: {
|
||||
feedback: null,
|
||||
pressed: hasBit(buttons1, 3),
|
||||
value: normalizeTriggerAxis(report.getUint8(8)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseExtendedInputReport(report: DataView, offsets: ExtendedInputOffsets): DualSenseInputState {
|
||||
const buttons0 = report.getUint8(offsets.buttons)
|
||||
const buttons1 = report.getUint8(offsets.buttons + 1)
|
||||
const buttons2 = report.getUint8(offsets.buttons + 2)
|
||||
const rightFeedback = report.getUint8(offsets.feedback)
|
||||
const leftFeedback = report.getUint8(offsets.feedback + 1)
|
||||
const battery0 = report.getUint8(offsets.battery)
|
||||
const battery1 = report.getUint8(offsets.battery + 1)
|
||||
|
||||
return {
|
||||
battery: {
|
||||
charging: hasBit(battery1, 3),
|
||||
full: hasBit(battery0, 5),
|
||||
levelPercent: Math.min(100, (battery0 & 0x0F) * 100 / 8),
|
||||
},
|
||||
buttons: parseButtons(buttons0, buttons1, buttons2, true),
|
||||
dpad: parseDpad(buttons0),
|
||||
motion: {
|
||||
accelerometer: parseVector3(report, offsets.accelerometer),
|
||||
gyroscope: parseVector3(report, offsets.gyroscope),
|
||||
},
|
||||
sensorTimestamp: offsets.sensorTimestamp === null
|
||||
? null
|
||||
: report.getUint32(offsets.sensorTimestamp, true),
|
||||
sequenceNumber: offsets.sequenceNumber === null
|
||||
? null
|
||||
: report.getUint8(offsets.sequenceNumber),
|
||||
sticks: parseSticks(report, offsets.axes),
|
||||
timestamp: report.getUint32(offsets.timestamp, true),
|
||||
touchPoints: [
|
||||
parseTouchPoint(report, offsets.touchPoints),
|
||||
parseTouchPoint(report, offsets.touchPoints + 4),
|
||||
],
|
||||
triggers: {
|
||||
left: {
|
||||
feedback: parseTriggerFeedback(leftFeedback),
|
||||
pressed: hasBit(buttons1, 2),
|
||||
value: normalizeTriggerAxis(report.getUint8(offsets.axes + 4)),
|
||||
},
|
||||
right: {
|
||||
feedback: parseTriggerFeedback(rightFeedback),
|
||||
pressed: hasBit(buttons1, 3),
|
||||
value: normalizeTriggerAxis(report.getUint8(offsets.axes + 5)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseButtons(
|
||||
buttons0: number,
|
||||
buttons1: number,
|
||||
buttons2: number,
|
||||
hasMuteButton: boolean,
|
||||
): DualSenseButtonState {
|
||||
return {
|
||||
circle: hasBit(buttons0, 6),
|
||||
create: hasBit(buttons1, 4),
|
||||
cross: hasBit(buttons0, 5),
|
||||
l1: hasBit(buttons1, 0),
|
||||
l3: hasBit(buttons1, 6),
|
||||
mute: hasMuteButton && hasBit(buttons2, 2),
|
||||
options: hasBit(buttons1, 5),
|
||||
ps: hasBit(buttons2, 0),
|
||||
r1: hasBit(buttons1, 1),
|
||||
r3: hasBit(buttons1, 7),
|
||||
square: hasBit(buttons0, 4),
|
||||
touchpad: hasBit(buttons2, 1),
|
||||
triangle: hasBit(buttons0, 7),
|
||||
}
|
||||
}
|
||||
|
||||
function parseDpad(buttons0: number): DualSenseDpadState {
|
||||
const direction = buttons0 & 0x0F
|
||||
return {
|
||||
down: direction === 3 || direction === 4 || direction === 5,
|
||||
left: direction === 5 || direction === 6 || direction === 7,
|
||||
right: direction === 1 || direction === 2 || direction === 3,
|
||||
up: direction === 0 || direction === 1 || direction === 7,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSticks(report: DataView, offset: number): DualSenseInputState['sticks'] {
|
||||
return {
|
||||
left: {
|
||||
x: normalizeThumbStickAxis(report.getUint8(offset)),
|
||||
y: normalizeThumbStickAxis(report.getUint8(offset + 1)),
|
||||
},
|
||||
right: {
|
||||
x: normalizeThumbStickAxis(report.getUint8(offset + 2)),
|
||||
y: normalizeThumbStickAxis(report.getUint8(offset + 3)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseVector3(report: DataView, offset: number) {
|
||||
return {
|
||||
x: report.getInt16(offset, true),
|
||||
y: report.getInt16(offset + 2, true),
|
||||
z: report.getInt16(offset + 4, true),
|
||||
}
|
||||
}
|
||||
|
||||
function parseTouchPoint(report: DataView, offset: number): DualSenseTouchPoint {
|
||||
const byte0 = report.getUint8(offset)
|
||||
const byte1 = report.getUint8(offset + 1)
|
||||
const byte2 = report.getUint8(offset + 2)
|
||||
const byte3 = report.getUint8(offset + 3)
|
||||
return {
|
||||
active: (byte0 & 0x80) === 0,
|
||||
id: byte0 & 0x7F,
|
||||
x: ((byte2 & 0x0F) << 8) | byte1,
|
||||
y: (byte3 << 4) | ((byte2 & 0xF0) >> 4),
|
||||
}
|
||||
}
|
||||
|
||||
function parseTriggerFeedback(value: number): DualSenseTriggerFeedback {
|
||||
return {
|
||||
active: (value & 0x10) !== 0,
|
||||
state: value & 0x0F,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThumbStickAxis(value: number): number {
|
||||
return (2 * value / 0xFF) - 1
|
||||
}
|
||||
|
||||
function normalizeTriggerAxis(value: number): number {
|
||||
return value / 0xFF
|
||||
}
|
||||
|
||||
function hasBit(value: number, bit: number): boolean {
|
||||
return (value & (1 << bit)) !== 0
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildDualSenseOutputReport,
|
||||
createDefaultDualSenseOutputState,
|
||||
} from './output-report'
|
||||
|
||||
describe('buildDualSenseOutputReport', () => {
|
||||
it('builds a USB output report', () => {
|
||||
const initial = createDefaultDualSenseOutputState()
|
||||
const report = buildDualSenseOutputReport('usb', {
|
||||
...initial,
|
||||
leftMotor: 300,
|
||||
lightbar: { blue: 232, green: 178, red: 124 },
|
||||
muteLed: true,
|
||||
playerLeds: [true, false, true, false, true],
|
||||
rightMotor: -10,
|
||||
})
|
||||
|
||||
expect(report.reportId).toBe(0x02)
|
||||
expect(report.data).toHaveLength(47)
|
||||
expect(report.data[0]).toBe(0xFF)
|
||||
expect(report.data[1]).toBe(0xF7)
|
||||
expect(report.data[2]).toBe(0)
|
||||
expect(report.data[3]).toBe(0xFF)
|
||||
expect(report.data[8]).toBe(1)
|
||||
expect(report.data[9]).toBe(0)
|
||||
expect(report.data[10]).toBe(0x26)
|
||||
expect(report.data[11]).toBe(0x90)
|
||||
expect(report.data[21]).toBe(0x26)
|
||||
expect(report.data[43]).toBe(0x15)
|
||||
expect(Array.from(report.data.slice(44, 47))).toEqual([124, 178, 232])
|
||||
})
|
||||
|
||||
it('builds a Bluetooth output report with its sequence and checksum', () => {
|
||||
const report = buildDualSenseOutputReport(
|
||||
'bluetooth',
|
||||
createDefaultDualSenseOutputState(),
|
||||
15,
|
||||
)
|
||||
|
||||
expect(report.reportId).toBe(0x31)
|
||||
expect(report.data).toHaveLength(77)
|
||||
expect(report.data[0]).toBe(0xF0)
|
||||
expect(report.data[1]).toBe(0x10)
|
||||
expect(report.nextSequenceNumber).toBe(0)
|
||||
expect(Array.from(report.data.slice(-4))).toEqual([0xB0, 0xB6, 0xD2, 0xB2])
|
||||
})
|
||||
|
||||
it('rejects an unknown transport', () => {
|
||||
expect(() => buildDualSenseOutputReport(
|
||||
'unknown',
|
||||
createDefaultDualSenseOutputState(),
|
||||
)).toThrowError('The DualSense connection type is unknown.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
DualSenseConnectionType,
|
||||
DualSenseOutputReport,
|
||||
DualSenseOutputState,
|
||||
DualSenseTriggerEffect,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Creates the initial output state used by the DualSense Explorer protocol.
|
||||
*
|
||||
* @example
|
||||
* createDefaultDualSenseOutputState()
|
||||
* // => { lightbar: { red: 255, green: 255, blue: 255 }, ... }
|
||||
*/
|
||||
export function createDefaultDualSenseOutputState(): DualSenseOutputState {
|
||||
return {
|
||||
leftMotor: 0,
|
||||
leftTriggerEffect: createDefaultTriggerEffect(),
|
||||
lightbar: { blue: 0xFF, green: 0xFF, red: 0xFF },
|
||||
muteLed: false,
|
||||
playerLeds: [false, false, false, false, false],
|
||||
rightMotor: 0,
|
||||
rightTriggerEffect: createDefaultTriggerEffect(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one USB or Bluetooth DualSense output report.
|
||||
* The sequence number is used only by Bluetooth reports.
|
||||
*/
|
||||
export function buildDualSenseOutputReport(
|
||||
connectionType: DualSenseConnectionType,
|
||||
output: DualSenseOutputState,
|
||||
sequenceNumber = 1,
|
||||
): DualSenseOutputReport {
|
||||
if (connectionType === 'unknown')
|
||||
throw new Error('The DualSense connection type is unknown.')
|
||||
|
||||
const reportId = connectionType === 'bluetooth' ? 0x31 : 0x02
|
||||
const data = new Uint8Array(connectionType === 'bluetooth' ? 77 : 47)
|
||||
const commonOffset = connectionType === 'bluetooth' ? 2 : 0
|
||||
const common = new DataView(data.buffer, commonOffset, 47)
|
||||
|
||||
if (connectionType === 'bluetooth') {
|
||||
data[0] = (sequenceNumber & 0x0F) << 4
|
||||
data[1] = 0x10
|
||||
}
|
||||
|
||||
common.setUint8(0, 0xFF)
|
||||
common.setUint8(1, 0xF7)
|
||||
common.setUint8(2, toByte(output.rightMotor))
|
||||
common.setUint8(3, toByte(output.leftMotor))
|
||||
common.setUint8(8, output.muteLed ? 0x01 : 0x00)
|
||||
common.setUint8(9, output.muteLed ? 0x00 : 0x10)
|
||||
writeTriggerEffect(common, 10, output.rightTriggerEffect)
|
||||
writeTriggerEffect(common, 21, output.leftTriggerEffect)
|
||||
common.setUint8(39, 0x02)
|
||||
common.setUint8(41, 0x02)
|
||||
common.setUint8(43, createPlayerLedMask(output.playerLeds))
|
||||
common.setUint8(44, toByte(output.lightbar.red))
|
||||
common.setUint8(45, toByte(output.lightbar.green))
|
||||
common.setUint8(46, toByte(output.lightbar.blue))
|
||||
|
||||
if (connectionType === 'bluetooth')
|
||||
fillBluetoothChecksum(reportId, data)
|
||||
|
||||
return {
|
||||
data,
|
||||
nextSequenceNumber: connectionType === 'bluetooth' ? (sequenceNumber + 1) & 0x0F : sequenceNumber,
|
||||
reportId,
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultTriggerEffect(): DualSenseTriggerEffect {
|
||||
return {
|
||||
mode: 0x26,
|
||||
parameters: [0x90, 0xA0, 0xFF, 0x00, 0x00, 0x00, 0x00],
|
||||
}
|
||||
}
|
||||
|
||||
function writeTriggerEffect(view: DataView, offset: number, effect: DualSenseTriggerEffect): void {
|
||||
view.setUint8(offset, toByte(effect.mode))
|
||||
effect.parameters.forEach((parameter, index) => view.setUint8(offset + index + 1, toByte(parameter)))
|
||||
}
|
||||
|
||||
function createPlayerLedMask(playerLeds: DualSenseOutputState['playerLeds']): number {
|
||||
return playerLeds.reduce((mask, enabled, index) => enabled ? mask | (1 << index) : mask, 0)
|
||||
}
|
||||
|
||||
function fillBluetoothChecksum(reportId: number, data: Uint8Array): void {
|
||||
const checksum = crc32([0xA2, reportId], new DataView(data.buffer, 0, data.byteLength - 4))
|
||||
data[data.byteLength - 4] = checksum & 0xFF
|
||||
data[data.byteLength - 3] = (checksum >>> 8) & 0xFF
|
||||
data[data.byteLength - 2] = (checksum >>> 16) & 0xFF
|
||||
data[data.byteLength - 1] = (checksum >>> 24) & 0xFF
|
||||
}
|
||||
|
||||
function crc32(prefixBytes: readonly number[], data: DataView): number {
|
||||
let crc = -1 >>> 0
|
||||
for (const byte of prefixBytes)
|
||||
crc = updateCrc32(crc, byte)
|
||||
for (let index = 0; index < data.byteLength; index++)
|
||||
crc = updateCrc32(crc, data.getUint8(index))
|
||||
return (crc ^ -1) >>> 0
|
||||
}
|
||||
|
||||
function updateCrc32(crc: number, byte: number): number {
|
||||
let value = (crc ^ byte) & 0xFF
|
||||
for (let index = 0; index < 8; index++)
|
||||
value = (value & 1) !== 0 ? 0xEDB88320 ^ (value >>> 1) : value >>> 1
|
||||
return (crc >>> 8) ^ value
|
||||
}
|
||||
|
||||
function toByte(value: number): number {
|
||||
return Math.min(0xFF, Math.max(0, Math.round(value)))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/** The transport that provides DualSense HID reports. */
|
||||
export type DualSenseConnectionType = 'bluetooth' | 'unknown' | 'usb'
|
||||
|
||||
/** The lifecycle state of a {@link DualSenseController}. */
|
||||
export type DualSenseControllerLifecycle = 'closed' | 'closing' | 'open' | 'opening'
|
||||
|
||||
/** A normalized two-dimensional stick value. Each axis is in the range from -1 to 1. */
|
||||
export interface DualSenseStickState {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** The state of the four directional buttons. */
|
||||
export interface DualSenseDpadState {
|
||||
readonly down: boolean
|
||||
readonly left: boolean
|
||||
readonly right: boolean
|
||||
readonly up: boolean
|
||||
}
|
||||
|
||||
/** The digital button state that does not belong to a trigger or directional pad. */
|
||||
export interface DualSenseButtonState {
|
||||
readonly circle: boolean
|
||||
readonly create: boolean
|
||||
readonly l1: boolean
|
||||
readonly l3: boolean
|
||||
readonly mute: boolean
|
||||
readonly options: boolean
|
||||
readonly ps: boolean
|
||||
readonly r1: boolean
|
||||
readonly r3: boolean
|
||||
readonly square: boolean
|
||||
readonly touchpad: boolean
|
||||
readonly triangle: boolean
|
||||
readonly cross: boolean
|
||||
}
|
||||
|
||||
/** Feedback from one adaptive trigger. */
|
||||
export interface DualSenseTriggerFeedback {
|
||||
readonly active: boolean
|
||||
readonly state: number
|
||||
}
|
||||
|
||||
/** The input state of one trigger. */
|
||||
export interface DualSenseTriggerState {
|
||||
readonly pressed: boolean
|
||||
/** The normalized trigger position in the range from 0 to 1. */
|
||||
readonly value: number
|
||||
/** Bluetooth report `0x01` does not contain this value. */
|
||||
readonly feedback: DualSenseTriggerFeedback | null
|
||||
}
|
||||
|
||||
/** One touch point from the DualSense touchpad. */
|
||||
export interface DualSenseTouchPoint {
|
||||
readonly active: boolean
|
||||
readonly id: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** A signed three-dimensional sensor value from the controller. */
|
||||
export interface DualSenseVector3 {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly z: number
|
||||
}
|
||||
|
||||
/** Motion data from an extended USB or Bluetooth report. */
|
||||
export interface DualSenseMotionState {
|
||||
readonly accelerometer: DualSenseVector3
|
||||
readonly gyroscope: DualSenseVector3
|
||||
}
|
||||
|
||||
/** Battery data from an extended USB or Bluetooth report. */
|
||||
export interface DualSenseBatteryState {
|
||||
readonly charging: boolean
|
||||
readonly full: boolean
|
||||
readonly levelPercent: number
|
||||
}
|
||||
|
||||
/** The parsed state of one DualSense input report. */
|
||||
export interface DualSenseInputState {
|
||||
readonly battery: DualSenseBatteryState | null
|
||||
readonly buttons: DualSenseButtonState
|
||||
readonly dpad: DualSenseDpadState
|
||||
readonly motion: DualSenseMotionState | null
|
||||
/** This raw controller counter is not a wall-clock timestamp. */
|
||||
readonly sensorTimestamp: number | null
|
||||
readonly sequenceNumber: number | null
|
||||
readonly sticks: {
|
||||
readonly left: DualSenseStickState
|
||||
readonly right: DualSenseStickState
|
||||
}
|
||||
/** This raw controller counter is not a wall-clock timestamp. */
|
||||
readonly timestamp: number | null
|
||||
readonly touchPoints: readonly [DualSenseTouchPoint, DualSenseTouchPoint] | null
|
||||
readonly triggers: {
|
||||
readonly left: DualSenseTriggerState
|
||||
readonly right: DualSenseTriggerState
|
||||
}
|
||||
}
|
||||
|
||||
/** A parsed input report and its original bytes. */
|
||||
export interface DualSenseInputReport {
|
||||
readonly connectionType: DualSenseConnectionType
|
||||
/** These bytes do not include the report ID. */
|
||||
readonly data: Uint8Array
|
||||
readonly reportId: number
|
||||
readonly state: DualSenseInputState
|
||||
}
|
||||
|
||||
/** The eight-byte effect payload for one adaptive trigger. */
|
||||
export interface DualSenseTriggerEffect {
|
||||
readonly mode: number
|
||||
readonly parameters: readonly [number, number, number, number, number, number, number]
|
||||
}
|
||||
|
||||
/** Values for one DualSense output report. Byte values are clamped to the range from 0 to 255. */
|
||||
export interface DualSenseOutputState {
|
||||
readonly leftMotor: number
|
||||
readonly leftTriggerEffect: DualSenseTriggerEffect
|
||||
readonly lightbar: {
|
||||
readonly blue: number
|
||||
readonly green: number
|
||||
readonly red: number
|
||||
}
|
||||
readonly muteLed: boolean
|
||||
/** Each item controls one of the five white player LEDs. */
|
||||
readonly playerLeds: readonly [boolean, boolean, boolean, boolean, boolean]
|
||||
readonly rightMotor: number
|
||||
readonly rightTriggerEffect: DualSenseTriggerEffect
|
||||
}
|
||||
|
||||
/** Bytes that can be passed to `HIDDevice.sendReport()`. */
|
||||
export interface DualSenseOutputReport {
|
||||
readonly data: Uint8Array<ArrayBuffer>
|
||||
readonly nextSequenceNumber: number
|
||||
readonly reportId: number
|
||||
}
|
||||
|
||||
/** A DualSense connect or disconnect event from WebHID. */
|
||||
export interface DualSenseConnectionEvent {
|
||||
readonly device: HIDDevice
|
||||
readonly type: 'connect' | 'disconnect'
|
||||
}
|
||||
|
||||
/** Receives parsed input reports from a {@link DualSenseController}. */
|
||||
export type DualSenseInputReportListener = (report: DualSenseInputReport) => void
|
||||
|
||||
/** Receives WebHID connection changes for standard DualSense devices. */
|
||||
export type DualSenseConnectionListener = (event: DualSenseConnectionEvent) => void
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
detectDualSenseConnectionType,
|
||||
isDualSenseDevice,
|
||||
} from './web-hid'
|
||||
|
||||
function createGamepadCollection(reportBits: number): HIDCollectionInfo {
|
||||
return {
|
||||
inputReports: [{
|
||||
items: [{ reportCount: 1, reportSize: reportBits }],
|
||||
}],
|
||||
usage: 0x0005,
|
||||
usagePage: 0x0001,
|
||||
}
|
||||
}
|
||||
|
||||
describe('device detection', () => {
|
||||
it('matches the standard Sony DualSense identifiers', () => {
|
||||
expect(isDualSenseDevice({ productId: 0x0CE6, vendorId: 0x054C })).toBe(true)
|
||||
expect(isDualSenseDevice({ productId: 0x0CE7, vendorId: 0x054C })).toBe(false)
|
||||
})
|
||||
|
||||
it('detects USB and Bluetooth report descriptors', () => {
|
||||
expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(504)] })).toBe('usb')
|
||||
expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(616)] })).toBe('bluetooth')
|
||||
expect(detectDualSenseConnectionType({ collections: [createGamepadCollection(512)] })).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
DualSenseConnectionListener,
|
||||
DualSenseConnectionType,
|
||||
} from './types'
|
||||
|
||||
/** Sony's USB vendor ID. */
|
||||
export const dualSenseVendorId = 0x054C
|
||||
|
||||
/** The product ID of the standard PlayStation 5 DualSense controller. */
|
||||
export const dualSenseProductId = 0x0CE6
|
||||
|
||||
const genericDesktopUsagePage = 0x0001
|
||||
const gamepadUsage = 0x0005
|
||||
|
||||
/** Returns true when the current browser exposes WebHID. */
|
||||
export function isWebHidSupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && 'hid' in navigator
|
||||
}
|
||||
|
||||
/** Returns true when a WebHID device is a standard PlayStation 5 DualSense controller. */
|
||||
export function isDualSenseDevice(device: Pick<HIDDevice, 'productId' | 'vendorId'>): boolean {
|
||||
return device.vendorId === dualSenseVendorId && device.productId === dualSenseProductId
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the DualSense transport from its generic gamepad collection.
|
||||
* WebHID does not provide the USB or Bluetooth transport directly.
|
||||
*/
|
||||
export function detectDualSenseConnectionType(
|
||||
device: Pick<HIDDevice, 'collections'>,
|
||||
): DualSenseConnectionType {
|
||||
for (const collection of device.collections) {
|
||||
if (collection.usagePage !== genericDesktopUsagePage || collection.usage !== gamepadUsage)
|
||||
continue
|
||||
|
||||
const maximumInputReportBits = getMaximumInputReportBits(collection)
|
||||
if (maximumInputReportBits === 504)
|
||||
return 'usb'
|
||||
if (maximumInputReportBits === 616)
|
||||
return 'bluetooth'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the WebHID device chooser for a standard DualSense controller.
|
||||
* Call this function from a user action.
|
||||
*/
|
||||
export async function requestDualSenseDevice(hid?: HID): Promise<HIDDevice | undefined> {
|
||||
const devices = await resolveWebHid(hid).requestDevice({
|
||||
filters: [createDualSenseDeviceFilter()],
|
||||
})
|
||||
return devices.find(isDualSenseDevice)
|
||||
}
|
||||
|
||||
/** Returns the permitted standard DualSense devices that are currently known to WebHID. */
|
||||
export async function getGrantedDualSenseDevices(hid?: HID): Promise<HIDDevice[]> {
|
||||
const devices = await resolveWebHid(hid).getDevices()
|
||||
return devices.filter(isDualSenseDevice)
|
||||
}
|
||||
|
||||
/** Observes WebHID connect and disconnect events for standard DualSense devices. */
|
||||
export function onDualSenseConnectionChange(
|
||||
listener: DualSenseConnectionListener,
|
||||
hid?: HID,
|
||||
): () => void {
|
||||
const webHid = resolveWebHid(hid)
|
||||
const handleConnect = (event: HIDConnectionEvent) => {
|
||||
if (isDualSenseDevice(event.device))
|
||||
listener({ device: event.device, type: 'connect' })
|
||||
}
|
||||
const handleDisconnect = (event: HIDConnectionEvent) => {
|
||||
if (isDualSenseDevice(event.device))
|
||||
listener({ device: event.device, type: 'disconnect' })
|
||||
}
|
||||
|
||||
webHid.addEventListener('connect', handleConnect)
|
||||
webHid.addEventListener('disconnect', handleDisconnect)
|
||||
return () => {
|
||||
webHid.removeEventListener('connect', handleConnect)
|
||||
webHid.removeEventListener('disconnect', handleDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
function createDualSenseDeviceFilter(): HIDDeviceFilter {
|
||||
return {
|
||||
productId: dualSenseProductId,
|
||||
usage: gamepadUsage,
|
||||
usagePage: genericDesktopUsagePage,
|
||||
vendorId: dualSenseVendorId,
|
||||
}
|
||||
}
|
||||
|
||||
function getMaximumInputReportBits(collection: HIDCollectionInfo): number {
|
||||
const inputReports = collection.inputReports
|
||||
if (!inputReports)
|
||||
return 0
|
||||
|
||||
let maximumBits = 0
|
||||
for (const report of inputReports) {
|
||||
const items = report.items
|
||||
if (!items)
|
||||
continue
|
||||
|
||||
let reportBits = 0
|
||||
for (const item of items) {
|
||||
if (item.reportSize === undefined || item.reportCount === undefined)
|
||||
continue
|
||||
reportBits += item.reportSize * item.reportCount
|
||||
}
|
||||
maximumBits = Math.max(maximumBits, reportBits)
|
||||
}
|
||||
return maximumBits
|
||||
}
|
||||
|
||||
function resolveWebHid(hid: HID | undefined): HID {
|
||||
if (hid)
|
||||
return hid
|
||||
if (!isWebHidSupported())
|
||||
throw new Error('WebHID is not available in this browser.')
|
||||
return navigator.hid
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"types": [
|
||||
"w3c-web-hid"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"tsdown.config.ts",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
dts: true,
|
||||
platform: 'browser',
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Generated
+29
@@ -477,6 +477,9 @@ catalogs:
|
||||
'@types/vscode':
|
||||
specifier: ^1.134.0
|
||||
version: 1.134.0
|
||||
'@types/w3c-web-hid':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
'@types/whatwg-mimetype':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
@@ -3742,6 +3745,27 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 2.9.0
|
||||
|
||||
packages/input-gamepad: {}
|
||||
|
||||
packages/input-gamepad-vueuse:
|
||||
dependencies:
|
||||
'@proj-airi/input-gamepad':
|
||||
specifier: workspace:^
|
||||
version: link:../input-gamepad
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||
devDependencies:
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.41(typescript@6.0.3)
|
||||
|
||||
packages/input-playstation-dualsense-5:
|
||||
dependencies:
|
||||
'@types/w3c-web-hid':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.7
|
||||
|
||||
packages/memory-pgvector:
|
||||
dependencies:
|
||||
'@guiiai/logg':
|
||||
@@ -11445,6 +11469,9 @@ packages:
|
||||
'@types/vscode@1.134.0':
|
||||
resolution: {integrity: sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==}
|
||||
|
||||
'@types/w3c-web-hid@1.0.7':
|
||||
resolution: {integrity: sha512-/y97wBH7fYB5vKoDIn11O1ZDMNFLCAVqZ9af0OWDN7VSO2ClErEN2HlGbBuPgHBTUmZOVx1og2ZHX1U2gEJM4Q==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
@@ -25427,6 +25454,8 @@ snapshots:
|
||||
|
||||
'@types/vscode@1.134.0': {}
|
||||
|
||||
'@types/w3c-web-hid@1.0.7': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
@@ -200,6 +200,7 @@ catalog:
|
||||
'@types/three': ^0.185.4
|
||||
'@types/unist': ^3.0.3
|
||||
'@types/vscode': ^1.134.0
|
||||
'@types/w3c-web-hid': ^1.0.7
|
||||
'@types/whatwg-mimetype': ^5.0.0
|
||||
'@types/ws': ^8.18.1
|
||||
'@types/xast': ^2.0.4
|
||||
|
||||
@@ -11,6 +11,9 @@ export default defineConfig({
|
||||
'packages/ccc',
|
||||
'packages/core-agent',
|
||||
'packages/i18n',
|
||||
'packages/input-gamepad',
|
||||
'packages/input-gamepad-vueuse',
|
||||
'packages/input-playstation-dualsense-5',
|
||||
'packages/better-ws',
|
||||
'packages/plugin-sdk',
|
||||
'packages/plugin-sdk-tamagotchi',
|
||||
|
||||
Reference in New Issue
Block a user