ps-access 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -25,21 +25,21 @@ yourself.
25
25
 
26
26
  ## CLI
27
27
 
28
+ Install with `npm i -g ps-access`, or run any command without installing via `npx ps-access …`.
29
+
28
30
  ```bash
29
- npm install
30
-
31
- node cli.mjs list # list connected controllers
32
- node cli.mjs dump # decode all 3 profiles
33
- node cli.mjs backup # save all 3 profiles to captures/
34
- node cli.mjs read-profile 1 --json # decode one profile as JSON
35
- node cli.mjs set-active 2 # switch the active profile (like the profile button)
36
- node cli.mjs set 1 button5=triangle # remap button 5
37
- node cli.mjs set 1 port1=cross # expansion port 1 -> cross
38
- node cli.mjs set 1 "port0=left stick" # built-in stick assignment
39
- node cli.mjs set 1 orientation="stick on the right"
40
- node cli.mjs write-profile 2 captures/backup-....json
41
- node cli.mjs restore captures/backup-....json
42
- node cli.mjs apply profile.json 1 # apply a web-app export / share code / URL / preset id to a slot
31
+ ps-access list # list connected controllers
32
+ ps-access dump # decode all 3 profiles
33
+ ps-access backup # save all 3 profiles to captures/
34
+ ps-access read-profile 1 --json # decode one profile as JSON
35
+ ps-access set-active 2 # switch the active profile (like the profile button)
36
+ ps-access set 1 button5=triangle # remap button 5
37
+ ps-access set 1 port1=cross # expansion port 1 -> cross
38
+ ps-access set 1 "port0=left stick" # built-in stick assignment
39
+ ps-access set 1 orientation="stick on the right"
40
+ ps-access write-profile 2 captures/backup-....json
41
+ ps-access restore captures/backup-....json
42
+ ps-access apply profile.json 1 # apply a web-app export / share code / URL / preset id to a slot
43
43
  ```
44
44
 
45
45
  `apply` is the bridge between the web tool and the CLI: feed it a **profile JSON exported from the
