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 +210 -0
- package/README.md +203 -0
- package/bridge.mjs +231 -0
- package/cli.mjs +339 -0
- package/lib/bridge-sinks.mjs +81 -0
- package/lib/hid-node.mjs +67 -0
- package/lib/uinput-helper.py +143 -0
- package/package.json +53 -0
- package/web/access-protocol.mjs +310 -0
- package/web/bridge-core.mjs +132 -0
- package/web/bridge-map.mjs +102 -0
- package/web/controller-render.mjs +79 -0
- package/web/hid-capture.html +142 -0
- package/web/hid-web.mjs +65 -0
- package/web/icon.svg +14 -0
- package/web/index.html +346 -0
- package/web/manifest.webmanifest +14 -0
- package/web/monitor.html +121 -0
- package/web/monitor.js +117 -0
- package/web/profile-library.mjs +181 -0
- package/web/serve.json +10 -0
- package/web/sw.js +39 -0
- package/web/xmb.js +1069 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ps-access — read/write PlayStation Access Controller profiles over USB-C (no PS5).
|
|
3
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
parseProfile, buildProfile, describeProfile, PROFILE_SIZE, PROFILE_COUNT,
|
|
7
|
+
ACTION_BY_NAME, ACTIONS, STICK_BY_NAME, ORIENTATION_BY_NAME,
|
|
8
|
+
} from "./web/access-protocol.mjs";
|
|
9
|
+
import { PRESETS, presetById, fromFileText, applyPortable, decodeShare, shareURL } from "./web/profile-library.mjs";
|
|
10
|
+
|
|
11
|
+
// node-hid is loaded lazily so offline commands (presets/share/show-share/help) work
|
|
12
|
+
// without it installed. Device commands call loadHid() first (see the dispatcher).
|
|
13
|
+
let _hid = null;
|
|
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})`); }
|
|
18
|
+
}
|
|
19
|
+
return _hid;
|
|
20
|
+
}
|
|
21
|
+
const listControllers = (...a) => _hid.listControllers(...a);
|
|
22
|
+
const openController = (...a) => _hid.openController(...a);
|
|
23
|
+
const readProfileRaw = (...a) => _hid.readProfileRaw(...a);
|
|
24
|
+
const writeProfileRaw = (...a) => _hid.writeProfileRaw(...a);
|
|
25
|
+
const setActiveProfile = (...a) => _hid.setActiveProfile(...a);
|
|
26
|
+
|
|
27
|
+
const CAPTURES = new URL("./captures/", import.meta.url).pathname;
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const opts = { _: [] };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (a === "--device" || a === "-d") opts.device = argv[++i];
|
|
34
|
+
else if (a === "--out" || a === "-o") opts.out = argv[++i];
|
|
35
|
+
else if (a === "--json") opts.json = true;
|
|
36
|
+
else if (a === "--yes" || a === "-y") opts.yes = true;
|
|
37
|
+
else opts._.push(a);
|
|
38
|
+
}
|
|
39
|
+
return opts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hex(bytes) {
|
|
43
|
+
return [...bytes].map((x) => x.toString(16).padStart(2, "0")).join(" ");
|
|
44
|
+
}
|
|
45
|
+
function stamp() {
|
|
46
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readAllProfiles(device) {
|
|
50
|
+
const profiles = [];
|
|
51
|
+
for (let n = 1; n <= PROFILE_COUNT; n++) profiles.push(readProfileRaw(device, n));
|
|
52
|
+
return profiles;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Auto-backup all profiles before any write, so every change is reversible.
|
|
56
|
+
function autoBackup(device, label) {
|
|
57
|
+
mkdirSync(CAPTURES, { recursive: true });
|
|
58
|
+
const raws = readAllProfiles(device);
|
|
59
|
+
const file = join(CAPTURES, `autobackup-${label}-${stamp()}.json`);
|
|
60
|
+
saveBackup(file, raws);
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveBackup(file, raws) {
|
|
65
|
+
const payload = {
|
|
66
|
+
device: "PlayStation Access Controller",
|
|
67
|
+
vidpid: "054c:0e5f",
|
|
68
|
+
savedAt: new Date().toISOString(),
|
|
69
|
+
profiles: raws.map((raw, i) => ({
|
|
70
|
+
slot: i + 1,
|
|
71
|
+
rawHex: Buffer.from(raw).toString("hex"),
|
|
72
|
+
decoded: parseProfile(raw),
|
|
73
|
+
})),
|
|
74
|
+
};
|
|
75
|
+
writeFileSync(file, JSON.stringify(payload, (k, v) => (k === "_raw" ? undefined : v), 2));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadRawFromFile(path, slot) {
|
|
79
|
+
const txt = readFileSync(path, "utf8").trim();
|
|
80
|
+
// JSON backup with profiles[].rawHex, or a bare hex/binary file.
|
|
81
|
+
if (txt.startsWith("{")) {
|
|
82
|
+
const obj = JSON.parse(txt);
|
|
83
|
+
const entry = (obj.profiles || []).find((p) => p.slot === slot) || obj.profiles?.[0];
|
|
84
|
+
if (!entry) throw new Error("no matching profile in backup file");
|
|
85
|
+
return Uint8Array.from(Buffer.from(entry.rawHex, "hex"));
|
|
86
|
+
}
|
|
87
|
+
if (/^[0-9a-fA-F\s]+$/.test(txt)) return Uint8Array.from(Buffer.from(txt.replace(/\s+/g, ""), "hex"));
|
|
88
|
+
const bin = readFileSync(path);
|
|
89
|
+
return Uint8Array.from(bin);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveAction(name) {
|
|
93
|
+
const n = String(name).toLowerCase();
|
|
94
|
+
if (n in ACTION_BY_NAME) return { kind: "action", code: ACTION_BY_NAME[n] };
|
|
95
|
+
if (n in STICK_BY_NAME) return { kind: "stick", code: STICK_BY_NAME[n] };
|
|
96
|
+
if (/^\d+$/.test(n)) return { kind: "action", code: Number(n) };
|
|
97
|
+
throw new Error(`unknown action "${name}". Try: ${Object.values(ACTIONS).join(", ")}, left stick, right stick`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve a portable profile from a preset id, a file path (web export / backup), or a share code/URL.
|
|
101
|
+
function resolvePortable(src, slotIdx) {
|
|
102
|
+
const preset = presetById(src);
|
|
103
|
+
if (preset) return preset.portable;
|
|
104
|
+
if (existsSync(src)) return fromFileText(readFileSync(src, "utf8"), slotIdx);
|
|
105
|
+
const code = src.includes("#p=") ? src.split("#p=")[1] : src; // share URL or bare code
|
|
106
|
+
return fromFileText(code, slotIdx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const COMMANDS = {
|
|
110
|
+
list() {
|
|
111
|
+
const list = listControllers();
|
|
112
|
+
if (!list.length) return console.log("No Access Controller connected (VID 054C / PID 0E5F).");
|
|
113
|
+
console.log(`${list.length} Access Controller(s):`);
|
|
114
|
+
for (const d of list) {
|
|
115
|
+
console.log(` [${d.index}] ${d.product} — ${d.manufacturer} serial=${d.serialNumber ?? "n/a"} path=${d.path}`);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
dump(opts) {
|
|
120
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
121
|
+
try {
|
|
122
|
+
console.log(`# ${info.product} [${info.index}] ${info.path}`);
|
|
123
|
+
for (let n = 1; n <= PROFILE_COUNT; n++) {
|
|
124
|
+
const raw = readProfileRaw(device, n);
|
|
125
|
+
console.log(`\n=== Profile ${n} ===`);
|
|
126
|
+
console.log(describeProfile(parseProfile(raw)));
|
|
127
|
+
console.log("raw:", hex(raw.slice(0, 32)), "...");
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
device.close();
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
backup(opts) {
|
|
135
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
136
|
+
try {
|
|
137
|
+
mkdirSync(CAPTURES, { recursive: true });
|
|
138
|
+
const raws = readAllProfiles(device);
|
|
139
|
+
const file = opts.out || join(CAPTURES, `backup-dev${info.index}-${stamp()}.json`);
|
|
140
|
+
saveBackup(file, raws);
|
|
141
|
+
console.log(`Backed up 3 profiles from controller [${info.index}] -> ${file}`);
|
|
142
|
+
} finally {
|
|
143
|
+
device.close();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
"set-active"(opts) {
|
|
148
|
+
const slot = Number(opts._[0]);
|
|
149
|
+
if (!(slot >= 1 && slot <= PROFILE_COUNT)) throw new Error("usage: set-active <1..3>");
|
|
150
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
151
|
+
try {
|
|
152
|
+
setActiveProfile(device, slot);
|
|
153
|
+
console.log(`Switched controller [${info.index}] to active Profile ${slot}.`);
|
|
154
|
+
} finally {
|
|
155
|
+
device.close();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
"read-profile"(opts) {
|
|
160
|
+
const slot = Number(opts._[0]);
|
|
161
|
+
if (!(slot >= 1 && slot <= PROFILE_COUNT)) throw new Error("usage: read-profile <1..3>");
|
|
162
|
+
const { device } = openController(opts.device ?? 0);
|
|
163
|
+
try {
|
|
164
|
+
const raw = readProfileRaw(device, slot);
|
|
165
|
+
const p = parseProfile(raw);
|
|
166
|
+
if (opts.json) console.log(JSON.stringify(p, (k, v) => (k === "_raw" ? undefined : v), 2));
|
|
167
|
+
else console.log(describeProfile(p));
|
|
168
|
+
} finally {
|
|
169
|
+
device.close();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
"write-profile"(opts) {
|
|
174
|
+
const slot = Number(opts._[0]);
|
|
175
|
+
const file = opts._[1];
|
|
176
|
+
if (!(slot >= 1 && slot <= PROFILE_COUNT) || !file) throw new Error("usage: write-profile <1..3> <file>");
|
|
177
|
+
const raw = loadRawFromFile(file, slot);
|
|
178
|
+
if (raw.length < PROFILE_SIZE) throw new Error(`file is ${raw.length} bytes, need ${PROFILE_SIZE}`);
|
|
179
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
180
|
+
try {
|
|
181
|
+
const bk = autoBackup(device, `dev${info.index}-pre-write`);
|
|
182
|
+
console.log(`(auto-backup -> ${bk})`);
|
|
183
|
+
writeProfileRaw(device, slot, raw);
|
|
184
|
+
const back = readProfileRaw(device, slot);
|
|
185
|
+
const ok = Buffer.compare(Buffer.from(back.slice(0, PROFILE_SIZE)), Buffer.from(raw.slice(0, PROFILE_SIZE))) === 0;
|
|
186
|
+
console.log(`Wrote profile ${slot}. Round-trip verify: ${ok ? "OK (byte-identical)" : "DIFFERS (device may normalize fields)"}`);
|
|
187
|
+
} finally {
|
|
188
|
+
device.close();
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// set <slot> button5=triangle | port1=cross | port0="left stick" | orientation=...
|
|
193
|
+
set(opts) {
|
|
194
|
+
const slot = Number(opts._[0]);
|
|
195
|
+
const assignment = opts._.slice(1).join(" ");
|
|
196
|
+
const m = assignment.match(/^(button\d+|port\d+|orientation)\s*=\s*(.+)$/i);
|
|
197
|
+
if (!(slot >= 1 && slot <= PROFILE_COUNT) || !m) {
|
|
198
|
+
throw new Error('usage: set <1..3> <button1..10|port0..4|orientation>=<action>');
|
|
199
|
+
}
|
|
200
|
+
const target = m[1].toLowerCase();
|
|
201
|
+
const value = m[2].replace(/^["']|["']$/g, "");
|
|
202
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
203
|
+
try {
|
|
204
|
+
const raw = readProfileRaw(device, slot);
|
|
205
|
+
const p = parseProfile(raw);
|
|
206
|
+
if (target === "orientation") {
|
|
207
|
+
const code = value.toLowerCase() in ORIENTATION_BY_NAME ? ORIENTATION_BY_NAME[value.toLowerCase()] : Number(value);
|
|
208
|
+
for (const port of p.ports) if (port.kind === "stick") port.orientation = code;
|
|
209
|
+
} else if (target.startsWith("button")) {
|
|
210
|
+
const i = Number(target.slice(6)) - 1;
|
|
211
|
+
if (!(i >= 0 && i < 10)) throw new Error("button must be 1..10");
|
|
212
|
+
p.buttons[i].map1 = resolveAction(value).code;
|
|
213
|
+
} else {
|
|
214
|
+
const i = Number(target.slice(4));
|
|
215
|
+
if (!(i >= 0 && i < 5)) throw new Error("port must be 0..4");
|
|
216
|
+
const a = resolveAction(value);
|
|
217
|
+
if (a.kind === "stick") p.ports[i] = { kind: "stick", stick: a.code, orientation: p.ports.find((x) => x.kind === "stick")?.orientation ?? 3 };
|
|
218
|
+
else if (a.code === 0) p.ports[i] = { kind: "none" };
|
|
219
|
+
else p.ports[i] = { kind: "button", analog: false, map1: a.code, map2: 0, toggle: false };
|
|
220
|
+
}
|
|
221
|
+
const bk = autoBackup(device, `dev${info.index}-pre-set`);
|
|
222
|
+
console.log(`(auto-backup -> ${bk})`);
|
|
223
|
+
const out = buildProfile(p, { now: Date.now() });
|
|
224
|
+
writeProfileRaw(device, slot, out);
|
|
225
|
+
console.log(`set profile ${slot} ${target}=${value}`);
|
|
226
|
+
console.log(describeProfile(parseProfile(readProfileRaw(device, slot))));
|
|
227
|
+
} finally {
|
|
228
|
+
device.close();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
restore(opts) {
|
|
233
|
+
const file = opts._[0];
|
|
234
|
+
if (!file) throw new Error("usage: restore <backup.json> [--device X]");
|
|
235
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
236
|
+
try {
|
|
237
|
+
const bk = autoBackup(device, `dev${info.index}-pre-restore`);
|
|
238
|
+
console.log(`(auto-backup -> ${bk})`);
|
|
239
|
+
for (let slot = 1; slot <= PROFILE_COUNT; slot++) {
|
|
240
|
+
const raw = loadRawFromFile(file, slot);
|
|
241
|
+
writeProfileRaw(device, slot, raw);
|
|
242
|
+
}
|
|
243
|
+
console.log(`Restored 3 profiles from ${file} to controller [${info.index}]`);
|
|
244
|
+
} finally {
|
|
245
|
+
device.close();
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
// Apply a portable profile (web Library export / share code / URL / preset id) onto a slot:
|
|
250
|
+
// reads the current profile as a base (so uuid + unmodeled fields survive), applies the
|
|
251
|
+
// mapping, writes it back, and verifies.
|
|
252
|
+
apply(opts) {
|
|
253
|
+
const src = opts._[0];
|
|
254
|
+
const slot = Number(opts._[1]);
|
|
255
|
+
if (!src || !(slot >= 1 && slot <= PROFILE_COUNT)) {
|
|
256
|
+
throw new Error("usage: apply <file.json | share-code | url | preset-id> <1..3>");
|
|
257
|
+
}
|
|
258
|
+
const portable = resolvePortable(src, slot - 1);
|
|
259
|
+
const { device, info } = openController(opts.device ?? 0);
|
|
260
|
+
try {
|
|
261
|
+
const bk = autoBackup(device, `dev${info.index}-pre-apply`);
|
|
262
|
+
console.log(`(auto-backup -> ${bk})`);
|
|
263
|
+
const base = parseProfile(readProfileRaw(device, slot));
|
|
264
|
+
applyPortable(base, portable);
|
|
265
|
+
writeProfileRaw(device, slot, buildProfile(base, { now: Date.now() }));
|
|
266
|
+
const back = parseProfile(readProfileRaw(device, slot));
|
|
267
|
+
console.log(`Applied ${portable.name ? `"${portable.name}"` : "profile"} to slot ${slot} on controller [${info.index}].`);
|
|
268
|
+
console.log(describeProfile(back));
|
|
269
|
+
} finally {
|
|
270
|
+
device.close();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
// List the built-in starting-point presets (shared with the web Library).
|
|
275
|
+
presets() {
|
|
276
|
+
console.log("Presets — apply as a starting point, then customize:");
|
|
277
|
+
for (const p of PRESETS) console.log(` ${p.id.padEnd(18)} ${p.name}\n ${p.description}`);
|
|
278
|
+
console.log(`\nUse one with: share / show-share, or apply it in the web Library, then Save.`);
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// Print a shareable link (and #p= code) for a profile in a backup file — works offline.
|
|
282
|
+
share(opts) {
|
|
283
|
+
const file = opts._[0];
|
|
284
|
+
if (!file) throw new Error("usage: share <backup.json> [slot 1..3]");
|
|
285
|
+
const slot = opts._[1] ? Number(opts._[1]) - 1 : 0;
|
|
286
|
+
const portable = fromFileText(readFileSync(file, "utf8"), slot);
|
|
287
|
+
console.log(shareURL(portable, "https://ps-access.johnhenry.me/"));
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// Decode a share link/code and describe what it contains — works offline.
|
|
291
|
+
"show-share"(opts) {
|
|
292
|
+
const arg = opts._[0];
|
|
293
|
+
if (!arg) throw new Error("usage: show-share <code|url>");
|
|
294
|
+
const code = arg.includes("#p=") ? arg.split("#p=")[1] : arg;
|
|
295
|
+
const portable = decodeShare(code);
|
|
296
|
+
console.log(describeProfile({ uuid: "(shared profile)", buttons: portable.buttons, ports: portable.ports, name: portable.name }));
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
help() {
|
|
300
|
+
console.log(`ps-access — PlayStation Access Controller profile tool (USB-C, no PS5)
|
|
301
|
+
|
|
302
|
+
Usage: node cli.mjs <command> [args] [--device <index|path>]
|
|
303
|
+
|
|
304
|
+
Commands:
|
|
305
|
+
list List connected controllers
|
|
306
|
+
dump Read + decode all 3 profiles
|
|
307
|
+
read-profile <1..3> [--json] Read + decode one profile
|
|
308
|
+
set-active <1..3> Switch the controller's active profile (like its profile button)
|
|
309
|
+
backup [--out file] Save all 3 profiles to captures/ (raw + decoded)
|
|
310
|
+
restore <backup.json> Write all 3 profiles back from a backup
|
|
311
|
+
write-profile <1..3> <file> Write one profile from a backup/hex/binary file
|
|
312
|
+
apply <src> <1..3> Apply a web export / share code / URL / preset id to a slot
|
|
313
|
+
set <1..3> <target>=<action> Edit one mapping, e.g.:
|
|
314
|
+
set 1 button5=triangle
|
|
315
|
+
set 1 port1=cross
|
|
316
|
+
set 1 "port0=left stick"
|
|
317
|
+
set 1 orientation="stick on the right"
|
|
318
|
+
presets List built-in starting-point presets
|
|
319
|
+
share <backup.json> [slot] Print a shareable link/code for a backed-up profile (offline)
|
|
320
|
+
show-share <code|url> Decode + describe a share link/code (offline)
|
|
321
|
+
|
|
322
|
+
Every write auto-backs-up first (captures/) and round-trip verifies.
|
|
323
|
+
Actions: ${Object.values(ACTIONS).join(", ")}, left stick, right stick`);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Commands that never touch the controller — usable without node-hid installed.
|
|
328
|
+
const OFFLINE = new Set(["presets", "share", "show-share", "help"]);
|
|
329
|
+
|
|
330
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
331
|
+
const cmd = opts._.shift() || "help";
|
|
332
|
+
const fn = COMMANDS[cmd] || COMMANDS.help;
|
|
333
|
+
try {
|
|
334
|
+
if (!OFFLINE.has(cmd) && COMMANDS[cmd]) await loadHid();
|
|
335
|
+
await fn(opts);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error("error:", e.message);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Output sinks for the PC input bridge. A sink turns the abstract events from
|
|
2
|
+
// BridgeEngine (key/button down·up, mouse motion, axes) into real OS input.
|
|
3
|
+
//
|
|
4
|
+
// Sinks expose: apply(events) -> void|Promise and close() -> void|Promise
|
|
5
|
+
//
|
|
6
|
+
// DryRunSink — prints events; no OS input. Works everywhere (default for --dry-run).
|
|
7
|
+
// XdotoolSink — keyboard + mouse via the `xdotool` CLI (X11). No native deps.
|
|
8
|
+
// UinputSink — virtual gamepad/keyboard via /dev/uinput, driven by a stdlib-only
|
|
9
|
+
// Python helper (no extra packages). Needs access to /dev/uinput
|
|
10
|
+
// (run as root or add a udev rule). Lowest latency.
|
|
11
|
+
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------- dry run
|
|
19
|
+
export class DryRunSink {
|
|
20
|
+
constructor({ log = (s) => console.log(s) } = {}) { this.log = log; this.count = 0; }
|
|
21
|
+
apply(events) {
|
|
22
|
+
for (const e of events) {
|
|
23
|
+
this.count++;
|
|
24
|
+
if (e.type === "key") this.log(` ${e.action === "down" ? "▼" : "▲"} ${e.code}`);
|
|
25
|
+
else if (e.type === "mouseMove") this.log(` ↔ mouse ${e.dx >= 0 ? "+" : ""}${e.dx},${e.dy >= 0 ? "+" : ""}${e.dy}`);
|
|
26
|
+
else if (e.type === "axis") this.log(` ⊹ axis ${e.code}=${e.value.toFixed(2)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
close() { this.log(`dry-run: ${this.count} events`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- xdotool (X11)
|
|
33
|
+
const XDO_MOUSE = { mouse1: 1, mouse2: 2, mouse3: 3 };
|
|
34
|
+
|
|
35
|
+
export class XdotoolSink {
|
|
36
|
+
constructor({ display = process.env.DISPLAY || ":0" } = {}) {
|
|
37
|
+
this.env = { ...process.env, DISPLAY: display };
|
|
38
|
+
const probe = spawnSync("xdotool", ["version"], { env: this.env });
|
|
39
|
+
if (probe.error) throw new Error("xdotool not found — install it (NixOS: nix-install xdotool)");
|
|
40
|
+
if (probe.status !== 0) throw new Error(`xdotool can't reach DISPLAY=${display}. Set --display.`);
|
|
41
|
+
}
|
|
42
|
+
apply(events) {
|
|
43
|
+
const args = [];
|
|
44
|
+
for (const e of events) {
|
|
45
|
+
if (e.type === "key") {
|
|
46
|
+
const mb = XDO_MOUSE[e.code];
|
|
47
|
+
if (mb) args.push(e.action === "down" ? "mousedown" : "mouseup", String(mb));
|
|
48
|
+
else args.push(e.action === "down" ? "keydown" : "keyup", e.code);
|
|
49
|
+
} else if (e.type === "mouseMove") {
|
|
50
|
+
args.push("mousemove_relative", "--", String(e.dx), String(e.dy));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (args.length) spawnSync("xdotool", args, { env: this.env });
|
|
54
|
+
}
|
|
55
|
+
close() { /* nothing persistent */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- uinput (Linux)
|
|
59
|
+
// Spawns the Python helper and streams it one JSON event per line.
|
|
60
|
+
export class UinputSink {
|
|
61
|
+
constructor({ kind = "gamepad", python = "python3" } = {}) {
|
|
62
|
+
const helper = join(HERE, "uinput-helper.py");
|
|
63
|
+
this.proc = spawn(python, [helper, kind], { stdio: ["pipe", "inherit", "inherit"] });
|
|
64
|
+
this.proc.on("error", (e) => { throw new Error(`couldn't start uinput helper: ${e.message}`); });
|
|
65
|
+
}
|
|
66
|
+
apply(events) {
|
|
67
|
+
if (!this.proc?.stdin.writable) return;
|
|
68
|
+
for (const e of events) this.proc.stdin.write(JSON.stringify(e) + "\n");
|
|
69
|
+
}
|
|
70
|
+
close() { try { this.proc.stdin.end(); this.proc.kill(); } catch { /* ignore */ } }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Factory used by the CLI.
|
|
74
|
+
export function makeSink(name, opts = {}) {
|
|
75
|
+
switch (name) {
|
|
76
|
+
case "dry-run": case "dryrun": case "dry": return new DryRunSink(opts);
|
|
77
|
+
case "xdotool": case "keyboard": return new XdotoolSink(opts);
|
|
78
|
+
case "uinput": case "gamepad": return new UinputSink({ kind: "gamepad", ...opts });
|
|
79
|
+
default: throw new Error(`unknown sink "${name}" (use: dry-run, xdotool, uinput)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
package/lib/hid-node.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// node-hid transport for the Access Controller. Wraps the platform-agnostic protocol
|
|
2
|
+
// in lib/access-protocol.mjs with actual feature-report I/O.
|
|
3
|
+
import HID from "node-hid";
|
|
4
|
+
import {
|
|
5
|
+
VENDOR_ID, PRODUCT_ID, REPORT_ID_CMD, REPORT_ID_DATA, CMD_PAYLOAD_SIZE,
|
|
6
|
+
PROFILE_SIZE, PACKETS_PER_PROFILE, buildReadCommand, assembleProfile, buildWritePackets,
|
|
7
|
+
buildSetActiveCommand,
|
|
8
|
+
} from "../web/access-protocol.mjs";
|
|
9
|
+
|
|
10
|
+
// All connected Access Controllers, in a stable order, with index labels.
|
|
11
|
+
export function listControllers() {
|
|
12
|
+
const devices = HID.devices().filter((d) => d.vendorId === VENDOR_ID && d.productId === PRODUCT_ID);
|
|
13
|
+
return devices.map((d, i) => ({
|
|
14
|
+
index: i,
|
|
15
|
+
path: d.path,
|
|
16
|
+
serialNumber: d.serialNumber || null,
|
|
17
|
+
product: d.product || "Access Controller",
|
|
18
|
+
manufacturer: d.manufacturer || "Sony",
|
|
19
|
+
release: d.release,
|
|
20
|
+
interface: d.interface,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Open a controller by index (default 0) or by HID path.
|
|
25
|
+
export function openController(selector = 0) {
|
|
26
|
+
const list = listControllers();
|
|
27
|
+
if (list.length === 0) throw new Error("No Access Controller found (VID 054C / PID 0E5F). Connect it via USB-C.");
|
|
28
|
+
let entry;
|
|
29
|
+
if (typeof selector === "string" && selector.startsWith("/")) entry = list.find((d) => d.path === selector);
|
|
30
|
+
else entry = list[Number(selector)];
|
|
31
|
+
if (!entry) throw new Error(`No controller for selector ${JSON.stringify(selector)}. ${list.length} connected.`);
|
|
32
|
+
const device = new HID.HID(entry.path);
|
|
33
|
+
return { device, info: entry };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read raw 956 bytes for profile N (1..3).
|
|
37
|
+
export function readProfileRaw(device, profileNumber) {
|
|
38
|
+
const cmd = buildReadCommand(profileNumber);
|
|
39
|
+
device.sendFeatureReport([REPORT_ID_CMD, ...cmd]);
|
|
40
|
+
const packets = [];
|
|
41
|
+
for (let i = 0; i < PACKETS_PER_PROFILE; i++) {
|
|
42
|
+
// node-hid returns the report data with the report id as the first byte.
|
|
43
|
+
const resp = device.getFeatureReport(REPORT_ID_DATA, CMD_PAYLOAD_SIZE + 1);
|
|
44
|
+
packets.push(resp);
|
|
45
|
+
}
|
|
46
|
+
return assembleProfile(packets);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Switch the controller's active profile (1..3) — like pressing its profile button.
|
|
50
|
+
export function setActiveProfile(device, profileNumber) {
|
|
51
|
+
device.sendFeatureReport([REPORT_ID_CMD, ...buildSetActiveCommand(profileNumber)]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Write raw 956 bytes to profile N (1..3). CRC handled by buildWritePackets.
|
|
55
|
+
export function writeProfileRaw(device, profileNumber, profileBytes) {
|
|
56
|
+
if (profileBytes.length < PROFILE_SIZE) throw new Error(`profile must be ${PROFILE_SIZE} bytes`);
|
|
57
|
+
const packets = buildWritePackets(profileNumber, profileBytes);
|
|
58
|
+
for (const pkt of packets) device.sendFeatureReport([REPORT_ID_CMD, ...pkt]);
|
|
59
|
+
// The device streams 0x61 status after a write; drain until byte 2 (remaining) is 0,
|
|
60
|
+
// otherwise the next read command desyncs and returns empty data.
|
|
61
|
+
for (let i = 0; i < 32; i++) {
|
|
62
|
+
const resp = device.getFeatureReport(REPORT_ID_DATA, CMD_PAYLOAD_SIZE + 1);
|
|
63
|
+
if (!resp || resp[2] === 0) break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { HID };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Virtual input device for the ps-access PC bridge — Linux /dev/uinput, stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads one JSON event per line on stdin (from lib/bridge-sinks.mjs UinputSink):
|
|
5
|
+
{"type":"key","code":"space","action":"down"}
|
|
6
|
+
{"type":"axis","code":"x","value":-1.0}
|
|
7
|
+
and injects them as a virtual keyboard or gamepad via /dev/uinput.
|
|
8
|
+
|
|
9
|
+
No third-party packages: just ctypes/struct/fcntl/os. Needs write access to
|
|
10
|
+
/dev/uinput (run as root, or add a udev rule granting your user the `input` group).
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
uinput-helper.py keyboard # virtual keyboard (xdotool-style keysyms)
|
|
14
|
+
uinput-helper.py gamepad # virtual gamepad (BTN_* + ABS_X/Y)
|
|
15
|
+
uinput-helper.py keyboard --selftest # print planned device setup, don't open uinput
|
|
16
|
+
"""
|
|
17
|
+
import sys, os, json, struct, fcntl, time
|
|
18
|
+
|
|
19
|
+
# ---- linux/uinput.h + input-event-codes.h constants ----
|
|
20
|
+
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
|
|
21
|
+
SYN_REPORT = 0
|
|
22
|
+
ABS_X, ABS_Y = 0x00, 0x01
|
|
23
|
+
BUS_USB = 0x03
|
|
24
|
+
|
|
25
|
+
def _IOC(d, t, nr, size): return (d << 30) | (size << 16) | (t << 8) | nr
|
|
26
|
+
_IOW = lambda t, nr, size: _IOC(1, t, nr, size)
|
|
27
|
+
_IO = lambda t, nr: _IOC(0, t, nr, 0)
|
|
28
|
+
UI_SET_EVBIT = _IOW(ord('U'), 100, 4)
|
|
29
|
+
UI_SET_KEYBIT = _IOW(ord('U'), 101, 4)
|
|
30
|
+
UI_SET_ABSBIT = _IOW(ord('U'), 103, 4)
|
|
31
|
+
UI_DEV_CREATE = _IO(ord('U'), 1)
|
|
32
|
+
UI_DEV_DESTROY = _IO(ord('U'), 2)
|
|
33
|
+
|
|
34
|
+
# A practical subset of KEY_* codes, keyed by the xdotool-style keysyms the bridge emits.
|
|
35
|
+
KEY = {
|
|
36
|
+
"space": 57, "Return": 28, "Escape": 1, "BackSpace": 14, "Tab": 15,
|
|
37
|
+
"shift": 42, "ctrl": 29, "alt": 56,
|
|
38
|
+
"Up": 103, "Down": 108, "Left": 105, "Right": 106,
|
|
39
|
+
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, "h": 35,
|
|
40
|
+
"i": 23, "j": 36, "k": 37, "l": 38, "m": 50, "n": 49, "o": 24, "p": 25,
|
|
41
|
+
"q": 16, "r": 19, "s": 31, "t": 20, "u": 22, "v": 47, "w": 17, "x": 45,
|
|
42
|
+
"y": 21, "z": 44,
|
|
43
|
+
"1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10, "0": 11,
|
|
44
|
+
}
|
|
45
|
+
# Gamepad buttons (BTN_*), keyed by friendly names a gamepad mapping would use.
|
|
46
|
+
BTN = {
|
|
47
|
+
"BTN_SOUTH": 0x130, "BTN_EAST": 0x131, "BTN_NORTH": 0x133, "BTN_WEST": 0x134,
|
|
48
|
+
"BTN_TL": 0x136, "BTN_TR": 0x137, "BTN_SELECT": 0x13a, "BTN_START": 0x13b,
|
|
49
|
+
"BTN_THUMBL": 0x13d, "BTN_THUMBR": 0x13e,
|
|
50
|
+
# friendly aliases
|
|
51
|
+
"cross": 0x130, "circle": 0x131, "triangle": 0x133, "square": 0x134,
|
|
52
|
+
"l1": 0x136, "r1": 0x137, "select": 0x13a, "start": 0x13b,
|
|
53
|
+
}
|
|
54
|
+
ABS_RANGE = (-32767, 32767)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def codes_for(kind):
|
|
58
|
+
return KEY if kind == "keyboard" else {**BTN, **KEY}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build(kind, selftest=False):
|
|
62
|
+
table = codes_for(kind)
|
|
63
|
+
keycodes = sorted(set(table.values()))
|
|
64
|
+
name = f"ps-access {kind}".encode()[:79]
|
|
65
|
+
if selftest:
|
|
66
|
+
print(f"[selftest] device='{name.decode()}' kind={kind} "
|
|
67
|
+
f"keys={len(keycodes)} abs={'X,Y' if kind=='gamepad' else 'none'}")
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
|
71
|
+
except (PermissionError, FileNotFoundError) as ex:
|
|
72
|
+
print(f"uinput: cannot open /dev/uinput ({ex}). Run as root, or grant access with a "
|
|
73
|
+
f"udev rule (add your user to a group that owns /dev/uinput).", file=sys.stderr)
|
|
74
|
+
sys.exit(13)
|
|
75
|
+
fcntl.ioctl(fd, UI_SET_EVBIT, EV_KEY)
|
|
76
|
+
fcntl.ioctl(fd, UI_SET_EVBIT, EV_SYN)
|
|
77
|
+
for code in keycodes:
|
|
78
|
+
fcntl.ioctl(fd, UI_SET_KEYBIT, code)
|
|
79
|
+
absmin = [0] * 64
|
|
80
|
+
absmax = [0] * 64
|
|
81
|
+
if kind == "gamepad":
|
|
82
|
+
fcntl.ioctl(fd, UI_SET_EVBIT, EV_ABS)
|
|
83
|
+
for ax in (ABS_X, ABS_Y):
|
|
84
|
+
fcntl.ioctl(fd, UI_SET_ABSBIT, ax)
|
|
85
|
+
absmin[ax], absmax[ax] = ABS_RANGE
|
|
86
|
+
# struct uinput_user_dev: char name[80]; input_id(4*u16); u32 ff; s32 absmax/min/fuzz/flat[64]
|
|
87
|
+
dev = struct.pack("80sHHHHi", name, BUS_USB, 0x054c, 0x0e5f, 1, 0)
|
|
88
|
+
dev += struct.pack("64i", *absmax) + struct.pack("64i", *absmin)
|
|
89
|
+
dev += struct.pack("64i", *([0] * 64)) + struct.pack("64i", *([0] * 64))
|
|
90
|
+
os.write(fd, dev)
|
|
91
|
+
fcntl.ioctl(fd, UI_DEV_CREATE)
|
|
92
|
+
time.sleep(0.2) # give udev a moment to create the node
|
|
93
|
+
return fd
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit(fd, etype, code, value):
|
|
97
|
+
# struct input_event: timeval(2*long) + u16 type + u16 code + s32 value
|
|
98
|
+
os.write(fd, struct.pack("llHHi", 0, 0, etype, code, value))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def syn(fd):
|
|
102
|
+
emit(fd, EV_SYN, SYN_REPORT, 0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
107
|
+
kind = args[0] if args else "keyboard"
|
|
108
|
+
selftest = "--selftest" in sys.argv
|
|
109
|
+
if kind not in ("keyboard", "gamepad"):
|
|
110
|
+
print(f"unknown kind '{kind}' (keyboard|gamepad)", file=sys.stderr); sys.exit(2)
|
|
111
|
+
fd = build(kind, selftest)
|
|
112
|
+
if selftest:
|
|
113
|
+
return
|
|
114
|
+
table = codes_for(kind)
|
|
115
|
+
try:
|
|
116
|
+
for line in sys.stdin:
|
|
117
|
+
line = line.strip()
|
|
118
|
+
if not line:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
e = json.loads(line)
|
|
122
|
+
except ValueError:
|
|
123
|
+
continue
|
|
124
|
+
if e.get("type") == "key":
|
|
125
|
+
code = table.get(e.get("code"))
|
|
126
|
+
if code is not None:
|
|
127
|
+
emit(fd, EV_KEY, code, 1 if e.get("action") == "down" else 0)
|
|
128
|
+
syn(fd)
|
|
129
|
+
elif e.get("type") == "axis" and kind == "gamepad":
|
|
130
|
+
ax = ABS_X if e.get("code") == "x" else ABS_Y
|
|
131
|
+
val = int(max(-1.0, min(1.0, float(e.get("value", 0)))) * ABS_RANGE[1])
|
|
132
|
+
emit(fd, EV_ABS, ax, val)
|
|
133
|
+
syn(fd)
|
|
134
|
+
finally:
|
|
135
|
+
try:
|
|
136
|
+
fcntl.ioctl(fd, UI_DEV_DESTROY)
|
|
137
|
+
os.close(fd)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ps-access",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Read/write PlayStation Access Controller profiles from a PC (no PS5). CLI + WebHID tool + PC input bridge.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ps-access": "./cli.mjs",
|
|
8
|
+
"ps-access-bridge": "./bridge.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "npx --yes serve -l 3000 .",
|
|
12
|
+
"list": "node cli.mjs list",
|
|
13
|
+
"backup": "node cli.mjs backup",
|
|
14
|
+
"bridge": "node bridge.mjs",
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"cli.mjs",
|
|
19
|
+
"bridge.mjs",
|
|
20
|
+
"lib/",
|
|
21
|
+
"web/",
|
|
22
|
+
"PROTOCOL.md",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"playstation",
|
|
27
|
+
"access-controller",
|
|
28
|
+
"ps5",
|
|
29
|
+
"accessibility",
|
|
30
|
+
"adaptive-controller",
|
|
31
|
+
"webhid",
|
|
32
|
+
"hid",
|
|
33
|
+
"node-hid",
|
|
34
|
+
"remapping",
|
|
35
|
+
"assistive-technology"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/johnhenry/ps-access.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://ps-access.johnhenry.me",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/johnhenry/ps-access/issues"
|
|
47
|
+
},
|
|
48
|
+
"author": "johnhenry",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"node-hid": "^3.1.2"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|