feat(input-gamepad): add controller input packages (#2395)

This commit is contained in:
Neko
2026-08-29 03:03:53 +08:00
committed by GitHub
parent 4a18e51660
commit 54da6eac0e
34 changed files with 2170 additions and 0 deletions
+38
View File
@@ -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'],
},
})
+35
View File
@@ -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.
+38
View File
@@ -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": {}
}
+18
View File
@@ -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,
}
}
+71
View File
@@ -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
+19
View File
@@ -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"
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
dts: true,
platform: 'browser',
})
+7
View File
@@ -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'],
},
})
+29
View File
@@ -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': {}
+1
View File
@@ -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
+3
View File
@@ -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',