@@ -47,7 +47,8 @@ web Library**, a **share link/code**, or a built-in **preset id** (`ps-access pr
47
47
  writes that mapping to a slot on the controller (reading the current profile first so uuid and
48
48
  unmodeled fields survive, then round-trip verifying).
49
49
 
50
- - `--device <index|path>` targets a specific controller when several are connected.
50
+ - `--device <serial|index|path>` targets a specific controller when several are connected
51
+ (serial is the stable id — see `ps-access list`).
51
52
  - **Every write auto-backs-up first** to `captures/` and round-trip re-reads to verify.
52
53
 
53
54
  ## Web tool (multiple controllers)
@@ -76,7 +77,7 @@ or chord** to each physical button and the stick by selecting a row, pressing En
76
77
  the key you want (press the physical button to find its row — it lights up live). Pick a stick
77
78
  mode (keys / mouse / gamepad axis), then **Export** a `bridge.json` or **copy the run command**.
78
79
  The browser can author and preview the mapping, but it can't inject input into other apps — you
79
- run the exported config with the local bridge (`node bridge.mjs --config bridge.json`), which is
80
+ run the exported config with the local bridge (`ps-access bridge --config bridge.json`), which is
80
81
  what actually drives the PC. (Macros — multi-step sequences — are edited in the exported JSON.)
81
82
 
82
83
  **Accessibility:** the configurator is built to be used by the same people the controller is for.
@@ -127,11 +128,11 @@ not just a PS5. The bridge reads the controller's live USB input and maps it thr
127
128
  platform-agnostic engine (`web/bridge-core.mjs`) to a pluggable output **sink**.
128
129
 
129
130
  ```bash
130
- node bridge.mjs --sink dry-run # print mapped events, inject nothing (try it first)
131
- node bridge.mjs --sink xdotool # stick -> arrow keys, buttons -> keys (X11; needs xdotool)
132
- node bridge.mjs --sink uinput # virtual gamepad/keyboard via /dev/uinput (Linux)
133
- node bridge.mjs --config my-map.json # custom mapping (see DEFAULT_MAPPING in bridge-core)
134
- node bridge.mjs --simulate frames.json --sink dry-run # replay recorded frames, no hardware
131
+ ps-access bridge --sink dry-run # print mapped events, inject nothing (try it first)
132
+ ps-access bridge --sink xdotool # stick -> arrow keys, buttons -> keys (X11; needs xdotool)
133
+ ps-access bridge --sink uinput # virtual gamepad/keyboard via /dev/uinput (Linux)
134
+ ps-access bridge --config my-map.json # custom mapping (see DEFAULT_MAPPING in bridge-core)
135
+ ps-access bridge --simulate frames.json --sink dry-run # replay recorded frames, no hardware
135
136
  ```
136
137
 
137
138
  - **xdotool** sink (X11): no native deps; set `--display :0` if `$DISPLAY` isn't set.
@@ -165,10 +166,10 @@ node bridge.mjs --simulate frames.json --sink dry-run # replay recorded frames
165
166
  Author the config visually in the web **Key Bridge** blade, or from the terminal:
166
167
 
167
168
  ```bash
168
- node bridge.mjs edit # interactive press-to-bind editor (TTY)
169
- node bridge.mjs edit --config my-map.json --out my-map.json
170
- node bridge.mjs set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
171
- node bridge.mjs show --config my-map.json # print the resolved config
169
+ ps-access bridge edit # interactive press-to-bind editor (TTY)
170
+ ps-access bridge edit --config my-map.json --out my-map.json
171
+ ps-access bridge set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
172
+ ps-access bridge show --config my-map.json # print the resolved config
172
173
  ```
173
174
 
174
175
  - **edit** is the CLI twin of the web editor: ↑/↓ to select a button/stick row, **Enter** to
@@ -187,8 +188,8 @@ lib/hid-node.mjs node-hid transport (Node CLI only)
187
188
  web/bridge-core.mjs PC bridge: pure input->output mapping engine
188
189
  lib/bridge-sinks.mjs PC bridge: output sinks (dry-run, xdotool, uinput)
189
190
  lib/uinput-helper.py PC bridge: stdlib-only virtual device for the uinput sink
190
- cli.mjs command-line tool (profiles)
191
- bridge.mjs command-line PC input bridge
191
+ cli.mjs the `ps-access` command (profiles; dispatches `bridge` subcommand)
192
+ bridge.mjs PC input bridge — module run via `ps-access bridge`
192
193
  web/index.html + xmb.js XMB-style configurator (the web UI) + Library + live Monitor, via hid-web.mjs
193
194
  web/controller-render.mjs shared controller SVG render + physical-input decode
194
195
  web/monitor.html + monitor.js standalone XMB-styled live input monitor
package/bridge.mjs CHANGED
@@ -4,14 +4,15 @@
4
4
  // Reads the controller's live input over USB and maps it to keyboard/mouse (xdotool)
5
5
  // or a virtual gamepad (uinput) so it can drive ANY PC software, not just a PS5.
6
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)
7
+ // ps-access bridge --sink xdotool # stick -> arrows, buttons -> keys (X11)
8
+ // ps-access bridge --sink uinput # virtual gamepad (needs /dev/uinput)
9
+ // ps-access bridge --sink dry-run # print events, inject nothing
10
+ // ps-access bridge --config my-map.json # custom mapping
11
+ // ps-access bridge --simulate frames.json --sink dry-run # replay (no hardware)
12
12
  //
13
13
  import { readFileSync, writeFileSync } from "node:fs";
14
14
  import readline from "node:readline";
15
+ import { pathToFileURL } from "node:url";
15
16
  import { BridgeEngine, decodeInput, DEFAULT_MAPPING } from "./web/bridge-core.mjs";
16
17
  import {
17
18
  PHYS_LABELS, STICK_MODES, STICK_DIRS, defaultBridgeMap, displayValue, toConfigJSON, keypressToValue,
@@ -47,13 +48,13 @@ function loadMapping(file) {
47
48
  const HELP = `ps-access bridge — drive a PC with the Access Controller (USB-C)
48
49
 
49
50
  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
51
+ ps-access bridge [--sink <name>] [options] run the bridge (live or --simulate)
52
+ ps-access bridge edit [--config f] [--out f] interactive press-to-bind key editor
53
+ ps-access bridge set <target=value>... [--out f] set mappings non-interactively
54
+ ps-access bridge show [--config f] print the resolved config JSON
54
55
 
55
56
  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
+ e.g. ps-access bridge set 0=ctrl+s 8=space 2=ctrl+c,ctrl+v stick.mode=mouse --out my-map.json
57
58
 
58
59
  Sinks:
59
60
  dry-run Print events only (default; no OS input)
@@ -85,11 +86,21 @@ async function runSimulate(opts, engine, sink) {
85
86
  }
86
87
 
87
88
  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();
89
+ let HID, listControllers, list;
90
+ try {
91
+ const mod = await import("./lib/hid-node.mjs");
92
+ HID = mod.HID; listControllers = mod.listControllers;
93
+ list = listControllers(); // also forces node-hid's native binding to load
94
+ } catch (e) {
95
+ const m = String(e.message || e);
96
+ if (e.code === "ERR_MODULE_NOT_FOUND" || /Cannot find (module|package)/.test(m)) {
97
+ throw new Error("live mode needs node-hid — run `npm install` (or `npm i -g ps-access`). `edit`/`set`/`show` work without it.");
98
+ }
99
+ if (/libudev|shared object|NODE_MODULE_VERSION|was compiled|dlopen|\.node/.test(m)) {
100
+ throw new Error(`node-hid couldn't load on this system (${m}). On Linux it needs libudev. \`edit\`/\`set\`/\`show\` work without it.`);
101
+ }
102
+ throw new Error(`live mode unavailable: ${m}`);
103
+ }
93
104
  if (!list.length) throw new Error("No Access Controller connected (VID 054C / PID 0E5F). Connect it via USB-C.");
94
105
  const sel = opts.device ?? 0;
95
106
  const entry = (typeof sel === "string" && sel.startsWith("/")) ? list.find((d) => d.path === sel) : list[Number(sel)];
@@ -206,8 +217,8 @@ function editConfig(opts) {
206
217
  });
