ps-access 0.0.3 → 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 +28 -27
- package/bridge.mjs +33 -18
- package/cli.mjs +31 -10
- package/lib/hid-node.mjs +1 -0
- package/package.json +3 -4
- package/web/bridge-map.mjs +2 -2
- package/web/index.html +29 -22
- package/web/xmb.js +64 -30
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 (`
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
191
|
-
bridge.mjs
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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 {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
210
|
-
const opts = parseArgs(
|
|
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
|
-
|
|
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 (
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
312
|
+
console.log(`ps-access — PlayStation Access Controller tool (USB-C, no PS5)
|
|
301
313
|
|
|
302
|
-
Usage:
|
|
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
|
|
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 (
|
|
335
|
-
|
|
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.
|
|
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
|
|
13
|
+
"bridge": "node cli.mjs bridge",
|
|
15
14
|
"test": "node --test"
|
|
16
15
|
},
|
|
17
16
|
"files": [
|
package/web/bridge-map.mjs
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
273
|
-
|
|
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) & <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
|
-
<
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
|
@@ -701,14 +701,37 @@ async function reloadFromDevice() {
|
|
|
701
701
|
// ============================ live input monitor ============================
|
|
702
702
|
// Full-screen overlay. The controller is purely observed here (navigation is suspended), so
|
|
703
703
|
// every physical button and the stick can be tested freely; exit with Esc or the Done button.
|
|
704
|
-
function
|
|
705
|
-
|
|
704
|
+
function monChipsHTML() {
|
|
705
|
+
return PHYS_NAMES.map((n, i) =>
|
|
706
706
|
`<div class="chip" data-i="${i}">${i < 8 ? n : n.split("-")[0]}<small>${i < 8 ? "button" : (i === 8 ? "center" : "L3")}</small></div>`).join("");
|
|
707
707
|
}
|
|
708
|
-
function
|
|
708
|
+
function monRawHTML() {
|
|
709
709
|
let h = "";
|
|
710
710
|
for (let i = 0; i < 63; i++) h += `<div class="b${i === 15 || i === 16 ? " btn" : ""}" data-i="${i}">00</div>`;
|
|
711
|
-
|
|
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);
|
|
712
735
|
}
|
|
713
736
|
// Step 1: a PS3-style confirm gate warning that the controller can't exit this view (Esc / Done
|
|
714
737
|
// only). A navigable two-option list — Start / Cancel — operable by keyboard (↑↓ + Enter / Esc),
|
|
@@ -742,13 +765,17 @@ function pickArm() { warnSel === 0 ? confirmArm() : cancelArm(); }
|
|
|
742
765
|
// Step 2: enter the live monitor, rendering the *chosen* profile (so the controller image
|
|
743
766
|
// matches that profile's orientation) and showing which profile is on screen.
|
|
744
767
|
function enterMonitor() {
|
|
745
|
-
if (!controllers
|
|
746
|
-
const prof = controllers[activeCtrl].profiles[shownProfileSlot()]; // the active on-device profile
|
|
768
|
+
if (!controllers.length) { toast("Connect a controller first"); return; }
|
|
747
769
|
monitorMode = true;
|
|
748
|
-
$("#mon-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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();
|
|
752
779
|
$("#monitor").classList.add("show");
|
|
753
780
|
$("#stage").style.display = "none";
|
|
754
781
|
$(".footer").style.display = "none"; // its nav hints don't apply while observing
|
|
@@ -762,23 +789,31 @@ function exitMonitor() {
|
|
|
762
789
|
blip(330);
|
|
763
790
|
render();
|
|
764
791
|
}
|
|
765
|
-
|
|
766
|
-
|
|
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]"))
|
|
767
802
|
el.classList.toggle("on", buttons.has(+el.getAttribute("data-btn")));
|
|
768
|
-
const thumb =
|
|
803
|
+
const thumb = card.querySelector(".r svg .thumb");
|
|
769
804
|
if (thumb) {
|
|
770
805
|
thumb.setAttribute("cx", (+thumb.dataset.bx + axes[0] * M.THUMB_R).toFixed(1));
|
|
771
806
|
thumb.setAttribute("cy", (+thumb.dataset.by + axes[1] * M.THUMB_R).toFixed(1));
|
|
772
807
|
}
|
|
773
|
-
for (const c of
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const cells =
|
|
779
|
-
for (let
|
|
780
|
-
cells[
|
|
781
|
-
cells[
|
|
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);
|
|
782
817
|
}
|
|
783
818
|
}
|
|
784
819
|
|
|
@@ -907,7 +942,7 @@ function onInputReport(e) {
|
|
|
907
942
|
lastInputAt = performance.now();
|
|
908
943
|
waveConnected = true; // a report is streaming -> the wave is visible
|
|
909
944
|
const { buttons, axes, profile } = decodePhysical(d);
|
|
910
|
-
ctrlState.set(e.device, { buttons, axes });
|
|
945
|
+
ctrlState.set(e.device, { buttons, axes, profile });
|
|
911
946
|
const isActive = e.device === controllers[activeCtrl]?.device;
|
|
912
947
|
// Active-profile tracking is per-device — only the *edited* controller's profile drives the
|
|
913
948
|
// indicator/wave/monitor. (Refresh the top bar, wave and "✓ Active" marker when it changes.)
|
|
@@ -915,15 +950,14 @@ function onInputReport(e) {
|
|
|
915
950
|
deviceProfile = profile - 1;
|
|
916
951
|
updateProfileTag();
|
|
917
952
|
setWaveProfile(deviceProfile);
|
|
918
|
-
if (monitorMode)
|
|
919
|
-
else if (!monitorArm && !nav.drill) render();
|
|
953
|
+
if (!monitorMode && !monitorArm && !nav.drill) render(); // monitor cards re-render themselves
|
|
920
954
|
}
|
|
921
955
|
// Unified live input (union of every connected controller) drives highlighting + navigation.
|
|
922
956
|
const m = mergedInput();
|
|
923
957
|
liveAxes = m.axes;
|
|
924
958
|
phys = m.buttons;
|
|
925
959
|
setGpStatus(true);
|
|
926
|
-
if (monitorMode) {
|
|
960
|
+
if (monitorMode) { updateMonitor(e.device, buttons, axes, d, profile); return; } // every controller updates its card
|
|
927
961
|
if (monitorArm) { handleArmInput(m.buttons, m.axes); return; }
|
|
928
962
|
handlePhysInput(m.buttons, m.axes);
|
|
929
963
|
updateLive();
|