ps-access 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md ADDED
@@ -0,0 +1,210 @@
1
+ # PlayStation Access Controller — profile protocol
2
+
3
+ Protocol for reading and writing the Access Controller's on-device profiles over **USB-C**, with
4
+ **no PS5**. Verified end-to-end against real hardware (read, write, round-trip, restore) on macOS
5
+ via both node-hid and WebHID.
6
+
7
+ Credit: this work builds on the prior art of Jacek Fedoryński's client-side web editor
8
+ (<https://www.jfedor.org/ps-access/>), which first made PC-side profile editing possible. The
9
+ byte offsets and behaviour documented here were confirmed against a live device, and this note
10
+ records where this implementation differs (it preserves the UUID/timestamp/stick-tuning bytes on
11
+ round-trip).
12
+
13
+ ## Device identity
14
+
15
+ | | value |
16
+ |---|---|
17
+ | Vendor ID | `0x054C` (Sony Interactive Entertainment) |
18
+ | Product ID | `0x0E5F` |
19
+ | USB usage | Generic Desktop (`0x01`) / Game Pad (`0x05`) |
20
+ | Product string | `Access Controller` |
21
+
22
+ Note `0x0E5F` is absent from public USB-ID databases.
23
+
24
+ ## Transport: feature reports
25
+
26
+ Two HID **feature reports** carry profile data, and are present **only over USB**:
27
+
28
+ - `0x60` (96) — host → device command/data channel
29
+ - `0x61` (97) — device → host data/status channel
30
+
31
+ Each `0x60` packet is **63 payload bytes** (after the report id). Each `0x61` response is the
32
+ full report with the **report id at byte 0**.
33
+
34
+ **USB vs Bluetooth detection:** over USB the feature-report set includes `0x60` and `0x61`
35
+ and does **not** include report `99` (`0x63`). Over Bluetooth, report `99` is present and the
36
+ profile channel is not usable. So: *USB-ready ⇔ has `0x60` & `0x61` and not `99`.*
37
+
38
+ A profile blob is **956 bytes** (`PROFILE_DATA_SIZE`). There are **3 profile slots** (1–3).
39
+
40
+ ### Reading profile N (1–3)
41
+
42
+ 1. Send feature report `0x60` with a 63-byte buffer where `buf[0] = 0x10 + (N-1)` (rest 0).
43
+ 2. Read feature report `0x61` **18 times**. In each response, payload bytes are at **offset 4**
44
+ (`[0]=0x61, [1]=cmd echo, [2]=remaining-count, [3]=?, [4..59]=56 payload bytes`).
45
+ 3. Concatenate `18 × 56 = 1008` bytes and keep the first **956**. Byte 0 must be `0x02`.
46
+
47
+ ### Writing profile N (1–3)
48
+
49
+ 1. Build 18 packets. Packet `i` (0–17), 63 bytes:
50
+ - `buf[0] = 0x08 + N`
51
+ - `buf[1] = i`
52
+ - `buf[2 + j] = profile[i*56 + j]` for `j` in 0..55 while `i*56+j < 956`
53
+ 2. On the **final** packet (`i == 17`), write `crc32(profile, 956)` as **little-endian u32** at
54
+ **offset 6** (the real profile data only occupies offsets 2–5 of the last packet, so there
55
+ is no overlap).
56
+ 3. Send all 18 packets via feature report `0x60`, in order.
57
+ 4. **Drain status:** read feature report `0x61` until byte `[2]` (remaining-count) is `0`
58
+ (cap the loop). Skipping this desyncs the next read command.
59
+
60
+ ### Setting the active profile (1–3)
61
+
62
+ Switch which profile the controller has **active** — the same thing its on-device profile
63
+ button does — with a single command:
64
+
65
+ 1. Send feature report `0x60` with a 63-byte buffer where `buf[0] = 0x05`, `buf[1] = N` (rest 0).
66
+
67
+ No `0x61` response needs draining. The switch is immediate: input-report `byte 39` (see below)
68
+ updates to `N` within a frame, and the controller applies that profile's mapping. Verified by
69
+ sending `0x05 0x01/0x02/0x03` and watching `byte 39` track `1/2/3`; the stored slot contents are
70
+ untouched. Reverse-engineered by probing command opcodes while watching `byte 39` — opcode `0x05`
71
+ with `byte[1] = 0` set `byte 39` to `0` (no/default profile), which pinned the encoding. Implemented
72
+ as `buildSetActiveCommand(N)`.
73
+
74
+ ### CRC
75
+
76
+ Standard zlib/IEEE CRC-32 (poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF`),
77
+ computed over the **956 profile bytes** and stored little-endian. Implemented in
78
+ `lib/access-protocol.mjs` (`crc32`).
79
+
80
+ ## Profile blob layout (956 bytes)
81
+
82
+ | offset | size | field |
83
+ |---|---|---|
84
+ | 0 | 1 | sentinel, always `0x02` |
85
+ | 4 | 2×40 | profile name, UTF-16LE, NUL-terminated, ≤40 chars |
86
+ | 84 | 16 | UUID (random; PS5/editor regenerate it each save) |
87
+ | 100 | 10×5 | button table — see below |
88
+ | 150 | 2 | toggle bitfield (u16 LE) — see below |
89
+ | 152 | 5×45 | expansion-port records — see below |
90
+ | 948 | 8 | timestamp, i64 LE (`Date.now()` ms) |
91
+
92
+ ### Buttons (offset 100, 10 entries × 5 bytes)
93
+
94
+ Entry `b` (0–9) at `100 + b*5`:
95
+
96
+ - byte 0 — primary action (`map1`)
97
+ - byte 1 — secondary action (`map2`, for combos)
98
+ - bytes 2–4 — unused/observed 0
99
+
100
+ Action codes:
101
+
102
+ ```
103
+ 0 nothing 1 circle 2 cross 3 triangle 4 square
104
+ 5 up 6 down 7 left 8 right
105
+ 9 L1 10 R1 11 L2 12 R2 13 L3 14 R3
106
+ 15 options 16 create 17 PS 18 touchpad
107
+ ```
108
+
109
+ ### Toggle bitfield (offset 150, u16 LE)
110
+
111
+ - bit `b` (0–9) → toggle enabled for button `b+1`
112
+ - bit `9 + p` → toggle enabled for expansion port `p` configured as a button
113
+ (the built-in stick is port 0 and is normally a stick, so its bit is not used as a toggle)
114
+
115
+ ### Expansion ports (offset 152, 5 entries × 45 bytes)
116
+
117
+ Port 0 = built-in stick; ports 1–4 = the four 3.5mm expansion ports. Entry `p` at
118
+ `152 + p*45`, byte 0 = **type**:
119
+
120
+ - `0x00` — disabled / nothing
121
+ - `0x01` — **stick**
122
+ - byte 1: stick assignment (`1` = left stick, `2` = right stick)
123
+ - byte 2: orientation (`0` below, `1` right, `2` above, `3` left) — only the built-in
124
+ stick is affected, but every stick port carries the same value
125
+ - byte 5: sensitivity; bytes 8–13: deadzone/curve (3 pairs, X/Y).
126
+ **`0` means "firmware default"** — observed on a live, never-PS5-tuned device, all these
127
+ bytes are `0`. The PS5 only writes non-zero when you change them from default; jfedor's
128
+ editor writes `sensitivity=3` and deadzone `80 80 c4 c4 e1 e1` as its "default" preset.
129
+ The exact value→behaviour mapping is **not publicly documented** (set them experimentally).
130
+ - `0x02` — **analog** button; `0x03` — **digital** button
131
+ - byte 2: primary action (`map1`); byte 3: secondary action (`map2`)
132
+
133
+ A port’s stick assignment is encoded in the UI as `100 + code` (101 = left stick,
134
+ 102 = right stick) to share one dropdown with the button actions.
135
+
136
+ ## Input report — live button & stick state (report id `0x01`)
137
+
138
+ The controller streams a DualSense-style **input report** (report id `1`, ~250 Hz, 64 bytes
139
+ incl. report id / 63 bytes of data). The left stick is at the front and a sequence counter ticks
140
+ at byte 6; the last 8 bytes (55–62) are a volatile per-frame trailer (see *Unexposed* below).
141
+ Reverse-engineered with `web/hid-capture.html` (connect → idle baseline → press one button at a
142
+ time, watch which bits flip).
143
+
144
+ **Key finding:** the report exposes **raw physical button state — independent of the profile
145
+ remapping.** Pressing a button lights a fixed physical bit even when that button is mapped to
146
+ *Not assigned*. (The standard DualSense face/shoulder bits near bytes 7–8 instead carry the
147
+ *mapped action*, so two buttons mapped to the same action are indistinguishable there.)
148
+
149
+ Byte offsets below are in **`event.data` coordinates** (WebHID `inputreport`, which **excludes**
150
+ the report id). For `node-hid`, whose buffer includes the id at `[0]`, add 1.
151
+
152
+ | data byte | bits | meaning |
153
+ |---|---|---|
154
+ | 0–1 | — | left stick X / Y (≈`0x80` centered; byte 0 jitters) |
155
+ | 6 | — | sequence counter (increments once per report) |
156
+ | ~7–8 | — | standard mapped-action bits (reflect the *mapped* action, not the physical button) |
157
+ | **15** | **0–7** | **the 8 perimeter buttons** — one bit each (physical) |
158
+ | **16** | **0, 1, 3** | **center button** (bit 0), **stick-click** (bit 1), **profile-switch button** (bit 3, `0x08`) |
159
+ | **39** | — | **active on-device profile** — `1`, `2`, or `3` (which profile the controller has selected) |
160
+
161
+ Pinned via an ordered capture: **perimeter button _n_ (1–8) → `byte 15` bit _(n−1)_**
162
+ (bit 0 = button 1 … bit 7 = button 8); **`byte 16` bit 0 = center button**; **`byte 16` bit 1 =
163
+ stick-click**. So all 10 physical inputs are readable directly from `byte 15` (bits 0–7) and
164
+ `byte 16` (bits 0–1).
165
+
166
+ **Active profile.** `byte 39` holds the profile the controller currently has active (`1`–`3`),
167
+ and `byte 16` bit 3 (`0x08`) pulses while the on-device **profile-switch button** is held. This
168
+ lets a PC-side tool show, and react to, which profile the user has selected on the controller —
169
+ without a PS5. Found by capturing the report while cycling the profile button: `byte 39` stepped
170
+ `3 → 1 → 2 → 3` in lockstep with each press, while all other low-cardinality bytes stayed put.
171
+
172
+ This enables physical-button features regardless of mapping — e.g. navigating a UI with the
173
+ controller (perimeter = back, center/stick-click = confirm) and lighting only the button
174
+ actually pressed. Note: WebHID `inputreport` and the browser **Gamepad API** can't both read the
175
+ device reliably at once, and the Gamepad API only sees mapped actions — so physical-button work
176
+ must go through the raw input report.
177
+
178
+ ### What the controller does *not* expose (verified)
179
+
180
+ A round of probing (capture + correlation against the DualSense layout) ruled these out, which is
181
+ useful to know before chasing them:
182
+
183
+ - **No analog/pressure on the buttons.** The 8 perimeter buttons, center, and stick-click are
184
+ **digital only**. Holding a button harder changes nothing — the only bytes that react are the
185
+ physical bits (`byte 15`/`byte 16`) and the mapped-action bits (`bytes 7–8`), and all of those
186
+ are flat on/off. (The profile's "analog" button type `0x02` is for **expansion-port** inputs —
187
+ analog sticks/triggers in the 3.5 mm AUX jacks — not the built-in buttons.)
188
+ - **No motion/IMU and no battery.** The gyro/accel region a DualSense fills with sensor noise is
189
+ constant/zero here (the device is meant to sit flat), and the DualSense battery byte reads `0`
190
+ (USB-powered, no battery to report).
191
+ - **Bytes 55–62 are an opaque 8-byte trailer.** They randomize every frame even when the rest of
192
+ the payload is static, the values are not a forward timestamp, and they do **not** match a
193
+ standard DualSense CRC-32 (seed `0xA1` over the payload). Most likely a device timestamp and/or
194
+ signature over hidden internal state — not a decodable field.
195
+ - **The profile light is firmware-controlled.** The device accepts output report `0x02` (31 data
196
+ bytes) without error but ignores it for lighting — the profile-button glow and its idle "wave"
197
+ animation are driven by firmware, not host-settable. (The standard DualSense player-LED/lightbar
198
+ bytes sit at offsets ~43–46, beyond this device's 31-byte output report, so the usual path
199
+ doesn't apply.)
200
+ - **The profile blob has no hidden fields.** Every non-zero byte in the 956-byte profile falls
201
+ inside a documented field; the rest is reserved/zero.
202
+
203
+ ## Implementation notes
204
+
205
+ - `buildProfile()` starts from the previously-read raw bytes when available, so fields this
206
+ tool doesn’t model (UUID, stick sensitivity/deadzone) survive a round trip. jfedor’s editor
207
+ instead rebuilds from scratch, randomizing the UUID and resetting stick tuning to defaults.
208
+ - A naive read **immediately after** a write returns zeros — always drain `0x61` first.
209
+ - `node-hid` `getFeatureReport(id, len)` and WebHID `receiveFeatureReport(id)` both return the
210
+ report id at byte 0, so the payload offset (4 on read) is identical across platforms.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # ps-access
2
+
3
+ Read and write **PlayStation Access Controller** profiles from a PC over **USB-C — no PS5
4
+ required**. Includes a command-line tool and a multi-controller browser (WebHID) configurator,
5
+ plus a documented protocol.
6
+
7
+ The Access Controller normally can only be customized by plugging it into a PS5. This project
8
+ talks the same on-device profile protocol directly, so you can read, edit, back up, restore,
9
+ and clone the 3 on-device profiles (button remapping, the built-in stick, expansion ports)
10
+ yourself.
11
+
12
+ > Verified end-to-end against real hardware (read / write / round-trip / restore) on macOS.
13
+ > See [PROTOCOL.md](PROTOCOL.md) for the protocol. This project builds on the prior art of
14
+ > Jacek Fedoryński’s web editor (<https://www.jfedor.org/ps-access/>) — credit and thanks to him
15
+ > for first making PC-side profile editing possible.
16
+
17
+ ## Requirements
18
+
19
+ - The controller connected by **USB-C** (the profile channel isn’t available over Bluetooth).
20
+ - CLI: Node.js (tested on v26) with `node-hid` (`npm install`).
21
+ - Web tool: Chrome or Edge (desktop) for WebHID.
22
+ - macOS may prompt for **Input Monitoring** permission for the terminal/Chrome on first use.
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ npm install
28
+
29
+ node cli.mjs list # list connected controllers
30
+ node cli.mjs dump # decode all 3 profiles
31
+ node cli.mjs backup # save all 3 profiles to captures/
32
+ node cli.mjs read-profile 1 --json # decode one profile as JSON
33
+ node cli.mjs set-active 2 # switch the active profile (like the profile button)
34
+ node cli.mjs set 1 button5=triangle # remap button 5
35
+ node cli.mjs set 1 port1=cross # expansion port 1 -> cross
36
+ node cli.mjs set 1 "port0=left stick" # built-in stick assignment
37
+ node cli.mjs set 1 orientation="stick on the right"
38
+ node cli.mjs write-profile 2 captures/backup-....json
39
+ node cli.mjs restore captures/backup-....json
40
+ node cli.mjs apply profile.json 1 # apply a web-app export / share code / URL / preset id to a slot
41
+ ```
42
+
43
+ `apply` is the bridge between the web tool and the CLI: feed it a **profile JSON exported from the
44
+ web Library**, a **share link/code**, or a built-in **preset id** (`ps-access presets`), and it
45
+ writes that mapping to a slot on the controller (reading the current profile first so uuid and
46
+ unmodeled fields survive, then round-trip verifying).
47
+
48
+ - `--device <index|path>` targets a specific controller when several are connected.
49
+ - **Every write auto-backs-up first** to `captures/` and round-trip re-reads to verify.
50
+
51
+ ## Web tool (multiple controllers)
52
+
53
+ ```bash
54
+ npm start # serves the project at http://localhost:3000
55
+ ```
56
+
57
+ Open **<http://localhost:3000/web/>** in Chrome/Edge.
58
+
59
+ ### XMB view (`index.html`)
60
+
61
+ A full-screen XrossMediaBar-style interface: a horizontal ribbon of blades
62
+ (`Controllers · Profile 1 · 2 · 3 · Save · Library · Key Bridge · Monitor`, each profile rendered
63
+ as a live mini-controller), a vertical item list, and an enlarged "hero" render when you drill in.
64
+ Edit button/stick/port mappings with horizontal value spinners, then save to the controller.
65
+
66
+ The **Library** blade is for **sharing and presets** (all client-side, no account): apply a
67
+ curated starting-point preset (one-handed, toggle-triggers, external-switch D-pad…), **export**
68
+ a profile to a JSON file, **import** a file or a CLI backup, or **copy a share link** — a URL
69
+ whose `#p=…` hash encodes the profile, auto-detected when someone opens it. After applying or
70
+ importing, use **Save** to write it to the controller.
71
+
72
+ The **Key Bridge** blade is a visual editor for the PC input bridge: assign **any keyboard key
73
+ or chord** to each physical button and the stick by selecting a row, pressing Enter, then pressing
74
+ the key you want (press the physical button to find its row — it lights up live). Pick a stick
75
+ mode (keys / mouse / gamepad axis), then **Export** a `bridge.json` or **copy the run command**.
76
+ The browser can author and preview the mapping, but it can't inject input into other apps — you
77
+ run the exported config with the local bridge (`node bridge.mjs --config bridge.json`), which is
78
+ what actually drives the PC. (Macros — multi-step sequences — are edited in the exported JSON.)
79
+
80
+ **Accessibility:** the configurator is built to be used by the same people the controller is for.
81
+ Every section/option/value change is announced to screen readers (a polite live region), it's
82
+ fully keyboard- and controller-operable, **?** opens a controls reference, and it honors
83
+ `prefers-reduced-motion` (no animated wave), `prefers-contrast`, and Windows High Contrast /
84
+ `forced-colors`. A high-visibility focus ring on the selected item is available as an **opt-in
85
+ toggle in Help (off by default**, so it doesn't fight the XMB look); OS high-contrast / forced-colors
86
+ modes turn it on automatically.
87
+
88
+ It's driven by the controller's **raw HID input report**, so it reads *physical* buttons
89
+ regardless of remapping: tilt the **stick** to navigate, **center / stick-click = confirm**,
90
+ **any perimeter button = back**, and pressing any physical button lights it up on every render.
91
+ Keyboard works too (arrows / Enter / Backspace).
92
+
93
+ Unplugging and replugging the controller **reconnects automatically** (no refresh). The
94
+ **Controllers** blade also has a persistent **+ Connect a controller…** action (activate it with
95
+ Enter or a click) to grant or reconnect a controller on demand.
96
+
97
+ Under the controller name in the top bar, the **active on-device profile** is shown live (e.g.
98
+ `Profile 3 · stick on the right`) — it reflects whichever profile is selected on the controller
99
+ itself and **updates the moment you press the device's profile button**, independent of the UI
100
+ cursor (decoded from input-report `byte 39`; see [PROTOCOL.md](PROTOCOL.md)). You can also **switch
101
+ it from the app**: each Profile blade has a **Set active on controller** item (the active one is
102
+ marked `✓`), doing the same thing as the device's profile button — `set-active` on the CLI. The ambient
103
+ background wave echoes it too: its three curves fade their leading lines as the active profile
104
+ climbs (1 → all solid, 2 → first faded, 3 → first two faded), and fade out entirely when no
105
+ controller is connected.
106
+
107
+ The **Monitor** blade opens a full-screen live input view (big controller render + physical-button
108
+ chips + stick crosshair + the raw input report with the physical-button bytes highlighted). Because
109
+ the controller is purely *observed* here — navigation is suspended so every button and the stick can
110
+ be tested freely — opening it first shows a **confirm gate** warning that you'll need the **keyboard
111
+ (Esc)** or the **Done** button to leave (the controller can't exit on its own). The render follows
112
+ the **active on-device profile**, matching its **orientation**, and re-renders if you switch
113
+ profiles on the controller while watching. (Also available as a standalone page, `monitor.html`.)
114
+
115
+ ### Diagnostics (`hid-capture.html`)
116
+
117
+ A developer tool that shows the live input report and logs which bits flip on each press —
118
+ used to reverse-engineer the physical-button layout (see PROTOCOL.md).
119
+
120
+ ## PC input bridge (use the controller on any PC)
121
+
122
+ Beyond editing PS5 profiles, you can use the Access Controller as a **general PC input device** —
123
+ its stick and buttons driving keyboard/mouse or a virtual gamepad, so it controls *any* software,
124
+ not just a PS5. The bridge reads the controller's live USB input and maps it through a small,
125
+ platform-agnostic engine (`web/bridge-core.mjs`) to a pluggable output **sink**.
126
+
127
+ ```bash
128
+ node bridge.mjs --sink dry-run # print mapped events, inject nothing (try it first)
129
+ node bridge.mjs --sink xdotool # stick -> arrow keys, buttons -> keys (X11; needs xdotool)
130
+ node bridge.mjs --sink uinput # virtual gamepad/keyboard via /dev/uinput (Linux)
131
+ node bridge.mjs --config my-map.json # custom mapping (see DEFAULT_MAPPING in bridge-core)
132
+ node bridge.mjs --simulate frames.json --sink dry-run # replay recorded frames, no hardware
133
+ ```
134
+
135
+ - **xdotool** sink (X11): no native deps; set `--display :0` if `$DISPLAY` isn't set.
136
+ - **uinput** sink (Linux, lowest latency): a stdlib-only Python helper creates the virtual device.
137
+ It needs access to `/dev/uinput` — run as root, or add a udev rule, e.g.:
138
+ ```
139
+ # /etc/udev/rules.d/99-uinput.rules
140
+ KERNEL=="uinput", GROUP="input", MODE="0660" # then: add your user to the `input` group
141
+ ```
142
+ - Mapping config example (`my-map.json`):
143
+ ```json
144
+ { "buttons": {
145
+ "8": "space",
146
+ "0": "mouse1",
147
+ "1": "ctrl+s",
148
+ "2": ["ctrl+c", "ctrl+v"]
149
+ },
150
+ "stick": { "mode": "mouse" }, "mouse": { "speed": 22 } }
151
+ ```
152
+ `stick.mode` is `keys` (arrows/WASD), `mouse` (relative pointer), or `axis` (gamepad).
153
+ - A button value can be:
154
+ - a single key — **held** while the button is held (`"space"`, `"a"`, `"mouse1"`);
155
+ - a **chord** — `"ctrl+s"` — fired once on press (modifiers held around the key);
156
+ - a **macro** — `["ctrl+c", "ctrl+v"]` or `["g", "i"]` — a sequence fired once on press.
157
+
158
+ Chords and macros make a single accessible switch trigger a complex action that would
159
+ otherwise need several simultaneous or sequential presses.
160
+
161
+ ### Building a mapping
162
+
163
+ Author the config visually in the web **Key Bridge** blade, or from the terminal:
164
+
165
+ ```bash
166
+ node bridge.mjs edit # interactive press-to-bind editor (TTY)
167
+ node bridge.mjs edit --config my-map.json --out my-map.json
168
+ node bridge.mjs set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
169
+ node bridge.mjs show --config my-map.json # print the resolved config
170
+ ```
171
+
172
+ - **edit** is the CLI twin of the web editor: ↑/↓ to select a button/stick row, **Enter** to
173
+ bind (then press the key you want), **Del** to clear, **s** to save, **q** to quit.
174
+ - **set** targets: `0`..`9` (buttons), `stick.mode`, `stick.up/down/left/right`, `mouse.speed`;
175
+ a comma-separated value (`2=ctrl+c,ctrl+v`) becomes a macro. These commands need no controller.
176
+
177
+ > Verified with simulated input on Linux; on-hardware verification is pending a physical unit.
178
+
179
+ ## Layout
180
+
181
+ ```
182
+ web/access-protocol.mjs shared, I/O-free protocol (parse/build/CRC/enums) — used by both tools
183
+ web/profile-library.mjs shared, I/O-free profile sharing + preset library (used by the web tool)
184
+ lib/hid-node.mjs node-hid transport (Node CLI only)
185
+ web/bridge-core.mjs PC bridge: pure input->output mapping engine
186
+ lib/bridge-sinks.mjs PC bridge: output sinks (dry-run, xdotool, uinput)
187
+ lib/uinput-helper.py PC bridge: stdlib-only virtual device for the uinput sink
188
+ cli.mjs command-line tool (profiles)
189
+ bridge.mjs command-line PC input bridge
190
+ web/index.html + xmb.js XMB-style configurator (the web UI) + Library + live Monitor, via hid-web.mjs
191
+ web/controller-render.mjs shared controller SVG render + physical-input decode
192
+ web/monitor.html + monitor.js standalone XMB-styled live input monitor
193
+ web/hid-capture.html input-report diagnostics / RE tool
194
+ captures/ profile backups (created on backup/auto-backup)
195
+ reference/ Jacek Fedoryński’s original web editor — third-party, see reference/NOTICE.md
196
+ PROTOCOL.md protocol documentation
197
+ ```
198
+
199
+ ## Safety
200
+
201
+ Profiles live in 3 on-device slots and are fully recoverable: take a `backup` first, and note
202
+ that writes auto-back-up. Connecting the controller to a PS5 will overwrite these profiles with
203
+ the console’s copies.
package/bridge.mjs ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // ps-access bridge — use the PlayStation Access Controller as a PC input device.
3
+ //
4
+ // Reads the controller's live input over USB and maps it to keyboard/mouse (xdotool)
5
+ // or a virtual gamepad (uinput) so it can drive ANY PC software, not just a PS5.
6
+ //
7
+ // node bridge.mjs --sink xdotool # stick -> arrows, buttons -> keys (X11)
8
+ // node bridge.mjs --sink uinput # virtual gamepad (needs /dev/uinput)
9
+ // node bridge.mjs --sink dry-run # print events, inject nothing
10
+ // node bridge.mjs --config my-map.json # custom mapping
11
+ // node bridge.mjs --simulate frames.json --sink dry-run # replay (no hardware)
12
+ //
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import readline from "node:readline";
15
+ import { BridgeEngine, decodeInput, DEFAULT_MAPPING } from "./web/bridge-core.mjs";
16
+ import {
17
+ PHYS_LABELS, STICK_MODES, STICK_DIRS, defaultBridgeMap, displayValue, toConfigJSON, keypressToValue,
18
+ } from "./web/bridge-map.mjs";
19
+ import { makeSink } from "./lib/bridge-sinks.mjs";
20
+
21
+ // Don't crash if our output is piped into something that closes early (e.g. `| head`).
22
+ process.stdout.on("error", (e) => { if (e.code === "EPIPE") process.exit(0); });
23
+
24
+ function parseArgs(argv) {
25
+ const o = { sink: "dry-run", rate: 60, _: [] };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === "--sink") o.sink = argv[++i];
29
+ else if (a === "--config") o.config = argv[++i];
30
+ else if (a === "--simulate") o.simulate = argv[++i];
31
+ else if (a === "--device" || a === "-d") o.device = argv[++i];
32
+ else if (a === "--display") o.display = argv[++i];
33
+ else if (a === "--rate") o.rate = Number(argv[++i]);
34
+ else if (a === "--out" || a === "-o") o.out = argv[++i];
35
+ else if (a === "--help" || a === "-h") o.help = true;
36
+ else o._.push(a);
37
+ }
38
+ return o;
39
+ }
40
+
41
+ function loadMapping(file) {
42
+ if (!file) return DEFAULT_MAPPING;
43
+ const m = JSON.parse(readFileSync(file, "utf8"));
44
+ return m.mapping || m; // accept {mapping:{...}} or a bare mapping
45
+ }
46
+
47
+ const HELP = `ps-access bridge — drive a PC with the Access Controller (USB-C)
48
+
49
+ Usage:
50
+ node bridge.mjs [--sink <name>] [options] run the bridge (live or --simulate)
51
+ node bridge.mjs edit [--config f] [--out f] interactive press-to-bind key editor
52
+ node bridge.mjs set <target=value>... [--out f] set mappings non-interactively
53
+ node bridge.mjs show [--config f] print the resolved config JSON
54
+
55
+ edit/set targets: 0..9 (buttons), stick.mode, stick.up/down/left/right, mouse.speed
56
+ e.g. node bridge.mjs set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
57
+
58
+ Sinks:
59
+ dry-run Print events only (default; no OS input)
60
+ xdotool Keyboard + mouse via xdotool (X11)
61
+ uinput Virtual gamepad/keyboard via /dev/uinput (Linux; needs access)
62
+
63
+ Options:
64
+ --config <file> JSON mapping (see DEFAULT_MAPPING in web/bridge-core.mjs)
65
+ --simulate <file> Replay recorded input frames instead of reading hardware
66
+ --device <i|path> Select controller (default 0)
67
+ --display <:N> X display for xdotool (default $DISPLAY or :0)
68
+ --rate <hz> Simulate playback rate (default 60)
69
+ -h, --help This help
70
+
71
+ Mapping config example (my-map.json):
72
+ { "buttons": { "8": "space", "0": "mouse1" },
73
+ "stick": { "mode": "mouse" }, "mouse": { "speed": 22 } }`;
74
+
75
+ async function runSimulate(opts, engine, sink) {
76
+ // Frames file: JSON array of byte arrays (report id already stripped), each one input report.
77
+ const frames = JSON.parse(readFileSync(opts.simulate, "utf8"));
78
+ const delay = Math.max(0, Math.round(1000 / (opts.rate || 60)));
79
+ console.log(`simulate: ${frames.length} frames -> ${opts.sink}`);
80
+ for (const f of frames) {
81
+ sink.apply(engine.update(decodeInput(Uint8Array.from(f))));
82
+ if (delay) await new Promise((r) => setTimeout(r, delay));
83
+ }
84
+ sink.apply(engine.releaseAll());
85
+ }
86
+
87
+ async function runLive(opts, engine, sink) {
88
+ let HID;
89
+ try { ({ HID } = await import("./lib/hid-node.mjs")); }
90
+ catch (e) { throw new Error("node-hid is required for live mode (run `npm install`). " + (e.message || e)); }
91
+ const { listControllers } = await import("./lib/hid-node.mjs");
92
+ const list = listControllers();
93
+ if (!list.length) throw new Error("No Access Controller connected (VID 054C / PID 0E5F). Connect it via USB-C.");
94
+ const sel = opts.device ?? 0;
95
+ const entry = (typeof sel === "string" && sel.startsWith("/")) ? list.find((d) => d.path === sel) : list[Number(sel)];
96
+ if (!entry) throw new Error(`No controller for --device ${sel}`);
97
+ const device = new HID.HID(entry.path);
98
+ console.log(`bridge: ${entry.product} [${entry.index}] -> ${opts.sink}. Ctrl-C to stop.`);
99
+ device.on("data", (buf) => {
100
+ // node-hid prefixes the report id for numbered reports; the decoder expects it stripped.
101
+ const d = buf[0] === 0x01 ? buf.subarray(1) : buf;
102
+ try { sink.apply(engine.update(decodeInput(d))); } catch (e) { console.error("map error:", e.message); }
103
+ });
104
+ device.on("error", (e) => { console.error("device error:", e.message); shutdown(); });
105
+ function shutdown() {
106
+ try { sink.apply(engine.releaseAll()); sink.close(); device.close(); } catch { /* ignore */ }
107
+ process.exit(0);
108
+ }
109
+ process.on("SIGINT", shutdown);
110
+ process.on("SIGTERM", shutdown);
111
+ await new Promise(() => {}); // run until signal
112
+ }
113
+
114
+ // ---- config editing (no controller / node-hid needed) ----
115
+
116
+ // Load a saved config into a full editable map (fills unset buttons, merges stick/mouse).
117
+ function loadEditMap(file) {
118
+ const base = defaultBridgeMap();
119
+ if (!file) return base;
120
+ try {
121
+ const j = JSON.parse(readFileSync(file, "utf8"));
122
+ const m = j.mapping || j;
123
+ if (m.buttons) for (const k of Object.keys(base.buttons)) if (m.buttons[k] != null) base.buttons[k] = m.buttons[k];
124
+ if (m.stick) base.stick = { ...base.stick, ...m.stick };
125
+ if (m.mouse) base.mouse = { ...base.mouse, ...m.mouse };
126
+ } catch { /* treat as a new file */ }
127
+ return base;
128
+ }
129
+
130
+ function applySet(map, target, value) {
131
+ const val = value.includes(",") ? value.split(",").map((s) => s.trim()) : value; // comma -> macro
132
+ if (/^\d+$/.test(target)) { map.buttons[target] = val; return; } // 0..9 -> button
133
+ if (target === "stick.mode") { map.stick.mode = value; return; }
134
+ if (target.startsWith("stick.")) { map.stick[target.slice(6)] = val; return; }
135
+ if (target.startsWith("mouse.")) { map.mouse[target.slice(6)] = isNaN(+value) ? value : +value; return; }
136
+ throw new Error(`unknown target "${target}" (use 0..9, stick.mode, stick.up.., mouse.speed)`);
137
+ }
138
+
139
+ function showConfig(opts) {
140
+ console.log(toConfigJSON(loadEditMap(opts.config)));
141
+ }
142
+
143
+ function setConfig(opts) {
144
+ const assigns = opts._.slice(1);
145
+ if (!assigns.length) throw new Error('usage: set <target=value>... e.g. set 0=ctrl+s 8=space stick.mode=mouse');
146
+ const map = loadEditMap(opts.config);
147
+ for (const a of assigns) {
148
+ const m = a.match(/^([^=]+)=(.*)$/);
149
+ if (!m) throw new Error(`bad assignment "${a}" (want target=value)`);
150
+ applySet(map, m[1].trim(), m[2].trim());
151
+ }
152
+ const out = opts.out || opts.config || "ps-access-bridge.json";
153
+ writeFileSync(out, toConfigJSON(map));
154
+ console.log(`wrote ${out}\n`);
155
+ console.log(toConfigJSON(map));
156
+ }
157
+
158
+ // Interactive press-to-bind editor — the CLI twin of the web Key Bridge blade.
159
+ function editConfig(opts) {
160
+ if (!process.stdin.isTTY) throw new Error("`edit` needs an interactive terminal — use `set`/`show` for scripting.");
161
+ const map = loadEditMap(opts.config);
162
+ const out = opts.out || opts.config || "ps-access-bridge.json";
163
+ let sel = 0, capturing = false, note = "";
164
+
165
+ const targets = () => {
166
+ const t = PHYS_LABELS.map((label, i) => ({ type: "button", i, label, get: () => map.buttons[i] }));
167
+ t.push({ type: "stickmode", label: "Stick mode", get: () => map.stick.mode });
168
+ if (map.stick.mode === "keys") for (const dir of STICK_DIRS) t.push({ type: "stick", dir, label: "Stick " + dir, get: () => map.stick[dir] });
169
+ return t;
170
+ };
171
+ const assign = (t, v) => { if (t.type === "button") map.buttons[t.i] = v; else if (t.type === "stick") map.stick[t.dir] = v; };
172
+ const render = () => {
173
+ const list = targets();
174
+ let s = "\x1b[2J\x1b[H\n ps-access bridge — key editor\n";
175
+ s += " ↑/↓ select · Enter bind (or cycle stick mode) · Del clear · s save · q quit\n\n";
176
+ list.forEach((t, idx) => { s += ` ${idx === sel ? "\x1b[36m▸" : " "} ${t.label.padEnd(13)} ${displayValue(t.get())}\x1b[0m\n`; });
177
+ s += capturing ? "\n \x1b[33m▶ press a key to bind… (Esc cancels)\x1b[0m\n" : `\n saving to: ${out}${note ? " " + note : ""}\n`;
178
+ process.stdout.write(s);
179
+ };
180
+
181
+ readline.emitKeypressEvents(process.stdin);
182
+ process.stdin.setRawMode(true);
183
+ process.stdin.resume();
184
+ render();
185
+ return new Promise((resolve) => {
186
+ const quit = () => { process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write("\n"); resolve(); };
187
+ process.stdin.on("keypress", (_str, key) => {
188
+ const list = targets();
189
+ const t = list[Math.min(sel, list.length - 1)];
190
+ if (capturing) {
191
+ if (key.name === "escape") { capturing = false; note = "cancelled"; }
192
+ else if (key.name === "delete") { assign(t, "nothing"); capturing = false; writeFileSync(out, toConfigJSON(map)); note = "cleared + saved"; }
193
+ else { const v = keypressToValue(key); if (v) { assign(t, v); capturing = false; writeFileSync(out, toConfigJSON(map)); note = "bound + saved"; } else return; }
194
+ render(); return;
195
+ }
196
+ if (key.name === "up") { sel = Math.max(0, sel - 1); note = ""; render(); }
197
+ else if (key.name === "down") { sel = Math.min(list.length - 1, sel + 1); note = ""; render(); }
198
+ else if (key.name === "return") {
199
+ if (t.type === "stickmode") { map.stick.mode = STICK_MODES[(STICK_MODES.indexOf(map.stick.mode) + 1) % STICK_MODES.length]; writeFileSync(out, toConfigJSON(map)); note = "saved"; }
200
+ else { capturing = true; note = ""; }
201
+ render();
202
+ }
203
+ else if (key.name === "s") { writeFileSync(out, toConfigJSON(map)); note = "saved " + out; render(); }
204
+ else if (key.name === "q" || (key.ctrl && key.name === "c")) quit();
205
+ });
206
+ });
207
+ }
208
+
209
+ async function main() {
210
+ const opts = parseArgs(process.argv.slice(2));
211
+ if (opts.help) { console.log(HELP); return; }
212
+ const sub = opts._[0];
213
+ try {
214
+ if (sub === "edit") return await editConfig(opts);
215
+ if (sub === "set") return setConfig(opts);
216
+ if (sub === "show") return showConfig(opts);
217
+ } catch (e) { console.error("error:", e.message); process.exit(1); }
218
+
219
+ // default: run the bridge
220
+ const engine = new BridgeEngine(loadMapping(opts.config));
221
+ const sink = makeSink(opts.sink, { display: opts.display });
222
+ try {
223
+ if (opts.simulate) { await runSimulate(opts, engine, sink); sink.close(); }
224
+ else await runLive(opts, engine, sink);
225
+ } catch (e) {
226
+ console.error("error:", e.message);
227
+ try { sink.close(); } catch { /* ignore */ }
228
+ process.exit(1);
229
+ }
230
+ }
231
+ main();