207
218
  }
208
219
 
209
- async function main() {
210
- const opts = parseArgs(process.argv.slice(2));
220
+ export async function runBridge(argv) {
221
+ const opts = parseArgs(argv);
211
222
  if (opts.help) { console.log(HELP); return; }
212
223
  const sub = opts._[0];
213
224
  try {
@@ -228,4 +239,8 @@ async function main() {
228
239
  process.exit(1);
229
240
  }
230
241
  }
231
- main();
242
+
243
+ // Allow `node bridge.mjs …` directly (dev), while also being importable as `ps-access bridge`.
244
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
245
+ runBridge(process.argv.slice(2));
246
+ }
package/cli.mjs CHANGED
@@ -12,10 +12,22 @@ import { PRESETS, presetById, fromFileText, applyPortable, decodeShare, shareURL
12
12
  // without it installed. Device commands call loadHid() first (see the dispatcher).
13
13
  let _hid = null;
14
14
  async function loadHid() {
15
- if (!_hid) {
16
- try { _hid = await import("./lib/hid-node.mjs"); }
17
- catch (e) { throw new Error(`this command needs the controller — run "npm install" first (${e.message || e})`); }
15
+ if (_hid) return _hid;
16
+ let mod;
17
+ try {
18
+ mod = await import("./lib/hid-node.mjs");
19
+ mod.listControllers(); // probe: force node-hid's native binding to load now, so we can explain failures
20
+ } catch (e) {
21
+ const m = String(e.message || e);
22
+ if (e.code === "ERR_MODULE_NOT_FOUND" || /Cannot find (module|package)|ERR_MODULE_NOT_FOUND/.test(m)) {
23
+ throw new Error(`controller access needs node-hid, which isn't installed. Run "npm install" (or "npm i -g ps-access"). The offline commands (presets, share, show-share) work without it.`);
24
+ }
25
+ if (/libudev|shared object|NODE_MODULE_VERSION|was compiled|dlopen|\.node/.test(m)) {
26
+ throw new Error(`node-hid couldn't load on this system (${m}). On Linux it needs libudev (e.g. apt install libudev1, or the udev package). Offline commands (presets, share, show-share) still work.`);
27
+ }
28
+ throw new Error(`controller access unavailable: ${m}`);
18
29
  }
30
+ _hid = mod;
19
31
  return _hid;
20
32
  }
21
33
  const listControllers = (...a) => _hid.listControllers(...a);
@@ -297,9 +309,10 @@ const COMMANDS = {
297
309
  },
298
310
 
299
311
  help() {
300
- console.log(`ps-access — PlayStation Access Controller profile tool (USB-C, no PS5)
312
+ console.log(`ps-access — PlayStation Access Controller tool (USB-C, no PS5)
301
313
 
302
- Usage: node cli.mjs <command> [args] [--device <index|path>]
314
+ Usage: ps-access <command> [args] [--device <serial|index|path>]
315
+ (no install: npx ps-access <command> …)
303
316
 
304
317
  Commands:
305
318
  list List connected controllers
@@ -318,6 +331,7 @@ Commands:
318
331
  presets List built-in starting-point presets
319
332
  share <backup.json> [slot] Print a shareable link/code for a backed-up profile (offline)
320
333
  show-share <code|url> Decode + describe a share link/code (offline)
334
+ bridge [run|edit|set|show] Use the controller as a PC input device (ps-access bridge --help)
321
335
 
322
336
  Every write auto-backs-up first (captures/) and round-trip verifies.
323
337
  Actions: ${Object.values(ACTIONS).join(", ")}, left stick, right stick`);
@@ -327,12 +341,19 @@ Actions: ${Object.values(ACTIONS).join(", ")}, left stick, right stick`);
327
341
  // Commands that never touch the controller — usable without node-hid installed.
328
342
  const OFFLINE = new Set(["presets", "share", "show-share", "help"]);
329
343
 
330
- const opts = parseArgs(process.argv.slice(2));
331
- const cmd = opts._.shift() || "help";
332
- const fn = COMMANDS[cmd] || COMMANDS.help;
344
+ const rawArgv = process.argv.slice(2);
333
345
  try {
334
- if (!OFFLINE.has(cmd) && COMMANDS[cmd]) await loadHid();
335
- await fn(opts);
346
+ if (rawArgv[0] === "bridge") {
347
+ // PC input bridge — its own subcommands/flags (run/edit/set/show). Delegated verbatim.
348
+ const { runBridge } = await import("./bridge.mjs");
349
+ await runBridge(rawArgv.slice(1));
350
+ } else {
351
+ const opts = parseArgs(rawArgv);
352
+ const cmd = opts._.shift() || "help";
353
+ const fn = COMMANDS[cmd] || COMMANDS.help;
354
+ if (!OFFLINE.has(cmd) && COMMANDS[cmd]) await loadHid();
355
+ await fn(opts);
356
+ }
336
357
  } catch (e) {
337
358
  console.error("error:", e.message);
338
359
  process.exit(1);
package/lib/hid-node.mjs CHANGED
@@ -27,6 +27,7 @@ export function openController(selector = 0) {
27
27
  if (list.length === 0) throw new Error("No Access Controller found (VID 054C / PID 0E5F). Connect it via USB-C.");
28
28
  let entry;
29
29
  if (typeof selector === "string" && selector.startsWith("/")) entry = list.find((d) => d.path === selector);
30
+ else if (typeof selector === "string" && list.some((d) => d.serialNumber === selector)) entry = list.find((d) => d.serialNumber === selector); // stable across reconnects
30
31
  else entry = list[Number(selector)];
31
32
  if (!entry) throw new Error(`No controller for selector ${JSON.stringify(selector)}. ${list.length} connected.`);
32
33
  const device = new HID.HID(entry.path);
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "ps-access",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Read/write PlayStation Access Controller profiles from a PC (no PS5). CLI + WebHID tool + PC input bridge.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ps-access": "./cli.mjs",
8
- "ps-access-bridge": "./bridge.mjs"
7
+ "ps-access": "./cli.mjs"
9
8
  },
10
9
  "scripts": {
11
10
  "start": "npx --yes serve -l 3000 .",
12
11
  "list": "node cli.mjs list",
13
12
  "backup": "node cli.mjs backup",
14
- "bridge": "node bridge.mjs",
13
+ "bridge": "node cli.mjs bridge",
15
14
  "test": "node --test"
16
15
  },
17
16
  "files": [
@@ -96,7 +96,7 @@ export function toConfigJSON(map) {
96
96
  return JSON.stringify(toConfig(map), null, 2);
97
97
  }
98
98
 
99
- // A ready-to-run command for the exported config.
99
+ // A ready-to-run command for the exported config, using the published CLI.
100
100
  export function runCommand(filename = "ps-access-bridge.json", sink = "xdotool") {
101
- return `node bridge.mjs --config ${filename} --sink ${sink}`;
101
+ return `npx ps-access bridge --config ${filename} --sink ${sink}`;
102
102
  }
package/web/index.html CHANGED
@@ -158,6 +158,29 @@
158
158
  #mon-done { position: absolute; top: -2px; right: 0; font: inherit; font-size: 13px; color: var(--dim);
159
159
  background: var(--glass); border: 1px solid rgba(255,255,255,.14); padding: 5px 12px; border-radius: 999px; cursor: pointer; z-index: 7; }
160
160
  #mon-done:hover { color: var(--ink); border-color: var(--accent); }
161
+
162
+ /* multi-controller monitor: one card per connected controller */
163
+ #mon-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 22px;
164
+ height: 100%; overflow: auto; align-content: start; padding: 2px 2px 8px; }
165
+ .mon-card { background: var(--glass); border: 1px solid rgba(255,255,255,.08); border-radius: 16px;
166
+ backdrop-filter: blur(10px); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
167
+ .mon-card .hd { display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
168
+ font-size: 12px; letter-spacing: .04em; color: var(--dim); }
169
+ .mon-card .hd .nm { color: var(--ink); font-size: 13px; }
170
+ .mon-card .hd .pf b { color: var(--accent); font-weight: 500; }
171
+ .mon-card .r { display: grid; place-items: center; }
172
+ .mon-card .r svg { width: 100%; max-width: 300px; height: auto; overflow: visible; }
173
+ .mon-card .r .seg { fill: rgba(255,255,255,.045); stroke: rgba(255,255,255,.2); stroke-width: 2; stroke-linejoin: round; transition: fill .07s, stroke .07s; }
174
+ .mon-card .r .seg.on { fill: var(--accent2); stroke: var(--accent); }
175
+ .mon-card .r .lab { fill: var(--ink); font: 300 22px sans-serif; text-anchor: middle; }
176
+ .mon-card .r .lab.big { font-size: 34px; } .mon-card .r .lab.sm { font-size: 15px; fill: var(--dim); }
177
+ .mon-card .r .stickwell { fill: rgba(255,255,255,.035); stroke: rgba(255,255,255,.2); stroke-width: 2; }
178
+ .mon-card .r .thumb { fill: rgba(255,255,255,.12); stroke: rgba(255,255,255,.22); stroke-width: 2; transition: fill .07s, cx .04s linear, cy .04s linear; }
179
+ .mon-card .r .thumb.on { fill: var(--accent2); stroke: var(--accent); }
180
+ .mon-card .raw { display: grid; grid-template-columns: repeat(16,1fr); gap: 3px; font: 11px ui-monospace, monospace; }
181
+ .mon-card .raw .b { text-align: center; padding: 3px 0; border-radius: 4px; background: rgba(255,255,255,.03); color: #6c7a89; }
182
+ .mon-card .raw .b.nz { color: var(--ink); background: rgba(255,255,255,.07); }
183
+ .mon-card .raw .b.btn { outline: 1.5px solid var(--accent); color: var(--accent); }
161
184
  .devcol { display: inline-flex; flex-direction: column; line-height: 1.25; }
162
185
  #mon-prof { font-size: 11px; color: var(--dim); letter-spacing: .02em; }
163
186
  #mon-prof:empty { display: none; }
@@ -249,6 +272,8 @@
249
272
  <span class="device"><span class="dot" id="dev-dot"></span><span class="devcol"><span id="dev-name">No controller</span><span id="mon-prof"></span></span></span>
250
273
  <span id="crumb"></span>
251
274
  <span class="clock" id="clock"></span>
275
+ <a href="https://www.npmjs.com/package/ps-access" target="_blank" rel="noopener"
276
+ title="Also a command-line tool: npm i -g ps-access (profiles + PC input bridge)">CLI ↗</a>
252
277
  </div>
253
278
 
254
279
  <!-- Screen-reader live region: every navigation/value change is announced here. -->
@@ -269,27 +294,8 @@
269
294
 
270
295
  <div id="monitor">
271
296
  <button id="mon-done">Done · Esc</button>
272
- <div class="mon-grid">
273
- <div id="mon-render"></div>
274
- <div class="panel">
275
- <div>
276
- <h2>Buttons (physical)</h2>
277
- <div class="chips" id="mon-chips"></div>
278
- </div>
279
- <div>
280
- <h2>Stick</h2>
281
- <div class="stickrow">
282
- <div class="crosshair"><div class="dot" id="mon-stickdot"></div></div>
283
- <div class="axisvals">X <b id="mon-ax">0.00</b><br>Y <b id="mon-ay">0.00</b></div>
284
- </div>
285
- </div>
286
- <div>
287
- <h2>Raw input report (id 0x01)</h2>
288
- <div id="mon-raw"></div>
289
- <div class="legend" style="font-size:11px;color:var(--dim);margin-top:6px">outlined = <b style="color:var(--accent)">byte 15</b> (perimeter) &amp; <b style="color:var(--accent)">byte 16</b> (center / stick-click)</div>
290
- </div>
291
- </div>
292
- </div>
297
+ <!-- One card per connected controller, built in xmb.js. -->
298
+ <div id="mon-cards"></div>
293
299
  </div>
294
300
 
295
301
  <div id="mon-warn">
@@ -332,7 +338,8 @@
332
338
  <dt>M</dt><dd>Toggle navigation sounds</dd>
333
339
  <dt>?</dt><dd>Open this help</dd>
334
340
  </dl>
335
- <button class="close" id="help-focus" aria-pressed="false" style="margin-top:18px;margin-right:8px">High-visibility focus ring: Off</button>
341
+ <p class="sub" style="margin-top:16px">Also a command-line tool — <code>npm i -g ps-access</code> (profile editing + the PC input bridge). See <a href="https://github.com/johnhenry/ps-access" target="_blank" rel="noopener" style="color:var(--accent)">github.com/johnhenry/ps-access</a>.</p>
342
+ <button class="close" id="help-focus" aria-pressed="false" style="margin-right:8px">High-visibility focus ring: Off</button>
336
343
  <button class="close" id="help-close">Close · Esc</button>
337
344
  </div>
338
345
  </div>
package/web/xmb.js CHANGED
@@ -250,9 +250,9 @@ function bladeItems(blade) {
250
250
  }
251
251
  items.push(
252
252
  { key: "br-reset", label: "Reset to defaults", action: "bridgeReset" },
253
- { key: "br-export", label: "Export config file (bridge.json)", action: "bridgeExport" },
253
+ { key: "br-export", label: "Export config for the ps-access CLI (bridge.json)", action: "bridgeExport" },
254
254
  { key: "br-json", label: "Copy config JSON", action: "bridgeCopyJson" },
255
- { key: "br-cmd", label: "Copy run command", action: "bridgeCopyCmd" },
255
+ { key: "br-cmd", label: "Copy CLI run command (npx ps-access-bridge)", action: "bridgeCopyCmd" },
256
256
  );
257
257
  return items;
258
258
  }
@@ -388,7 +388,7 @@ function announce(msg) {
388
388
  // Concise description of the current focus, spoken by screen readers on every nav change.
389
389
  function describeNav() {
390
390
  if (capturing) return "Listening — press a keyboard key to assign, Delete to clear, or Escape to cancel.";
391
- if (monitorMode) return "Live input monitor open. Observe the controller, then press Escape to exit.";
391
+ if (monitorMode) return "Live input monitor open, showing all connected controllers. Press Escape to exit.";
392
392
  if (monitorArm) return `Start the live monitor? ${warnSel === 0 ? "Start monitoring" : "Cancel"}, option ${warnSel + 1} of 2. Up or Down to choose, Enter to confirm.`;
393
393
  const blade = BLADES[nav.col];
394
394
  if (nav.drill) {
@@ -455,7 +455,7 @@ function activate() {
455
455
  case "capture": startCapture(it.cap); break;
456
456
  case "cycleStick": cycleStickMode(); break;
457
457
  case "bridgeReset": bridgeMap = defaultBridgeMap(); saveBridgeMap(); render(); toast("Bridge mapping reset to defaults"); break;
458
- case "bridgeExport": downloadText("ps-access-bridge.json", toConfigJSON(bridgeMap)); toast("Exported bridge.json — run it with the bridge CLI", 4000); break;
458
+ case "bridgeExport": downloadText("ps-access-bridge.json", toConfigJSON(bridgeMap)); toast("Exported bridge.json — run it with: npx ps-access-bridge --config bridge.json", 5000); break;
459
459
  case "bridgeCopyJson": copyText(toConfigJSON(bridgeMap)).then((ok) => toast(ok ? "Config JSON copied" : "Copy failed", 2500)); break;
460
460
  case "bridgeCopyCmd": copyText(runCommand()).then((ok) => toast(ok ? "Run command copied" : "Copy failed", 2500)); break;
461
461
  }
@@ -619,27 +619,46 @@ async function load() {
619
619
  async function connectOnce() {
620
620
  try {
621
621
  const before = controllers.length;
622
- const ds = await requestControllers();
623
- if (!ds.length) { toast("No controller selected", 2500); return; }
624
- await addDevices(ds);
625
- toast(controllers.length > before ? "Controller connected" : "Controller already connected", 2000);
622
+ await requestControllers(); // user grants one in the chooser (this gesture)
623
+ await addDevices(await grantedControllers()); // reconcile: add every granted controller not yet present
624
+ const added = controllers.length - before;
625
+ if (added > 0) toast(added > 1 ? `${added} controllers connected` : "Controller connected", 2200);
626
+ else toast("That one's already connected — to add another, pick the *other* “Access Controller” in the chooser.", 5500);
626
627
  } catch (e) { toast(String(e.message || e), 4000); }
627
628
  }
628
- async function addDevices(devices) {
629
+
630
+ // Serialize addDevices: granting a device in the chooser fires the `connect` event, whose handler
631
+ // also calls addDevices — running both concurrently on the same new device interleaves its
632
+ // feature-report reads and corrupts the add. Chaining guarantees one batch finishes before the next.
633
+ let _addChain = Promise.resolve();
634
+ function addDevices(devices) {
635
+ const run = _addChain.then(() => _addDevices(devices));
636
+ _addChain = run.catch(() => {}); // keep the chain alive even if a batch throws
637
+ return run;
638
+ }
639
+ async function _addDevices(devices) {
640
+ let added = 0;
629
641
  for (const device of devices) {
630
642
  if (controllers.some((c) => c.device === device)) continue;
631
- await ensureOpen(device);
632
- device.addEventListener("inputreport", onInputReport); // physical buttons + stick, live
633
- const profiles = [];
634
- for (let s = 1; s <= PROFILE_COUNT; s++) {
635
- const p = parseProfile(await readProfileRaw(device, s));
636
- p._physOrient = p.ports[0].kind === "stick" ? p.ports[0].orientation : 3;
637
- profiles.push(p);
643
+ try {
644
+ await ensureOpen(device);
645
+ const profiles = [];
646
+ for (let s = 1; s <= PROFILE_COUNT; s++) {
647
+ const p = parseProfile(await readProfileRaw(device, s));
648
+ p._physOrient = p.ports[0].kind === "stick" ? p.ports[0].orientation : 3;
649
+ profiles.push(p);
650
+ }
651
+ if (controllers.some((c) => c.device === device)) continue; // defensive: added while awaiting
652
+ device.addEventListener("inputreport", onInputReport); // attach only after a clean read
653
+ controllers.push({ device, name: `Controller ${controllers.length + 1}`, profiles });
654
+ added++;
655
+ } catch (e) {
656
+ toast(`Couldn't read a controller: ${e.message || e}`, 4000);
638
657
  }
639
- controllers.push({ device, name: `Controller ${controllers.length + 1}`, profiles });
640
658
  }
641
659
  updateDeviceStatus();
642
660
  render();
661
+ return added;
643
662
  }
644
663
  function updateDeviceStatus() {
645
664
  const c = controllers[activeCtrl];
@@ -682,14 +701,37 @@ async function reloadFromDevice() {
682
701
  // ============================ live input monitor ============================
683
702
  // Full-screen overlay. The controller is purely observed here (navigation is suspended), so
684
703
  // every physical button and the stick can be tested freely; exit with Esc or the Done button.
685
- function buildMonChips() {
686
- $("#mon-chips").innerHTML = PHYS_NAMES.map((n, i) =>
704
+ function monChipsHTML() {
705
+ return PHYS_NAMES.map((n, i) =>
687
706
  `<div class="chip" data-i="${i}">${i < 8 ? n : n.split("-")[0]}<small>${i < 8 ? "button" : (i === 8 ? "center" : "L3")}</small></div>`).join("");
688
707
  }
689
- function buildMonRaw() {
708
+ function monRawHTML() {
690
709
  let h = "";
691
710
  for (let i = 0; i < 63; i++) h += `<div class="b${i === 15 || i === 16 ? " btn" : ""}" data-i="${i}">00</div>`;
692
- $("#mon-raw").innerHTML = h;
711
+ return h;
712
+ }
713
+ // Active on-device profile slot for a controller (from the last input report's byte 39), else 0.
714
+ function monProfileSlot(c) {
715
+ const p = ctrlState.get(c.device)?.profile;
716
+ return (p >= 1 && p <= PROFILE_COUNT) ? p - 1 : 0;
717
+ }
718
+ function monCardHTML(i, c) {
719
+ return `<div class="mon-card" data-ctrl="${i}" data-pslot="">
720
+ <div class="hd"><span class="nm">${c.name}</span><span class="pf"></span></div>
721
+ <div class="r"></div>
722
+ <div class="chips"></div>
723
+ <div class="stickrow">
724
+ <div class="crosshair"><div class="dot"></div></div>
725
+ <div class="axisvals">X <b class="ax">0.00</b> · Y <b class="ay">0.00</b></div>
726
+ </div>
727
+ <div class="raw"></div>
728
+ </div>`;
729
+ }
730
+ function renderMonCardProfile(card, c) {
731
+ const slot = monProfileSlot(c);
732
+ card.querySelector(".r").innerHTML = profileSVG(c.profiles[slot] || c.profiles[0]);
733
+ card.querySelector(".pf").innerHTML = `<b>Profile ${slot + 1}</b>`;
734
+ card.dataset.pslot = String(slot);
693
735
  }
694
736
  // Step 1: a PS3-style confirm gate warning that the controller can't exit this view (Esc / Done
695
737
  // only). A navigable two-option list — Start / Cancel — operable by keyboard (↑↓ + Enter / Esc),
@@ -723,13 +765,17 @@ function pickArm() { warnSel === 0 ? confirmArm() : cancelArm(); }
723
765
  // Step 2: enter the live monitor, rendering the *chosen* profile (so the controller image
724
766
  // matches that profile's orientation) and showing which profile is on screen.
725
767
  function enterMonitor() {
726
- if (!controllers[activeCtrl]) { toast("Connect a controller first"); return; }
727
- const prof = controllers[activeCtrl].profiles[shownProfileSlot()]; // the active on-device profile
768
+ if (!controllers.length) { toast("Connect a controller first"); return; }
728
769
  monitorMode = true;
729
- $("#mon-render").innerHTML = profileSVG(prof); // profileSVG bakes in the orientation
730
- updateProfileTag(); // top-bar already shows it, keep in sync
731
- if (!$("#mon-chips").children.length) buildMonChips();
732
- if (!$("#mon-raw").children.length) buildMonRaw();
770
+ const wrap = $("#mon-cards");
771
+ wrap.innerHTML = controllers.map((c, i) => monCardHTML(i, c)).join(""); // one card per controller
772
+ for (const card of wrap.querySelectorAll(".mon-card")) {
773
+ const i = +card.dataset.ctrl;
774
+ card.querySelector(".chips").innerHTML = monChipsHTML();
775
+ card.querySelector(".raw").innerHTML = monRawHTML();
776
+ renderMonCardProfile(card, controllers[i]);
777
+ }
778
+ updateProfileTag();
733
779
  $("#monitor").classList.add("show");
734
780
  $("#stage").style.display = "none";
735
781
  $(".footer").style.display = "none"; // its nav hints don't apply while observing
@@ -743,23 +789,31 @@ function exitMonitor() {
743
789
  blip(330);
744
790
  render();
745
791
  }
746
- function updateMonitor(buttons, axes, d) {
747
- for (const el of document.querySelectorAll("#mon-render svg [data-btn]"))
792
+ // Update the card for the controller that sent this report (every controller updates live).
793
+ function updateMonitor(device, buttons, axes, d, profile) {
794
+ const i = controllers.findIndex((c) => c.device === device);
795
+ if (i < 0) return;
796
+ const card = document.querySelector(`#mon-cards .mon-card[data-ctrl="${i}"]`);
797
+ if (!card) return;
798
+ if (profile >= 1 && profile <= PROFILE_COUNT && String(profile - 1) !== card.dataset.pslot) {
799
+ renderMonCardProfile(card, controllers[i]); // active on-device profile changed -> re-render this card
800
+ }
801
+ for (const el of card.querySelectorAll(".r svg [data-btn]"))
748
802
  el.classList.toggle("on", buttons.has(+el.getAttribute("data-btn")));
749
- const thumb = $("#mon-render svg .thumb");
803
+ const thumb = card.querySelector(".r svg .thumb");
750
804
  if (thumb) {
751
805
  thumb.setAttribute("cx", (+thumb.dataset.bx + axes[0] * M.THUMB_R).toFixed(1));
752
806
  thumb.setAttribute("cy", (+thumb.dataset.by + axes[1] * M.THUMB_R).toFixed(1));
753
807
  }
754
- for (const c of $("#mon-chips").children) c.classList.toggle("on", buttons.has(+c.dataset.i));
755
- $("#mon-stickdot").style.left = (50 + axes[0] * 38) + "%";
756
- $("#mon-stickdot").style.top = (50 + axes[1] * 38) + "%";
757
- $("#mon-ax").textContent = axes[0].toFixed(2);
758
- $("#mon-ay").textContent = axes[1].toFixed(2);
759
- const cells = $("#mon-raw").children;
760
- for (let i = 0; i < d.length && i < cells.length; i++) {
761
- cells[i].textContent = d[i].toString(16).padStart(2, "0");
762
- cells[i].classList.toggle("nz", d[i] !== 0);
808
+ for (const c of card.querySelectorAll(".chips .chip")) c.classList.toggle("on", buttons.has(+c.dataset.i));
809
+ card.querySelector(".dot").style.left = (50 + axes[0] * 38) + "%";
810
+ card.querySelector(".dot").style.top = (50 + axes[1] * 38) + "%";
811
+ card.querySelector(".ax").textContent = axes[0].toFixed(2);
812
+ card.querySelector(".ay").textContent = axes[1].toFixed(2);
813
+ const cells = card.querySelectorAll(".raw .b");
814
+ for (let k = 0; k < d.length && k < cells.length; k++) {
815
+ cells[k].textContent = d[k].toString(16).padStart(2, "0");
816
+ cells[k].classList.toggle("nz", d[k] !== 0);
763
817
  }
764
818
  }
765
819
 
@@ -866,28 +920,47 @@ function closeHelp() {
866
920
  // Nav scheme (per design): center/stick-click = confirm; any perimeter button = back; stick = directions.
867
921
  const inputEdge = {};
868
922
  let dirRepeatAt = 0;
923
+ // Latest decoded physical state per connected controller. Navigation/highlighting is driven by the
924
+ // *union* of all of them (either controller can move the cursor, confirm, back), while the
925
+ // active-profile indicator, wave tint and monitor follow the controller being edited (activeCtrl).
926
+ const ctrlState = new Map(); // device -> { buttons:Set, axes:[x,y] }
927
+ function mergedInput() {
928
+ const buttons = new Set();
929
+ let axes = [0, 0], best = 0;
930
+ for (const c of controllers) {
931
+ const st = ctrlState.get(c.device);
932
+ if (!st) continue;
933
+ for (const b of st.buttons) buttons.add(b);
934
+ const mag = Math.abs(st.axes[0]) + Math.abs(st.axes[1]); // whichever stick is pushed furthest steers
935
+ if (mag > best) { best = mag; axes = st.axes; }
936
+ }
937
+ return { buttons, axes };
938
+ }
869
939
  function onInputReport(e) {
870
- if (e.device !== controllers[activeCtrl]?.device) return;
940
+ if (!controllers.some((c) => c.device === e.device)) return; // ignore reports from a device mid-add
871
941
  const d = new Uint8Array(e.data.buffer.slice(e.data.byteOffset, e.data.byteOffset + e.data.byteLength));
872
942
  lastInputAt = performance.now();
873
943
  waveConnected = true; // a report is streaming -> the wave is visible
874
944
  const { buttons, axes, profile } = decodePhysical(d);
875
- liveAxes = axes;
876
- phys = buttons;
877
- // Track the controller's *active* profile (changed with the device's profile button). When it
878
- // changes, refresh the top-bar indicator and, if the monitor is open, re-render it for that profile.
879
- if (profile && profile - 1 !== deviceProfile) {
945
+ ctrlState.set(e.device, { buttons, axes, profile });
946
+ const isActive = e.device === controllers[activeCtrl]?.device;
947
+ // Active-profile tracking is per-device only the *edited* controller's profile drives the
948
+ // indicator/wave/monitor. (Refresh the top bar, wave and "✓ Active" marker when it changes.)
949
+ if (isActive && profile && profile - 1 !== deviceProfile) {
880
950
  deviceProfile = profile - 1;
881
951
  updateProfileTag();
882
- setWaveProfile(deviceProfile); // fade the wave's leading curves to match the active profile
883
- if (monitorMode) $("#mon-render").innerHTML = profileSVG(controllers[activeCtrl].profiles[deviceProfile]);
884
- else if (!monitorArm && !nav.drill) render(); // refresh the "✓ Active on controller" marker
952
+ setWaveProfile(deviceProfile);
953
+ if (!monitorMode && !monitorArm && !nav.drill) render(); // monitor cards re-render themselves
885
954
  }
886
- if (monitorMode) { updateMonitor(buttons, axes, d); setGpStatus(true); return; }
887
- if (monitorArm) { handleArmInput(buttons, axes); setGpStatus(true); return; }
888
- handlePhysInput(buttons, axes);
889
- updateLive();
955
+ // Unified live input (union of every connected controller) drives highlighting + navigation.
956
+ const m = mergedInput();
957
+ liveAxes = m.axes;
958
+ phys = m.buttons;
890
959
  setGpStatus(true);
960
+ if (monitorMode) { updateMonitor(e.device, buttons, axes, d, profile); return; } // every controller updates its card
961
+ if (monitorArm) { handleArmInput(m.buttons, m.axes); return; }
962
+ handlePhysInput(m.buttons, m.axes);
963
+ updateLive();
891
964
  }
892
965
 
893
966
  function handlePhysInput(buttons, axes) {
@@ -1031,6 +1104,7 @@ function init() {
1031
1104
  const idx = controllers.findIndex((c) => c.device === e.device);
1032
1105
  if (idx !== -1) {
1033
1106
  e.device.removeEventListener("inputreport", onInputReport);
1107
+ ctrlState.delete(e.device);
1034
1108
  controllers.splice(idx, 1);
1035
1109
  if (activeCtrl >= controllers.length) activeCtrl = Math.max(0, controllers.length - 1);
1036
1110
  deviceProfile = null;