diff --git a/CONTROLLERS.md b/CONTROLLERS.md index 9930c08..95dd4df 100644 --- a/CONTROLLERS.md +++ b/CONTROLLERS.md @@ -12,6 +12,7 @@ Every controller below has a committed `openmicro doctor` report. CI replays cap | ----------------------------------------- | ------------------------------- | --------- | ---------- | --------- | -------------- | ------------- | --------------- | | GameSir Cyclone 2 (DS4 mode) | DUALSHOCK 4 Wireless Controller | 054c:05c4 | bluetooth | ds4 | 17/17 | none | ✅ full | | GameSir G7 Pro | GameSir-G7 Pro | 3537:1022 | bluetooth | gamesir | 17/17 | none | ✅ full | +| Microsoft Xbox 360 Controller | Controller | 045e:028e | usb | xbox | 17/17 | none | ✅ full | | Microsoft Xbox Elite Series 2 | Controller | 045e:0b00 | usb | xbox | 17/17 | none | ✅ full | | Microsoft Xbox Elite Series 2 (Bluetooth) | Xbox Wireless Controller | 045e:0b13 | bluetooth | xbox | 17/17 | none | ✅ full | | Microsoft Xbox One S Controller | Controller | 045e:02ea | usb | xbox | 17/17 | none | ✅ full | diff --git a/README.md b/README.md index ff1e302..4a048d7 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ Stick flicks fire after returning to center. Rotation gestures (`rstick_cw`/`rst Voice and thinking-depth support varies by harness; see [OpenMicro feature parity](#openmicro-feature-parity). +#### Set up push-to-talk in Claude Code + +The north button (△ / Y) starts and stops Claude Code dictation. It needs two settings: + +1. Turn voice on with `/voice`. +2. Set the voice mode to `tap`. The default mode, `hold`, doesn't work with a controller. The `/config` screen doesn't show this setting, so add it to `~/.claude/settings.json` yourself: + + ```json + "voice": { "enabled": true, "mode": "tap" } + ``` + + Then restart Claude Code. + +In tap mode, press △ / Y once to start listening and again to stop. In `hold` mode, Claude Code detects a held Space by the repeated keystrokes a keyboard sends while the key is down. The controller sends one Space per press, so Claude Code types a space and doesn't start listening. + ## See it in action Animated demos (rendered, not filmed) of the six core interactions: @@ -61,6 +76,16 @@ Animated demos (rendered, not filmed) of the six core interactions: | ![Left-stick flick launches a workflow prompt](assets/demo/workflow-flick.gif)
**Workflow flick** — stick up = review PR | ![Right-stick flicks step thinking depth](assets/demo/thinking-dial.gif)
**Thinking dial** — stick flicks step `/effort` | | ![L1 plus a face button switches control layers](assets/demo/layers.gif)
**Layers** — hold L1 to switch | ![Touchpad click moves focus between sessions](assets/demo/multi-session.gif)
**Multi-session** — touchpad switches focus | +### Xbox 360 Controller (wired) + +The same interactions on the wired Xbox 360 Controller. Face buttons map by position (A = ✕, B = ○, Y = △, X = □), LB stands in for L1, and the Guide button takes the touchpad's role. OpenMicro can't drive the 360's Ring of Light, so session status appears only in the terminal and there is no status-lightbar demo. + +| | | +| :-------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------: | +| ![Xbox 360 face buttons submit, interrupt, dictate, and start a new chat](assets/demo/xbox360/command-keys.gif)
**Command keys** — A B Y X | ![Xbox 360 left-stick flick launches a workflow prompt](assets/demo/xbox360/workflow-flick.gif)
**Workflow flick** — stick up = review PR | +| ![Xbox 360 right-stick flicks step thinking depth](assets/demo/xbox360/thinking-dial.gif)
**Thinking dial** — stick flicks step `/effort` | ![Xbox 360 LB plus a face button switches control layers](assets/demo/xbox360/layers.gif)
**Layers** — hold LB to switch | +| ![Xbox 360 Guide button moves focus between sessions](assets/demo/xbox360/multi-session.gif)
**Multi-session** — Guide switches focus | | + ## What it gives you - Respond to agents without hunting through terminal tabs. @@ -82,7 +107,7 @@ Animated demos (rendered, not filmed) of the six core interactions: | Start a new chat | ✅ `/clear` | ✅ `/new` | | D-pad TUI navigation | ✅ Arrow-key passthrough | ✅ Arrow-key passthrough | | Stick-triggered workflow prompts | ✅ Supported | ✅ Supported | -| Push-to-talk | ✅ Enable with `/voice` | — No equivalent | +| Push-to-talk | ⚠️ `/voice`, `tap` mode | — No equivalent | | Thinking-depth dial | ✅ `/effort`, low → max | — No deterministic binding | | Six remappable control layers | ✅ Supported | ✅ Supported | | Multi-session focus switching | ✅ Supported | ✅ Supported | @@ -220,6 +245,10 @@ Each function returns `{ message, tone }` or `null`; agent statuses also return ## Troubleshooting +### △ / Y types a space instead of starting dictation + +Claude Code's voice mode is set to `hold`. Change it to `tap`; see [Set up push-to-talk in Claude Code](#set-up-push-to-talk-in-claude-code). + ### Controller is connected but OpenMicro cannot open it Another process probably owns the device exclusively. Quit other controller tools, Steam, browser tabs using Gamepad/WebHID, and PS Remote Play, then retry. diff --git a/assets/demo/command-keys.gif b/assets/demo/command-keys.gif index bdac2e2..c0f2b4b 100644 Binary files a/assets/demo/command-keys.gif and b/assets/demo/command-keys.gif differ diff --git a/assets/demo/xbox360/command-keys.gif b/assets/demo/xbox360/command-keys.gif new file mode 100644 index 0000000..c1b1de3 Binary files /dev/null and b/assets/demo/xbox360/command-keys.gif differ diff --git a/assets/demo/xbox360/layers.gif b/assets/demo/xbox360/layers.gif new file mode 100644 index 0000000..da9d370 Binary files /dev/null and b/assets/demo/xbox360/layers.gif differ diff --git a/assets/demo/xbox360/multi-session.gif b/assets/demo/xbox360/multi-session.gif new file mode 100644 index 0000000..ba5b121 Binary files /dev/null and b/assets/demo/xbox360/multi-session.gif differ diff --git a/assets/demo/xbox360/thinking-dial.gif b/assets/demo/xbox360/thinking-dial.gif new file mode 100644 index 0000000..8b13869 Binary files /dev/null and b/assets/demo/xbox360/thinking-dial.gif differ diff --git a/assets/demo/xbox360/workflow-flick.gif b/assets/demo/xbox360/workflow-flick.gif new file mode 100644 index 0000000..8d77f77 Binary files /dev/null and b/assets/demo/xbox360/workflow-flick.gif differ diff --git a/demo/package.json b/demo/package.json index 732aef9..d7a661e 100644 --- a/demo/package.json +++ b/demo/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "studio": "remotion studio", - "render": "remotion render StatusLeds ../assets/demo/status-leds.gif --codec=gif --every-nth-frame=2 && remotion render CommandKeys ../assets/demo/command-keys.gif --codec=gif --every-nth-frame=2 && remotion render WorkflowFlick ../assets/demo/workflow-flick.gif --codec=gif --every-nth-frame=2 && remotion render ThinkingDial ../assets/demo/thinking-dial.gif --codec=gif --every-nth-frame=2 && remotion render Layers ../assets/demo/layers.gif --codec=gif --every-nth-frame=2 && remotion render MultiSession ../assets/demo/multi-session.gif --codec=gif --every-nth-frame=2" + "render": "remotion render StatusLeds ../assets/demo/status-leds.gif --codec=gif --every-nth-frame=2 && remotion render CommandKeys ../assets/demo/command-keys.gif --codec=gif --every-nth-frame=2 && remotion render WorkflowFlick ../assets/demo/workflow-flick.gif --codec=gif --every-nth-frame=2 && remotion render ThinkingDial ../assets/demo/thinking-dial.gif --codec=gif --every-nth-frame=2 && remotion render Layers ../assets/demo/layers.gif --codec=gif --every-nth-frame=2 && remotion render MultiSession ../assets/demo/multi-session.gif --codec=gif --every-nth-frame=2", + "render:xbox360": "remotion render Xbox360CommandKeys ../assets/demo/xbox360/command-keys.gif --codec=gif --every-nth-frame=2 && remotion render Xbox360WorkflowFlick ../assets/demo/xbox360/workflow-flick.gif --codec=gif --every-nth-frame=2 && remotion render Xbox360ThinkingDial ../assets/demo/xbox360/thinking-dial.gif --codec=gif --every-nth-frame=2 && remotion render Xbox360Layers ../assets/demo/xbox360/layers.gif --codec=gif --every-nth-frame=2 && remotion render Xbox360MultiSession ../assets/demo/xbox360/multi-session.gif --codec=gif --every-nth-frame=2" }, "dependencies": { "@remotion/cli": "^4.0.0", diff --git a/demo/src/Root.tsx b/demo/src/Root.tsx index 494c39e..153d8c2 100644 --- a/demo/src/Root.tsx +++ b/demo/src/Root.tsx @@ -8,14 +8,82 @@ import { Layers } from './scenes/Layers' import { MultiSession } from './scenes/MultiSession' const base = { width: 800, height: 450, fps: 30 } as const +const ds = { variant: 'dualsense' } as const +// No StatusLeds for the 360: OpenMicro cannot drive its Ring of Light. +const x360 = { variant: 'xbox360' } as const export const Root: React.FC = () => ( <> - - - - - + + + + + + + + + + ) diff --git a/demo/src/components/Pad.tsx b/demo/src/components/Pad.tsx new file mode 100644 index 0000000..b814881 --- /dev/null +++ b/demo/src/components/Pad.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { DualSense, LEFT_STICK, RIGHT_STICK } from './DualSense' +import { Xbox360, XBOX_LEFT_STICK, XBOX_RIGHT_STICK } from './Xbox360' + +export type Variant = 'dualsense' | 'xbox360' +export type SceneProps = { variant: Variant } + +/** On-screen names for each pad's controls, keyed by the DualSense button id. */ +export const LABELS = { + dualsense: { + cross: '✕', + circle: '○', + triangle: '△', + square: '□', + l1: 'L1', + touchpad: 'touchpad', + touchpadClick: 'touchpad click', + }, + xbox360: { + cross: 'A', + circle: 'B', + triangle: 'Y', + square: 'X', + l1: 'LB', + touchpad: 'Guide', + touchpadClick: 'Guide press', + }, +} as const + +/** Stick centers, plus where a left-stick callout fits without covering other controls. */ +export const STICKS = { + dualsense: { left: LEFT_STICK, right: RIGHT_STICK, callout: { x: 176, y: 258 } }, + xbox360: { left: XBOX_LEFT_STICK, right: XBOX_RIGHT_STICK, callout: { x: 96, y: 246 } }, +} as const + +/** Renders the variant's controller; the Xbox 360 ignores lightbar and player-LED props. */ +export const Pad: React.FC & { variant: Variant }> = ({ + variant, + ...props +}) => (variant === 'xbox360' ? : ) diff --git a/demo/src/components/SceneFrame.tsx b/demo/src/components/SceneFrame.tsx index 6b6b6ca..a6282e6 100644 --- a/demo/src/components/SceneFrame.tsx +++ b/demo/src/components/SceneFrame.tsx @@ -20,18 +20,20 @@ export const SceneFrame: React.FC<{ >
{pad} - {caption && ( -
- {caption} -
- )} + {/* fixed-height line so the pad doesn't jump when a caption appears or clears */} +
+ {caption} +
{term} diff --git a/demo/src/components/Xbox360.tsx b/demo/src/components/Xbox360.tsx new file mode 100644 index 0000000..78b79d9 --- /dev/null +++ b/demo/src/components/Xbox360.tsx @@ -0,0 +1,341 @@ +import React from 'react' +import type { ButtonId, Vec2 } from './DualSense' + +type Props = { + /** Stick deflection, each axis -1..1 (y positive = down). */ + leftStick?: Vec2 + rightStick?: Vec2 + /** Buttons to highlight (DualSense ids: cross = A, circle = B, touchpad = Guide, …). */ + highlight?: Partial> + width?: number + /** Extra SVG overlays in the pad's 480x320 coordinate space. */ + children?: React.ReactNode +} + +const HL_DEFAULT = '#4da3ff' +const SHELL_EDGE = '#9a9ea8' +// Classic 360 gem colors. +const FACE = { + cross: { letter: 'A', color: '#7fb81f' }, + circle: { letter: 'B', color: '#e0301e' }, + square: { letter: 'X', color: '#1e5fd6' }, + triangle: { letter: 'Y', color: '#f5a300' }, +} as const + +const hlColor = (v: string | boolean | undefined): string | null => + v ? (typeof v === 'string' ? v : HL_DEFAULT) : null + +// Stick centers in the 480x320 viewBox — exported for scene overlays. +// Proportions traced from the white wired 360: left stick high, right stick low. +export const XBOX_LEFT_STICK = { cx: 118, cy: 128 } +export const XBOX_RIGHT_STICK = { cx: 300, cy: 200 } +const DPAD = { cx: 178, cy: 202 } +const GUIDE = { cx: 240, cy: 126 } +const FACE_C = { cx: 366, cy: 128 } + +const FaceButton: React.FC<{ + cx: number + cy: number + hl: string | boolean | undefined + face: keyof typeof FACE +}> = ({ cx, cy, hl, face }) => { + const c = hlColor(hl) + const { letter, color } = FACE[face] + const r = 13 + return ( + + {c && } + + + + + {letter} + + + ) +} + +const Bumper: React.FC<{ + x: number + y: number + w: number + h: number + label: string + hl?: string | boolean +}> = ({ x, y, w, h, label, hl }) => { + const c = hlColor(hl) + return ( + + {c && ( + + )} + + + {label} + + + ) +} + +const Stick: React.FC<{ cx: number; cy: number; v: Vec2 }> = ({ cx, cy, v }) => { + const x = cx + v.x * 9 + const y = cy + v.y * 9 + return ( + + {/* dished well molded into the shell */} + + + {/* charcoal cap with concave top and four grip dots */} + + + + + {[ + [0, -12], + [12, 0], + [0, 12], + [-12, 0], + ].map(([dx, dy], i) => ( + + ))} + + ) +} + +/** Wired Xbox 360 Controller. The Ring of Light is drawn statically: OpenMicro has no output channel to it. */ +export const Xbox360: React.FC = ({ + leftStick = { x: 0, y: 0 }, + rightStick = { x: 0, y: 0 }, + highlight = {}, + width = 380, + children, +}) => { + const guide = hlColor(highlight.touchpad) + return ( + + + + + + + + + + + + + + + + + + + + + + + + {/* gray USB cable looping up from the top edge */} + + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + {/* triggers and bumpers, tucked behind the raised shoulders */} + + + + + + {/* shell silhouette: raised shoulders, dip at the cable, full rounded grips */} + + + + {/* gray underside band showing along the bottom valley */} + + + {/* Guide button inside the Ring of Light (lit by the pad itself, not by OpenMicro) */} + {guide && } + + {[0, 1, 2, 3].map((i) => { + const a0 = ((-90 + i * 90 + 6) * Math.PI) / 180 + const a1 = ((-90 + (i + 1) * 90 - 6) * Math.PI) / 180 + const r = 20.5 + const d = `M ${GUIDE.cx + r * Math.cos(a0)} ${GUIDE.cy + r * Math.sin(a0)} A ${r} ${r} 0 0 1 ${GUIDE.cx + r * Math.cos(a1)} ${GUIDE.cy + r * Math.sin(a1)}` + return ( + + + + + ) + })} + + + + + + {['1', '2', '3', '4'].map((n, i) => ( + + {n} + + ))} + + {/* round Back / Start buttons flanking the Guide button */} + {[ + { x: 202, label: 'BACK', pts: '198,126 205,122 205,130' }, + { x: 278, label: 'START', pts: '282,126 275,122 275,130' }, + ].map(({ x, label, pts }) => ( + + + + + {label} + + + ))} + + {/* d-pad: big gray disc with a raised cross, set in a dark ring */} + + + + + + + {/* face buttons */} + + + + + + {/* analog sticks */} + + + + {children} + + ) +} diff --git a/demo/src/scenes/CommandKeys.tsx b/demo/src/scenes/CommandKeys.tsx index ec1e55c..69ee634 100644 --- a/demo/src/scenes/CommandKeys.tsx +++ b/demo/src/scenes/CommandKeys.tsx @@ -1,25 +1,28 @@ import React from 'react' import { useCurrentFrame } from 'remotion' -import { DualSense, ButtonId } from '../components/DualSense' +import { ButtonId } from '../components/DualSense' +import { LABELS, Pad, SceneProps } from '../components/Pad' import { Terminal, Prompt, StatusLine, Cursor } from '../components/Terminal' import { SceneFrame } from '../components/SceneFrame' import { COLORS, mixColor, pulse } from '../components/timeline' const SEG = 75 // frames per button segment -const LABELS: { btn: ButtonId; glyph: string; label: string }[] = [ - { btn: 'cross', glyph: '✕', label: 'Submit ⏎' }, - { btn: 'circle', glyph: '○', label: 'Esc / interrupt' }, - { btn: 'triangle', glyph: '△', label: 'Push-to-talk' }, - { btn: 'square', glyph: '□', label: 'New chat' }, +const ACTIONS: { btn: 'cross' | 'circle' | 'triangle' | 'square'; label: string }[] = [ + { btn: 'cross', label: 'Submit ⏎' }, + { btn: 'circle', label: 'Esc / interrupt' }, + { btn: 'triangle', label: 'Push-to-talk' }, + { btn: 'square', label: 'New chat' }, ] -export const CommandKeys: React.FC = () => { +export const CommandKeys: React.FC = ({ variant }) => { const frame = useCurrentFrame() const seg = Math.min(3, Math.floor(frame / SEG)) const lf = frame - seg * SEG const pressed = lf >= 15 && lf < 55 - const { btn, glyph, label } = LABELS[seg] + const { btn, label } = ACTIONS[seg] + const glyph = LABELS[variant][btn] + const highlight: Partial> = pressed ? { [btn]: true } : {} const lightbar = seg === 0 && pressed @@ -29,13 +32,7 @@ export const CommandKeys: React.FC = () => { return ( - } + pad={} term={ {/* segment 0: submit */} @@ -74,7 +71,7 @@ export const CommandKeys: React.FC = () => { }} /> - Listening… release △ to stop + Listening… release {LABELS[variant].triangle} to stop )} diff --git a/demo/src/scenes/Layers.tsx b/demo/src/scenes/Layers.tsx index 8b00c46..71be7da 100644 --- a/demo/src/scenes/Layers.tsx +++ b/demo/src/scenes/Layers.tsx @@ -1,12 +1,13 @@ import React from 'react' import { useCurrentFrame } from 'remotion' -import { DualSense } from '../components/DualSense' +import { LABELS, Pad, SceneProps } from '../components/Pad' import { Terminal, StatusLine } from '../components/Terminal' import { SceneFrame } from '../components/SceneFrame' import { COLORS, DIM, TEXT, kf, mixColor, pulse } from '../components/timeline' -export const Layers: React.FC = () => { +export const Layers: React.FC = ({ variant }) => { const frame = useCurrentFrame() + const L = LABELS[variant] const l1Held = frame >= 30 && frame < 90 const circlePressed = frame >= 60 && frame < 85 @@ -40,9 +41,10 @@ export const Layers: React.FC = () => { return ( { {!onLayer2 && (
-
✕ accept  ○ reject  △ talk  □ new chat
-
hold L1 + ○ ⇒ Layer 2
+
+ {L.cross} accept  {L.circle} reject  {L.triangle}{' '} + talk   + {L.square} new chat +
+
+ hold {L.l1} + {L.circle} ⇒ Layer 2 +
)} {onLayer2 && (
⬢ custom bindings active
- ✕ → /compact + {L.cross} → /compact
- □ → /model + {L.square} → /model
- hold L1 + ✕ ⇒ back to Layer 1 + hold {L.l1} + {L.cross} ⇒ back to Layer 1
)} diff --git a/demo/src/scenes/MultiSession.tsx b/demo/src/scenes/MultiSession.tsx index b1add70..45f21a4 100644 --- a/demo/src/scenes/MultiSession.tsx +++ b/demo/src/scenes/MultiSession.tsx @@ -1,12 +1,13 @@ import React from 'react' import { useCurrentFrame } from 'remotion' -import { DualSense } from '../components/DualSense' +import { LABELS, Pad, SceneProps } from '../components/Pad' import { Terminal, Prompt, StatusLine } from '../components/Terminal' import { SceneFrame } from '../components/SceneFrame' import { COLORS, mixColor, pulse } from '../components/timeline' -export const MultiSession: React.FC = () => { +export const MultiSession: React.FC = ({ variant }) => { const frame = useCurrentFrame() + const L = LABELS[variant] // Timeline: pane1 focused/executing 0-90 | touchpad click 60-85 | focus->pane2 at 85, // lightbar amber | ✕ 130-155 -> pane2 approves, executing blue | touchpad click 235-255 @@ -43,13 +44,14 @@ export const MultiSession: React.FC = () => { { refactor auth module {pane2Waiting && ( - ⚠ Allow Edit(auth.ts)? — press ✕ to approve + ⚠ Allow Edit(auth.ts)? — press {L.cross} to approve )} {!pane2Waiting && ( diff --git a/demo/src/scenes/ThinkingDial.tsx b/demo/src/scenes/ThinkingDial.tsx index 398cce8..fbb8d6a 100644 --- a/demo/src/scenes/ThinkingDial.tsx +++ b/demo/src/scenes/ThinkingDial.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useCurrentFrame } from 'remotion' -import { DualSense, RIGHT_STICK } from '../components/DualSense' +import { Pad, SceneProps, STICKS } from '../components/Pad' import { Terminal, Prompt, StatusLine } from '../components/Terminal' import { SceneFrame } from '../components/SceneFrame' import { COLORS, DIM, kf } from '../components/timeline' @@ -16,7 +16,7 @@ const angleAt = (frame: number): number => [177, 0], // counter-clockwise quarter-turn back ]) -export const ThinkingDial: React.FC = () => { +export const ThinkingDial: React.FC = ({ variant }) => { const frame = useCurrentFrame() const deg = angleAt(frame) const rad = ((deg - 90) * Math.PI) / 180 @@ -41,7 +41,7 @@ export const ThinkingDial: React.FC = () => { const arcSweep = deg const arcPath = (endDeg: number): string => { const r = 39 - const { cx, cy } = RIGHT_STICK + const { cx, cy } = STICKS[variant].right const clamped = Math.max(-359.9, Math.min(359.9, endDeg)) const a0 = -90 const a1 = -90 + clamped @@ -58,7 +58,7 @@ export const ThinkingDial: React.FC = () => { + {turning && arcSweep > 2 && ( { opacity={0.9} /> )} -
+ } term={ diff --git a/demo/src/scenes/WorkflowFlick.tsx b/demo/src/scenes/WorkflowFlick.tsx index 832e3a4..0ef4566 100644 --- a/demo/src/scenes/WorkflowFlick.tsx +++ b/demo/src/scenes/WorkflowFlick.tsx @@ -1,14 +1,16 @@ import React from 'react' import { useCurrentFrame } from 'remotion' -import { DualSense } from '../components/DualSense' +import { Pad, SceneProps, STICKS } from '../components/Pad' import { Terminal, Prompt, StatusLine, Cursor, typeOn } from '../components/Terminal' import { SceneFrame } from '../components/SceneFrame' import { COLORS, kf, mixColor, pulse } from '../components/timeline' const WORKFLOW = 'Review this PR for correctness, security, and style issues.' -export const WorkflowFlick: React.FC = () => { +export const WorkflowFlick: React.FC = ({ variant }) => { const frame = useCurrentFrame() + const { cx, cy } = STICKS[variant].left + const callout = STICKS[variant].callout // Flick: up 20-30, snap back 30-40. const stickY = kf(frame, [ @@ -42,7 +44,12 @@ export const WorkflowFlick: React.FC = () => { + {/* motion streak */} {flicking && ( { ])} > { {showLabel && ( { strokeWidth={1} /> { )} - + } term={ diff --git a/src/controller/hid-manager.ts b/src/controller/hid-manager.ts index 636738b..63bff4a 100644 --- a/src/controller/hid-manager.ts +++ b/src/controller/hid-manager.ts @@ -14,9 +14,11 @@ import { GAMESIR_PIDS, GAMESIR_VID, parseGameSirReport } from './gamesir-driver. import { parseGenericReport } from './generic-driver.js' import { RawHidDriver } from './raw-hid-driver.js' import { + parseXbox360Report, parseXboxBtReport, parseXboxGipReport, parseXboxReport, + XBOX_360_PIDS, XBOX_BT_PIDS, XBOX_GIP_PIDS, XBOX_PIDS, @@ -60,6 +62,12 @@ export function createDriver(): ControllerHAL | null { if (xboxBt?.path) { return new RawHidDriver('xbox', xboxBt.path, parseXboxBtReport) } + const xbox360 = devices.find( + (d) => d.vendorId === XBOX_VID && XBOX_360_PIDS.includes(d.productId), + ) + if (xbox360?.path) { + return new RawHidDriver('xbox', xbox360.path, parseXbox360Report) + } const gamesir = devices.find( (d) => d.vendorId === GAMESIR_VID && GAMESIR_PIDS.includes(d.productId), ) diff --git a/src/controller/xbox-driver.ts b/src/controller/xbox-driver.ts index 3752928..387ec62 100644 --- a/src/controller/xbox-driver.ts +++ b/src/controller/xbox-driver.ts @@ -13,6 +13,9 @@ export const XBOX_GIP_PIDS = [0x02ea, 0x0b00] // Verified against live Xbox Wireless Controller (0x0b20) and Xbox Elite // Series 2 (0x0b13) captures. export const XBOX_BT_PIDS = [0x0b13, 0x0b20] +// Wired Xbox 360 pads (XInput report, 20 bytes). Verified against a live +// Xbox 360 Controller (0x028e) capture. +export const XBOX_360_PIDS = [0x028e] /** Normalize a signed 16-bit stick value to -1.0..1.0. */ function normalizeAxis(raw: number): number { @@ -141,6 +144,56 @@ export function parseXboxGipReport(data: Buffer): ControllerEvent[] { return events } +/** + * Parse a wired Xbox 360 input report (20 bytes). + * Byte 0: 0x00 message type, byte 1: 0x14 length, + * byte 2: dpad/start/back/stick clicks, byte 3: bumpers/guide/face buttons, + * byte 4: LT, byte 5: RT (uint8, 0-255), bytes 6-13: sticks (int16 LE). + * Layout verified against a live Xbox 360 Controller (045e:028e) capture. + */ +export function parseXbox360Report(data: Buffer): ControllerEvent[] { + const events: ControllerEvent[] = [] + if (data.length < 14 || data[0] !== 0x00 || data[1] !== 0x14) return events + + const b2 = data[2]! + const b3 = data[3]! + const map: Array<[number, number, ButtonId]> = [ + [b2, 0x01, 'dpad_up'], + [b2, 0x02, 'dpad_down'], + [b2, 0x04, 'dpad_left'], + [b2, 0x08, 'dpad_right'], + [b2, 0x10, 'menu'], + [b2, 0x20, 'view'], + [b2, 0x40, 'l3'], + [b2, 0x80, 'r3'], + [b3, 0x01, 'l1'], + [b3, 0x02, 'r1'], + // Guide button maps to touchpad like the other pads' home buttons. + [b3, 0x04, 'touchpad'], + [b3, 0x10, 'south'], + [b3, 0x20, 'east'], + [b3, 0x40, 'west'], + [b3, 0x80, 'north'], + ] + for (const [byte, bit, id] of map) { + events.push({ kind: 'button', button: id, pressed: (byte & bit) !== 0 }) + } + + const lt = data[4]! / 255 + const rt = data[5]! / 255 + events.push({ kind: 'axis', axis: 'l2', value: lt }) + events.push({ kind: 'axis', axis: 'r2', value: rt }) + events.push({ kind: 'button', button: 'l2', pressed: lt > 0.25 }) + events.push({ kind: 'button', button: 'r2', pressed: rt > 0.25 }) + + events.push({ kind: 'axis', axis: 'left_x', value: normalizeAxis(data.readInt16LE(6)) }) + events.push({ kind: 'axis', axis: 'left_y', value: normalizeAxis(data.readInt16LE(8)) }) + events.push({ kind: 'axis', axis: 'right_x', value: normalizeAxis(data.readInt16LE(10)) }) + events.push({ kind: 'axis', axis: 'right_y', value: normalizeAxis(data.readInt16LE(12)) }) + + return events +} + export function parseXboxReport(data: Buffer): ControllerEvent[] { const events: ControllerEvent[] = [] if (data.length < 16) return events diff --git a/src/doctor.ts b/src/doctor.ts index a8e8dd7..7c6e4fd 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -22,7 +22,13 @@ import { createDriver, DUALSENSE_PIDS, DUALSENSE_VID } from './controller/hid-ma import type { ControllerHAL } from './controller/hal.js' import { DS4_PIDS, DS4_VID } from './controller/ds4-driver.js' import { GAMESIR_PIDS, GAMESIR_VID } from './controller/gamesir-driver.js' -import { XBOX_BT_PIDS, XBOX_GIP_PIDS, XBOX_PIDS, XBOX_VID } from './controller/xbox-driver.js' +import { + XBOX_360_PIDS, + XBOX_BT_PIDS, + XBOX_GIP_PIDS, + XBOX_PIDS, + XBOX_VID, +} from './controller/xbox-driver.js' import type { AxisId, ButtonId, ControllerEvent, ControllerType } from './types.js' const PROMPT_TIMEOUT_MS = 30_000 @@ -136,7 +142,7 @@ function findDevice(type: ControllerType): Device | undefined { return all.find( (d) => d.vendorId === XBOX_VID && - [...XBOX_PIDS, ...XBOX_GIP_PIDS, ...XBOX_BT_PIDS].includes(d.productId), + [...XBOX_PIDS, ...XBOX_GIP_PIDS, ...XBOX_BT_PIDS, ...XBOX_360_PIDS].includes(d.productId), ) } if (type === 'gamesir') { diff --git a/src/harness/claude.ts b/src/harness/claude.ts index 66ce2a5..c076a3d 100644 --- a/src/harness/claude.ts +++ b/src/harness/claude.ts @@ -63,7 +63,7 @@ export const claudeHarness: Harness = { case 'reject': return { bytes: '\x1b' } // Esc interrupts Claude / closes a dialog case 'push_to_talk': - return { bytes: ' ' } // Space = hold-to-dictate (docs "Voice input", requires voice dictation enabled) + return { bytes: ' ' } // Space toggles dictation; needs /voice with voice.mode "tap" (one byte can't simulate a held key) case 'new_chat': return { bytes: '/clear\r' } // /clear starts a new conversation with empty context case 'thinking_depth': { diff --git a/test/drivers.test.ts b/test/drivers.test.ts index b9393ad..a11c65b 100644 --- a/test/drivers.test.ts +++ b/test/drivers.test.ts @@ -4,6 +4,7 @@ import { parseDs4Report } from '../src/controller/ds4-driver.js' import { parseGameSirReport } from '../src/controller/gamesir-driver.js' import { normalizeGenericAxis, parseGenericReport } from '../src/controller/generic-driver.js' import { + parseXbox360Report, parseXboxBtReport, parseXboxGipReport, parseXboxReport, @@ -177,6 +178,69 @@ describe('parseXboxGipReport', () => { }) }) +describe('parseXbox360Report', () => { + // Real frames captured from a wired Xbox 360 Controller (045e:028e) on macOS. + const x360 = (hex: string): Buffer => Buffer.from(hex, 'hex') + + it('parses face buttons, bumpers and guide from byte 3', () => { + const cases: Array<[string, string]> = [ + ['001400100000d8f9c2f771094509000000000000', 'south'], + ['001400200000d8f931f871094509000000000000', 'east'], + ['001400400000d8f931f871094509000000000000', 'west'], + ['001400800000d8f931f871094509000000000000', 'north'], + ['0014000100006cf931f871094509000000000000', 'l1'], + ['001400020000d8f9c2f771094509000000000000', 'r1'], + ['0014000400004cf7b3feb60ebd05000000000000', 'touchpad'], + ] + for (const [hex, id] of cases) { + const b = buttons(parseXbox360Report(x360(hex))) + expect(b.get(id), id).toBe(true) + expect([...b.values()].filter(Boolean), id).toHaveLength(1) + } + }) + + it('parses dpad, menu/view and stick clicks from byte 2', () => { + const cases: Array<[string, string]> = [ + ['0014010000001efba9fb6c14bd05000000000000', 'dpad_up'], + ['0014020000001efb18fc460ebd05000000000000', 'dpad_down'], + ['0014040000001efba9fb460ebd05000000000000', 'dpad_left'], + ['0014080000001efb18fcb60e4c05000000000000', 'dpad_right'], + ['001410000000d8f9c2f77109d408000000000000', 'menu'], + ['001420000000d8f9c2f771094509000000000000', 'view'], + ['001440000000bb0518fc970f6308000000000000', 'l3'], + ['001480000000a8ef6bf3fa01270a000000000000', 'r3'], + ] + for (const [hex, id] of cases) { + expect(buttons(parseXbox360Report(x360(hex))).get(id), id).toBe(true) + } + }) + + it('parses triggers as uint8 at bytes 4-5', () => { + const left = parseXbox360Report(x360('00140000ff004cf7b3fe460ebd05000000000000')) + expect(axes(left).get('l2')).toBe(1) + expect(buttons(left).get('l2')).toBe(true) + expect(buttons(left).get('r2')).toBe(false) + const right = parseXbox360Report(x360('0014000000ff4cf722ffb60ebd05000000000000')) + expect(axes(right).get('r2')).toBe(1) + expect(buttons(right).get('r2')).toBe(true) + }) + + it('parses sticks as int16 LE at bytes 6-13', () => { + const data = x360('001400000000d8f9c2f771094509000000000000') + data.writeInt16LE(-32768, 6) + data.writeInt16LE(32767, 12) + const a = axes(parseXbox360Report(data)) + expect(a.get('left_x')).toBe(-1) + expect(a.get('right_y')).toBe(1) + }) + + it('ignores short frames and other message types', () => { + expect(parseXbox360Report(Buffer.alloc(8))).toEqual([]) + expect(parseXbox360Report(x360('010300'))).toEqual([]) + expect(parseXbox360Report(x360('0814000000000000000000000000000000000000'))).toEqual([]) + }) +}) + describe('parseXboxBtReport', () => { // Real frames captured from an Xbox Wireless Controller (045e:0b20) over Bluetooth. const bt = (hex: string): Buffer => Buffer.from(hex, 'hex') diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 2b873c1..d9e801c 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -15,9 +15,11 @@ import { parseDs4Report } from '../src/controller/ds4-driver.js' import { parseGameSirReport } from '../src/controller/gamesir-driver.js' import { parseGenericReport } from '../src/controller/generic-driver.js' import { + parseXbox360Report, parseXboxBtReport, parseXboxGipReport, parseXboxReport, + XBOX_360_PIDS, XBOX_BT_PIDS, XBOX_GIP_PIDS, } from '../src/controller/xbox-driver.js' @@ -68,13 +70,15 @@ describe('controller fixtures replay through their parse function', () => { for (const fixture of fixtures) { const { driver, product, pid } = fixture.controller - // Wired GIP and Bluetooth pads share the 'xbox' driver name but use their own parsers. + // Wired GIP, wired 360 and Bluetooth pads share the 'xbox' driver name but use their own parsers. const parse = driver === 'xbox' && XBOX_GIP_PIDS.includes(Number(pid)) ? parseXboxGipReport : driver === 'xbox' && XBOX_BT_PIDS.includes(Number(pid)) ? parseXboxBtReport - : PARSERS[driver] + : driver === 'xbox' && XBOX_360_PIDS.includes(Number(pid)) + ? parseXbox360Report + : PARSERS[driver] // dualsense (parser lives in dualsense-ts) and capture-only 'none' have no // replayable parse function. if (!parse) continue diff --git a/test/fixtures/controllers/045e-028e-usb.json b/test/fixtures/controllers/045e-028e-usb.json new file mode 100644 index 0000000..d614c7d --- /dev/null +++ b/test/fixtures/controllers/045e-028e-usb.json @@ -0,0 +1,159 @@ +{ + "schemaVersion": 1, + "openmicroVersion": "1.4.3", + "platform": "darwin", + "osVersion": "Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:20 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T8142", + "controller": { + "vid": "0x045e", + "pid": "0x028e", + "product": "Controller", + "makeModel": "Microsoft Xbox 360 Controller", + "transport": "usb", + "driver": "xbox" + }, + "results": { + "south": { + "status": "pass" + }, + "east": { + "status": "pass", + "capture": { + "pressed": "001400200000aafd4d0107fe3dfe000000000000", + "idle": "0014001000003efd4d0107fe3dfe000000000000" + } + }, + "west": { + "status": "pass", + "capture": { + "pressed": "001400400000aafdbc0107feccfd000000000000", + "idle": "001400200000aafd4d0107fe3dfe000000000000" + } + }, + "north": { + "status": "pass", + "capture": { + "pressed": "001400800000aafdde0077fe3dfe000000000000", + "idle": "001400400000aafdbc0107feccfd000000000000" + } + }, + "dpad_up": { + "status": "pass", + "capture": { + "pressed": "00140100000045fade0077fe3dfe000000000000", + "idle": "001400800000aafdde0077fe3dfe000000000000" + } + }, + "dpad_down": { + "status": "pass", + "capture": { + "pressed": "001402000000d8f9de0007fe3dfe000000000000", + "idle": "00140100000045fade0077fe3dfe000000000000" + } + }, + "dpad_left": { + "status": "pass", + "capture": { + "pressed": "001404000000d8f9de0007fe3dfe000000000000", + "idle": "001402000000d8f9de0007fe3dfe000000000000" + } + }, + "dpad_right": { + "status": "pass", + "capture": { + "pressed": "001408000000d8f9de0007fe3dfe000000000000", + "idle": "001404000000d8f9de0007fe3dfe000000000000" + } + }, + "l1": { + "status": "pass", + "capture": { + "pressed": "00140001000045fade0007fe3dfe000000000000", + "idle": "001408000000d8f9de0007fe3dfe000000000000" + } + }, + "r1": { + "status": "pass", + "capture": { + "pressed": "001400020000d8f9de0007fe3dfe000000000000", + "idle": "00140001000045fade0007fe3dfe000000000000" + } + }, + "l2": { + "status": "pass", + "capture": { + "pressed": "00140000950081f00000b6fc44fa000000000000", + "idle": "001400020000d8f9de0007fe3dfe000000000000" + } + }, + "r2": { + "status": "pass", + "capture": { + "pressed": "00140000004781f00000b6fcd3f9000000000000", + "idle": "00140000950081f00000b6fc44fa000000000000" + } + }, + "l3": { + "status": "pass", + "capture": { + "pressed": "00144000000017fe31f8970fd408000000000000", + "idle": "00140000004781f00000b6fcd3f9000000000000" + } + }, + "r3": { + "status": "pass", + "capture": { + "pressed": "0014800000009c03cbfa0109db04000000000000", + "idle": "00144000000017fe31f8970fd408000000000000" + } + }, + "menu": { + "status": "pass", + "capture": { + "pressed": "0014100000009c03cbfa5911ccfd000000000000", + "idle": "0014800000009c03cbfa0109db04000000000000" + } + }, + "view": { + "status": "pass", + "capture": { + "pressed": "0014200000009c03cbfae810ccfd000000000000", + "idle": "0014100000009c03cbfa5911ccfd000000000000" + } + }, + "touchpad": { + "status": "pass", + "capture": { + "pressed": "0014000400009c03cbfa5911ccfd000000000000", + "idle": "0014200000009c03cbfae810ccfd000000000000" + } + } + }, + "axes": { + "left_x": { + "min": -1, + "max": 0.9867244483779413 + }, + "left_y": { + "min": -1, + "max": 1 + }, + "right_x": { + "min": -1, + "max": 1 + }, + "right_y": { + "min": -0.996490478515625, + "max": 1 + }, + "l2": { + "min": 0, + "max": 0 + }, + "r2": { + "min": 0, + "max": 0 + } + }, + "output": "unsupported", + "timestamp": "2026-10-10T14:05:17.964Z" +}