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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// PlayStation Access Controller profile protocol.
|
|
2
|
+
//
|
|
3
|
+
// Pure, I/O-free. Works in Node and the browser (operates on Uint8Array / DataView).
|
|
4
|
+
// Transcribed and verified against jfedor's web editor (https://www.jfedor.org/ps-access/),
|
|
5
|
+
// credited in PROTOCOL.md. No PS5 required.
|
|
6
|
+
|
|
7
|
+
// ---- Device identity ----
|
|
8
|
+
export const VENDOR_ID = 0x054c; // Sony
|
|
9
|
+
export const PRODUCT_ID = 0x0e5f; // Access Controller
|
|
10
|
+
|
|
11
|
+
// ---- Feature reports / transport ----
|
|
12
|
+
export const REPORT_ID_CMD = 0x60; // host -> device command channel
|
|
13
|
+
export const REPORT_ID_DATA = 0x61; // device -> host data channel
|
|
14
|
+
export const BT_ONLY_REPORT_ID = 99; // present only over Bluetooth; absent over USB (profile channel is USB-only)
|
|
15
|
+
export const CMD_PAYLOAD_SIZE = 63; // bytes after the report id in a 0x60 packet
|
|
16
|
+
export const PROFILE_SIZE = 956; // bytes of a single profile blob
|
|
17
|
+
export const PROFILE_COUNT = 3; // on-device profile slots (1..3)
|
|
18
|
+
const PACKET_COUNT = 18; // 18 * 56 = 1008 >= 956
|
|
19
|
+
const CHUNK = 56; // payload bytes per packet
|
|
20
|
+
const READ_PAYLOAD_OFFSET = 4; // payload offset within a 0x61 response (after report id + header)
|
|
21
|
+
const WRITE_PAYLOAD_OFFSET = 2; // payload offset within a 0x60 write packet
|
|
22
|
+
|
|
23
|
+
// ---- CRC-32 (standard zlib/IEEE, port of crc.js) ----
|
|
24
|
+
const CRC_TABLE = (() => {
|
|
25
|
+
const t = new Uint32Array(256);
|
|
26
|
+
for (let n = 0; n < 256; n++) {
|
|
27
|
+
let c = n;
|
|
28
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
29
|
+
t[n] = c >>> 0;
|
|
30
|
+
}
|
|
31
|
+
return t;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
export function crc32(bytes, length = bytes.length) {
|
|
35
|
+
let c = 0xffffffff;
|
|
36
|
+
for (let n = 0; n < length; n++) c = CRC_TABLE[(c ^ bytes[n]) & 0xff] ^ (c >>> 8);
|
|
37
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Enum tables (from index.html dropdowns) ----
|
|
41
|
+
// Button action codes (used for physical buttons and expansion-port-as-button).
|
|
42
|
+
export const ACTIONS = {
|
|
43
|
+
0: "nothing", 1: "circle", 2: "cross", 3: "triangle", 4: "square",
|
|
44
|
+
5: "up", 6: "down", 7: "left", 8: "right", 9: "L1", 10: "R1",
|
|
45
|
+
11: "L2", 12: "R2", 13: "L3", 14: "R3", 15: "options", 16: "create",
|
|
46
|
+
17: "PS", 18: "touchpad",
|
|
47
|
+
};
|
|
48
|
+
// Stick assignment for ports configured as a stick (stored value = code - 100).
|
|
49
|
+
export const STICKS = { 1: "left stick", 2: "right stick" };
|
|
50
|
+
// Built-in stick orientation.
|
|
51
|
+
export const ORIENTATIONS = {
|
|
52
|
+
0: "stick below", 1: "stick on the right", 2: "stick above", 3: "stick on the left",
|
|
53
|
+
};
|
|
54
|
+
// Stick tuning. The device stores 0 for "firmware default"; these are the values jfedor's
|
|
55
|
+
// editor writes as the PS5 "default" preset. Exact value semantics are not publicly
|
|
56
|
+
// documented, so the UI exposes these as adjustable/experimental.
|
|
57
|
+
export const STICK_DEFAULT_SENSITIVITY = 3;
|
|
58
|
+
export const STICK_DEFAULT_DEADZONE = [0x80, 0x80, 0xc4, 0xc4, 0xe1, 0xe1];
|
|
59
|
+
|
|
60
|
+
export const ACTION_BY_NAME = invert(ACTIONS);
|
|
61
|
+
export const STICK_BY_NAME = invert(STICKS);
|
|
62
|
+
export const ORIENTATION_BY_NAME = invert(ORIENTATIONS);
|
|
63
|
+
function invert(o) {
|
|
64
|
+
const r = {};
|
|
65
|
+
for (const [k, v] of Object.entries(o)) r[v.toLowerCase()] = Number(k);
|
|
66
|
+
return r;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- Profile layout offsets ----
|
|
70
|
+
const OFF = {
|
|
71
|
+
sentinel: 0, // u8 == 0x02
|
|
72
|
+
name: 4, // UTF-16LE, up to 40 chars
|
|
73
|
+
uuid: 84, // 16 bytes
|
|
74
|
+
buttons: 100, // 10 * 5 bytes: [map1, map2, ...]
|
|
75
|
+
toggle: 150, // u16 LE bitfield: bits 0..9 buttons, bits 9+port ports
|
|
76
|
+
ports: 152, // 5 * 45 bytes
|
|
77
|
+
timestamp: 948, // i64 LE
|
|
78
|
+
};
|
|
79
|
+
const BUTTON_COUNT = 10;
|
|
80
|
+
const BUTTON_STRIDE = 5;
|
|
81
|
+
const PORT_COUNT = 5;
|
|
82
|
+
const PORT_STRIDE = 45;
|
|
83
|
+
const NAME_MAX = 40;
|
|
84
|
+
|
|
85
|
+
// Decode a 956-byte profile blob into an editable object. Keeps the raw bytes so a
|
|
86
|
+
// write can preserve fields we don't model (stick sensitivity/deadzone, uuid, etc.).
|
|
87
|
+
export function parseProfile(bytes) {
|
|
88
|
+
const u8 = u8of(bytes);
|
|
89
|
+
if (u8.length < PROFILE_SIZE) throw new Error(`profile too short: ${u8.length}`);
|
|
90
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
91
|
+
if (dv.getUint8(OFF.sentinel) !== 0x02) {
|
|
92
|
+
throw new Error(`expected byte 0 == 0x02, got 0x${dv.getUint8(0).toString(16)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let name = "";
|
|
96
|
+
for (let i = 0; i < NAME_MAX; i++) {
|
|
97
|
+
const c = dv.getUint16(OFF.name + 2 * i, true);
|
|
98
|
+
if (c === 0) break;
|
|
99
|
+
name += String.fromCharCode(c);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const toggleBits = dv.getUint16(OFF.toggle, true);
|
|
103
|
+
const buttons = [];
|
|
104
|
+
for (let b = 0; b < BUTTON_COUNT; b++) {
|
|
105
|
+
const base = OFF.buttons + b * BUTTON_STRIDE;
|
|
106
|
+
buttons.push({
|
|
107
|
+
map1: dv.getUint8(base),
|
|
108
|
+
map2: dv.getUint8(base + 1),
|
|
109
|
+
toggle: (toggleBits & (1 << b)) !== 0,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ports = [];
|
|
114
|
+
for (let p = 0; p < PORT_COUNT; p++) {
|
|
115
|
+
const base = OFF.ports + p * PORT_STRIDE;
|
|
116
|
+
const type = dv.getUint8(base);
|
|
117
|
+
if (type === 0x00) {
|
|
118
|
+
ports.push({ kind: "none" });
|
|
119
|
+
} else if (type === 0x01) {
|
|
120
|
+
ports.push({
|
|
121
|
+
kind: "stick",
|
|
122
|
+
stick: dv.getUint8(base + 1), // 1=left, 2=right
|
|
123
|
+
orientation: dv.getUint8(base + 2),
|
|
124
|
+
sensitivity: dv.getUint8(base + 5), // 0 = firmware default
|
|
125
|
+
deadzone: [...u8.slice(base + 8, base + 14)], // 6 bytes, 0 = firmware default
|
|
126
|
+
});
|
|
127
|
+
} else if (type === 0x02 || type === 0x03) {
|
|
128
|
+
ports.push({
|
|
129
|
+
kind: "button",
|
|
130
|
+
analog: type === 0x02,
|
|
131
|
+
map1: dv.getUint8(base + 2),
|
|
132
|
+
map2: dv.getUint8(base + 3),
|
|
133
|
+
toggle: (toggleBits & (1 << (9 + p))) !== 0,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
throw new Error(`unexpected expansion-port type 0x${type.toString(16)} on port ${p}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const uuid = [...u8.slice(OFF.uuid, OFF.uuid + 16)]
|
|
141
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
142
|
+
.join("");
|
|
143
|
+
const timestamp = Number(dv.getBigInt64(OFF.timestamp, true));
|
|
144
|
+
|
|
145
|
+
return { name, uuid, timestamp, buttons, ports, _raw: u8.slice(0, PROFILE_SIZE) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Build a 956-byte profile blob from an object. Starts from `_raw` when present so
|
|
149
|
+
// unmodeled fields (stick sensitivity/deadzone) survive a round trip. Pass a Date.now()
|
|
150
|
+
// value as `now` to refresh the timestamp (caller supplies it; the lib stays pure).
|
|
151
|
+
export function buildProfile(profile, { now = null, regenerateUuid = false, randomBytes = null } = {}) {
|
|
152
|
+
const out = profile._raw ? u8of(profile._raw).slice(0, PROFILE_SIZE) : new Uint8Array(PROFILE_SIZE);
|
|
153
|
+
const dv = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
154
|
+
out[OFF.sentinel] = 0x02;
|
|
155
|
+
|
|
156
|
+
if (profile.name != null) {
|
|
157
|
+
for (let i = 0; i < NAME_MAX; i++) {
|
|
158
|
+
dv.setUint16(OFF.name + 2 * i, i < profile.name.length ? profile.name.charCodeAt(i) : 0, true);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (regenerateUuid) {
|
|
163
|
+
const rnd = randomBytes || defaultRandom(16);
|
|
164
|
+
out.set(rnd.slice(0, 16), OFF.uuid);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let toggleBits = 0;
|
|
168
|
+
const buttons = profile.buttons || [];
|
|
169
|
+
for (let b = 0; b < BUTTON_COUNT; b++) {
|
|
170
|
+
const base = OFF.buttons + b * BUTTON_STRIDE;
|
|
171
|
+
const btn = buttons[b];
|
|
172
|
+
if (btn) {
|
|
173
|
+
dv.setUint8(base, btn.map1 & 0xff);
|
|
174
|
+
dv.setUint8(base + 1, btn.map2 & 0xff);
|
|
175
|
+
if (btn.toggle) toggleBits |= 1 << b;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ports = profile.ports || [];
|
|
180
|
+
for (let p = 0; p < PORT_COUNT; p++) {
|
|
181
|
+
const base = OFF.ports + p * PORT_STRIDE;
|
|
182
|
+
const port = ports[p];
|
|
183
|
+
if (!port) continue;
|
|
184
|
+
if (port.kind === "none") {
|
|
185
|
+
dv.setUint8(base, 0x00);
|
|
186
|
+
} else if (port.kind === "stick") {
|
|
187
|
+
dv.setUint8(base, 0x01);
|
|
188
|
+
dv.setUint8(base + 1, port.stick & 0xff);
|
|
189
|
+
dv.setUint8(base + 2, port.orientation & 0xff);
|
|
190
|
+
// Stick tuning. 0 = firmware default. Written explicitly when the model carries them
|
|
191
|
+
// (so edits persist); otherwise the bytes from _raw are kept as-is.
|
|
192
|
+
if (port.sensitivity != null) dv.setUint8(base + 5, port.sensitivity & 0xff);
|
|
193
|
+
if (Array.isArray(port.deadzone)) {
|
|
194
|
+
for (let i = 0; i < 6; i++) dv.setUint8(base + 8 + i, port.deadzone[i] & 0xff);
|
|
195
|
+
}
|
|
196
|
+
} else if (port.kind === "button") {
|
|
197
|
+
dv.setUint8(base, port.analog ? 0x02 : 0x03);
|
|
198
|
+
dv.setUint8(base + 2, port.map1 & 0xff);
|
|
199
|
+
dv.setUint8(base + 3, port.map2 & 0xff);
|
|
200
|
+
if (port.toggle) toggleBits |= 1 << (9 + p);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
dv.setUint16(OFF.toggle, toggleBits, true);
|
|
205
|
+
if (now != null) dv.setBigInt64(OFF.timestamp, BigInt(now), true);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- Transport packet builders (platform-agnostic) ----
|
|
210
|
+
|
|
211
|
+
// 0x60 command requesting profile N (1..3) be streamed back on 0x61.
|
|
212
|
+
export function buildReadCommand(profileNumber) {
|
|
213
|
+
checkProfileNumber(profileNumber);
|
|
214
|
+
const buf = new Uint8Array(CMD_PAYLOAD_SIZE);
|
|
215
|
+
buf[0] = 0x10 + (profileNumber - 1);
|
|
216
|
+
return buf;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 0x60 command that switches the controller's *active* profile (1..3) — the same thing the
|
|
220
|
+
// device's profile button does. Reverse-engineered by probing the command channel while
|
|
221
|
+
// watching input-report byte 39: opcode 0x05 with the 1-based profile number in byte 1.
|
|
222
|
+
export const CMD_SET_ACTIVE = 0x05;
|
|
223
|
+
export function buildSetActiveCommand(profileNumber) {
|
|
224
|
+
checkProfileNumber(profileNumber);
|
|
225
|
+
const buf = new Uint8Array(CMD_PAYLOAD_SIZE);
|
|
226
|
+
buf[0] = CMD_SET_ACTIVE;
|
|
227
|
+
buf[1] = profileNumber;
|
|
228
|
+
return buf;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Reassemble a 956-byte profile from the 18 raw 0x61 responses. Each response is the
|
|
232
|
+
// full report (report id at [0]); payload starts at READ_PAYLOAD_OFFSET.
|
|
233
|
+
export function assembleProfile(packets) {
|
|
234
|
+
const out = new Uint8Array(PROFILE_SIZE);
|
|
235
|
+
for (let i = 0; i < PACKET_COUNT; i++) {
|
|
236
|
+
const pkt = u8of(packets[i]);
|
|
237
|
+
for (let j = 0; j < CHUNK; j++) {
|
|
238
|
+
const idx = i * CHUNK + j;
|
|
239
|
+
if (idx < PROFILE_SIZE) out[idx] = pkt[READ_PAYLOAD_OFFSET + j] ?? 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build the 18 0x60 write packets for profile N from a 956-byte blob. CRC32 (LE) of the
|
|
246
|
+
// whole blob is embedded at offset 6 of the final packet. Returns CMD_PAYLOAD_SIZE buffers
|
|
247
|
+
// (no report id prefix — the transport adds 0x60).
|
|
248
|
+
export function buildWritePackets(profileNumber, profileBytes) {
|
|
249
|
+
checkProfileNumber(profileNumber);
|
|
250
|
+
const data = u8of(profileBytes);
|
|
251
|
+
if (data.length < PROFILE_SIZE) throw new Error(`profile too short: ${data.length}`);
|
|
252
|
+
const crc = crc32(data, PROFILE_SIZE);
|
|
253
|
+
const packets = [];
|
|
254
|
+
for (let i = 0; i < PACKET_COUNT; i++) {
|
|
255
|
+
const buf = new Uint8Array(CMD_PAYLOAD_SIZE);
|
|
256
|
+
const dv = new DataView(buf.buffer);
|
|
257
|
+
buf[0] = 0x08 + profileNumber;
|
|
258
|
+
buf[1] = i;
|
|
259
|
+
for (let j = 0; j < CHUNK; j++) {
|
|
260
|
+
const idx = i * CHUNK + j;
|
|
261
|
+
if (idx < PROFILE_SIZE) buf[WRITE_PAYLOAD_OFFSET + j] = data[idx];
|
|
262
|
+
}
|
|
263
|
+
if (i === PACKET_COUNT - 1) dv.setUint32(6, crc, true);
|
|
264
|
+
packets.push(buf);
|
|
265
|
+
}
|
|
266
|
+
return packets;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const PACKETS_PER_PROFILE = PACKET_COUNT;
|
|
270
|
+
|
|
271
|
+
// ---- helpers ----
|
|
272
|
+
function checkProfileNumber(n) {
|
|
273
|
+
if (!Number.isInteger(n) || n < 1 || n > PROFILE_COUNT) {
|
|
274
|
+
throw new Error(`profile number must be 1..${PROFILE_COUNT}, got ${n}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function u8of(x) {
|
|
278
|
+
if (x instanceof Uint8Array) return x;
|
|
279
|
+
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
280
|
+
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
281
|
+
if (Array.isArray(x)) return Uint8Array.from(x);
|
|
282
|
+
throw new Error("expected bytes");
|
|
283
|
+
}
|
|
284
|
+
function defaultRandom(n) {
|
|
285
|
+
const a = new Uint8Array(n);
|
|
286
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") globalThis.crypto.getRandomValues(a);
|
|
287
|
+
return a;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Human-readable summary used by the CLI and (optionally) the UI.
|
|
291
|
+
export function describeProfile(p) {
|
|
292
|
+
const lines = [`name: ${JSON.stringify(p.name)}`, `uuid: ${p.uuid}`];
|
|
293
|
+
p.buttons.forEach((b, i) => {
|
|
294
|
+
const m = ACTIONS[b.map1] ?? `?${b.map1}`;
|
|
295
|
+
const m2 = b.map2 ? ` + ${ACTIONS[b.map2] ?? b.map2}` : "";
|
|
296
|
+
lines.push(` button ${i + 1}: ${m}${m2}${b.toggle ? " [toggle]" : ""}`);
|
|
297
|
+
});
|
|
298
|
+
p.ports.forEach((pt, i) => {
|
|
299
|
+
let desc;
|
|
300
|
+
if (pt.kind === "none") desc = "—";
|
|
301
|
+
else if (pt.kind === "stick") {
|
|
302
|
+
const tuned = pt.sensitivity || (pt.deadzone || []).some((x) => x);
|
|
303
|
+
desc = `${STICKS[pt.stick] ?? "?"} (${ORIENTATIONS[pt.orientation] ?? "?"})` +
|
|
304
|
+
(tuned ? ` [sens=${pt.sensitivity}, deadzone=${(pt.deadzone || []).map((x) => x.toString(16)).join(" ")}]` : "");
|
|
305
|
+
}
|
|
306
|
+
else desc = `${ACTIONS[pt.map1] ?? "?"}${pt.map2 ? " + " + (ACTIONS[pt.map2] ?? pt.map2) : ""}${pt.analog ? " [analog]" : ""}${pt.toggle ? " [toggle]" : ""}`;
|
|
307
|
+
lines.push(` port ${i}${i === 0 ? " (built-in stick)" : ""}: ${desc}`);
|
|
308
|
+
});
|
|
309
|
+
return lines.join("\n");
|
|
310
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// PC input bridge — pure, platform-agnostic mapping engine.
|
|
2
|
+
//
|
|
3
|
+
// Turns the Access Controller's live input (physical buttons + stick) into abstract
|
|
4
|
+
// output events (key/button down·up, relative mouse motion, gamepad axes). It does NO
|
|
5
|
+
// I/O: a "sink" (xdotool, uinput, dry-run…) turns these events into real OS input.
|
|
6
|
+
// This is the reusable heart of the bridge — read once, map anywhere.
|
|
7
|
+
|
|
8
|
+
// Physical input indices (matches web/controller-render.mjs decodePhysical):
|
|
9
|
+
// 0..7 = perimeter buttons, 8 = center, 9 = stick-click.
|
|
10
|
+
export const PHYS_BUTTONS = 10;
|
|
11
|
+
|
|
12
|
+
// Decode a raw input report (report id ALREADY stripped) into { buttons:Set, axes:[x,y] }.
|
|
13
|
+
// Mirrors decodePhysical: byte 15 bits 0-7 = perimeter; byte 16 bit0 = center, bit1 = stick-click;
|
|
14
|
+
// bytes 0/1 = stick X/Y (0..255, 128 center).
|
|
15
|
+
export function decodeInput(data, deadzone = 0.16) {
|
|
16
|
+
const buttons = new Set();
|
|
17
|
+
for (let bit = 0; bit < 8; bit++) if (data[15] & (1 << bit)) buttons.add(bit);
|
|
18
|
+
if (data[16] & 0x01) buttons.add(8);
|
|
19
|
+
if (data[16] & 0x02) buttons.add(9);
|
|
20
|
+
const dz = (v) => (Math.abs(v) < deadzone ? 0 : v);
|
|
21
|
+
return { buttons, axes: [dz((data[0] - 128) / 128), dz((data[1] - 128) / 128)] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A default, keyboard-oriented mapping: stick drives the arrow keys, the center button
|
|
25
|
+
// confirms (Enter), and the perimeter buttons cover space + common keys. Good for general
|
|
26
|
+
// PC access / navigation; override per game via a config file.
|
|
27
|
+
export const DEFAULT_MAPPING = {
|
|
28
|
+
// physical index -> output key name (sink translates names to OS keysyms/codes)
|
|
29
|
+
buttons: {
|
|
30
|
+
0: "space", 1: "Return", 2: "Escape", 3: "BackSpace",
|
|
31
|
+
4: "Tab", 5: "f", 6: "e", 7: "q",
|
|
32
|
+
8: "Return", // center
|
|
33
|
+
9: "shift", // stick-click
|
|
34
|
+
},
|
|
35
|
+
stick: { mode: "keys", up: "Up", down: "Down", left: "Left", right: "Right", threshold: 0.5 },
|
|
36
|
+
mouse: { speed: 18, deadzone: 0.2 }, // used only when stick.mode === "mouse"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The engine diffs successive input states and emits only the changes (edges), so a sink
|
|
40
|
+
// can hold keys/buttons down for as long as the physical input is held.
|
|
41
|
+
export class BridgeEngine {
|
|
42
|
+
constructor(mapping = DEFAULT_MAPPING) {
|
|
43
|
+
this.mapping = normalizeMapping(mapping);
|
|
44
|
+
this.down = new Set(); // currently-asserted HELD output codes
|
|
45
|
+
this.prevPhys = new Set(); // physical buttons last tick (for press-edge detection)
|
|
46
|
+
// Classify each button's mapping: a held key, a chord ("ctrl+s"), or a macro (array).
|
|
47
|
+
this.btnOut = {};
|
|
48
|
+
for (const [idx, val] of Object.entries(this.mapping.buttons)) this.btnOut[idx] = parseOutput(val);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Feed a decoded input state; returns an array of output events:
|
|
52
|
+
// { type:"key", code, action:"down"|"up" } keyboard/button
|
|
53
|
+
// { type:"mouseMove", dx, dy } relative pointer motion
|
|
54
|
+
// { type:"axis", code:"x"|"y", value } gamepad axis (-1..1)
|
|
55
|
+
update(state) {
|
|
56
|
+
const events = [];
|
|
57
|
+
const want = new Set(); // HELD output codes that should be down this tick
|
|
58
|
+
const m = this.mapping;
|
|
59
|
+
|
|
60
|
+
// physical buttons -> output. Held keys stay down; chords/macros fire once on press edge.
|
|
61
|
+
for (const idx of state.buttons) {
|
|
62
|
+
const out = this.btnOut[idx];
|
|
63
|
+
if (!out) continue;
|
|
64
|
+
if (out.type === "hold") want.add(out.code);
|
|
65
|
+
else if (!this.prevPhys.has(idx)) events.push(...expand(out)); // momentary, on press edge only
|
|
66
|
+
}
|
|
67
|
+
this.prevPhys = new Set(state.buttons);
|
|
68
|
+
|
|
69
|
+
// stick -> keys / mouse / axis
|
|
70
|
+
const [x, y] = state.axes;
|
|
71
|
+
if (m.stick.mode === "keys") {
|
|
72
|
+
const t = m.stick.threshold;
|
|
73
|
+
if (x <= -t && m.stick.left) want.add(m.stick.left);
|
|
74
|
+
if (x >= t && m.stick.right) want.add(m.stick.right);
|
|
75
|
+
if (y <= -t && m.stick.up) want.add(m.stick.up);
|
|
76
|
+
if (y >= t && m.stick.down) want.add(m.stick.down);
|
|
77
|
+
} else if (m.stick.mode === "mouse") {
|
|
78
|
+
const dz = m.mouse.deadzone, sp = m.mouse.speed;
|
|
79
|
+
const ax = Math.abs(x) < dz ? 0 : x, ay = Math.abs(y) < dz ? 0 : y;
|
|
80
|
+
if (ax || ay) events.push({ type: "mouseMove", dx: Math.round(ax * sp), dy: Math.round(ay * sp) });
|
|
81
|
+
} else if (m.stick.mode === "axis") {
|
|
82
|
+
events.push({ type: "axis", code: "x", value: x }, { type: "axis", code: "y", value: y });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// diff against what's currently held: emit ups for released, downs for newly pressed
|
|
86
|
+
for (const code of this.down) if (!want.has(code)) { events.push({ type: "key", code, action: "up" }); }
|
|
87
|
+
for (const code of want) if (!this.down.has(code)) { events.push({ type: "key", code, action: "down" }); }
|
|
88
|
+
this.down = want;
|
|
89
|
+
return events;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Events to release everything still held (call on shutdown so no key gets "stuck").
|
|
93
|
+
releaseAll() {
|
|
94
|
+
const events = [...this.down].map((code) => ({ type: "key", code, action: "up" }));
|
|
95
|
+
this.down = new Set();
|
|
96
|
+
return events;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Classify a mapping value into how it should fire:
|
|
101
|
+
// "a" -> held while the button is held { type:"hold", code:"a" }
|
|
102
|
+
// "ctrl+s" -> chord, fired once on press { type:"chord", parts:["ctrl","s"] }
|
|
103
|
+
// ["g","g"] -> macro (sequence of chords), once on press{ type:"macro", steps:[["g"],["g"]] }
|
|
104
|
+
// ["ctrl+c","ctrl+v"] -> macro of chords
|
|
105
|
+
export function parseOutput(value) {
|
|
106
|
+
if (Array.isArray(value)) return { type: "macro", steps: value.map(toParts) };
|
|
107
|
+
if (typeof value === "string" && value.includes("+")) return { type: "chord", parts: toParts(value) };
|
|
108
|
+
return { type: "hold", code: String(value) };
|
|
109
|
+
}
|
|
110
|
+
function toParts(s) { return String(s).split("+").map((p) => p.trim()).filter(Boolean); }
|
|
111
|
+
|
|
112
|
+
// Expand a chord/macro into a burst of key down/up events (modifiers held around the key).
|
|
113
|
+
function expandChord(parts) {
|
|
114
|
+
const ev = [];
|
|
115
|
+
for (const p of parts) ev.push({ type: "key", code: p, action: "down" });
|
|
116
|
+
for (let i = parts.length - 1; i >= 0; i--) ev.push({ type: "key", code: parts[i], action: "up" });
|
|
117
|
+
return ev;
|
|
118
|
+
}
|
|
119
|
+
function expand(out) {
|
|
120
|
+
if (out.type === "chord") return expandChord(out.parts);
|
|
121
|
+
if (out.type === "macro") return out.steps.flatMap(expandChord);
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeMapping(mapping) {
|
|
126
|
+
const m = mapping || {};
|
|
127
|
+
return {
|
|
128
|
+
buttons: { ...DEFAULT_MAPPING.buttons, ...(m.buttons || {}) },
|
|
129
|
+
stick: { ...DEFAULT_MAPPING.stick, ...(m.stick || {}) },
|
|
130
|
+
mouse: { ...DEFAULT_MAPPING.mouse, ...(m.mouse || {}) },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Helpers for the in-app Key Bridge editor — pure, I/O-free, testable.
|
|
2
|
+
//
|
|
3
|
+
// The editor lets you assign a keyboard key / chord to each physical input and the stick,
|
|
4
|
+
// then export a config the local `bridge.mjs` process runs. The browser can author and
|
|
5
|
+
// preview this, but cannot inject input into other apps (that's the local bridge's job).
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_MAPPING } from "./bridge-core.mjs";
|
|
8
|
+
|
|
9
|
+
// Physical input labels (index 0..9), matching controller-render PHYS layout.
|
|
10
|
+
export const PHYS_LABELS = [
|
|
11
|
+
"Button 1", "Button 2", "Button 3", "Button 4", "Button 5", "Button 6", "Button 7", "Button 8",
|
|
12
|
+
"Center", "Stick-click",
|
|
13
|
+
];
|
|
14
|
+
export const STICK_MODES = ["keys", "mouse", "axis"];
|
|
15
|
+
export const STICK_DIRS = ["up", "down", "left", "right"];
|
|
16
|
+
|
|
17
|
+
// A fresh, editable copy of the default mapping with buttons keyed 0..9.
|
|
18
|
+
export function defaultBridgeMap() {
|
|
19
|
+
const m = JSON.parse(JSON.stringify(DEFAULT_MAPPING));
|
|
20
|
+
const buttons = {};
|
|
21
|
+
for (let i = 0; i < PHYS_LABELS.length; i++) buttons[i] = m.buttons[i] ?? "nothing";
|
|
22
|
+
return { buttons, stick: m.stick, mouse: m.mouse };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Translate a browser keyboard event into a bridge value ("a", "ctrl+s", …) or null if the
|
|
26
|
+
// event is a modifier on its own (caller should keep listening for the real key).
|
|
27
|
+
export function keyEventToValue(e) {
|
|
28
|
+
if (["Control", "Shift", "Alt", "Meta"].includes(e.key)) return null;
|
|
29
|
+
const base = keysym(e.key);
|
|
30
|
+
if (!base) return null;
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (e.ctrlKey) parts.push("ctrl");
|
|
33
|
+
if (e.altKey) parts.push("alt");
|
|
34
|
+
if (e.shiftKey && base !== "shift") parts.push("shift");
|
|
35
|
+
if (e.metaKey) parts.push("super");
|
|
36
|
+
parts.push(base);
|
|
37
|
+
return parts.join("+");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Translate a Node readline "keypress" event (terminal) into a bridge value — the CLI
|
|
41
|
+
// equivalent of keyEventToValue. key = { name, ctrl, meta, shift, sequence }.
|
|
42
|
+
export function keypressToValue(key) {
|
|
43
|
+
if (!key) return null;
|
|
44
|
+
let base = TERM_NAME[key.name];
|
|
45
|
+
if (!base) {
|
|
46
|
+
if (key.name && /^f\d+$/.test(key.name)) base = key.name.toUpperCase();
|
|
47
|
+
else if (key.name && key.name.length === 1) base = key.name.toLowerCase();
|
|
48
|
+
else if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") base = key.sequence.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
if (!base) return null;
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (key.ctrl) parts.push("ctrl");
|
|
53
|
+
if (key.meta) parts.push("alt");
|
|
54
|
+
if (key.shift && base !== "shift") parts.push("shift");
|
|
55
|
+
parts.push(base);
|
|
56
|
+
return parts.join("+");
|
|
57
|
+
}
|
|
58
|
+
const TERM_NAME = {
|
|
59
|
+
return: "Return", enter: "Return", space: "space", tab: "Tab", backspace: "BackSpace",
|
|
60
|
+
escape: "Escape", up: "Up", down: "Down", left: "Left", right: "Right",
|
|
61
|
+
delete: "Delete", home: "Home", end: "End", pageup: "Prior", pagedown: "Next",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const KEYSYM = {
|
|
65
|
+
" ": "space", "Spacebar": "space", "Enter": "Return", "Tab": "Tab", "Backspace": "BackSpace",
|
|
66
|
+
"ArrowUp": "Up", "ArrowDown": "Down", "ArrowLeft": "Left", "ArrowRight": "Right",
|
|
67
|
+
"Delete": "Delete", "Home": "Home", "End": "End", "PageUp": "Prior", "PageDown": "Next",
|
|
68
|
+
"Escape": "Escape", "Control": "ctrl", "Shift": "shift", "Alt": "alt", "Meta": "super",
|
|
69
|
+
};
|
|
70
|
+
function keysym(k) {
|
|
71
|
+
if (KEYSYM[k]) return KEYSYM[k];
|
|
72
|
+
if (k.length === 1) return k.toLowerCase();
|
|
73
|
+
if (/^F\d+$/.test(k)) return k;
|
|
74
|
+
return k;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Human-readable value for the UI.
|
|
78
|
+
export function displayValue(v) {
|
|
79
|
+
if (v == null || v === "" || v === "nothing") return "—";
|
|
80
|
+
if (Array.isArray(v)) return v.join(" , ");
|
|
81
|
+
return v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Reduce an editable map to a clean config object (drops unassigned buttons; mouse only in mouse mode).
|
|
85
|
+
export function toConfig(map) {
|
|
86
|
+
const buttons = {};
|
|
87
|
+
for (const [i, val] of Object.entries(map.buttons)) {
|
|
88
|
+
if (val && val !== "nothing") buttons[i] = val;
|
|
89
|
+
}
|
|
90
|
+
const cfg = { buttons, stick: { ...map.stick } };
|
|
91
|
+
if (map.stick.mode === "mouse") cfg.mouse = { ...map.mouse };
|
|
92
|
+
return cfg;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function toConfigJSON(map) {
|
|
96
|
+
return JSON.stringify(toConfig(map), null, 2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A ready-to-run command for the exported config.
|
|
100
|
+
export function runCommand(filename = "ps-access-bridge.json", sink = "xdotool") {
|
|
101
|
+
return `node bridge.mjs --config ${filename} --sink ${sink}`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Shared controller rendering + physical-input decoding for the XMB configurator and the
|
|
2
|
+
// input monitor. Pure (no DOM/IO) so any page can build a render and decode reports.
|
|
3
|
+
import { ACTIONS } from "./access-protocol.mjs";
|
|
4
|
+
|
|
5
|
+
// Glyphs for actions with a recognizable symbol; others fall back to short text.
|
|
6
|
+
export const SYMBOLS = { 1: "○", 2: "✕", 3: "△", 4: "□", 5: "▲", 6: "▼", 7: "◀", 8: "▶", 15: "☰", 18: "▭" };
|
|
7
|
+
export const symLabel = (code) => (code === 0 ? "—" : SYMBOLS[code] ?? ACTIONS[code] ?? `?${code}`);
|
|
8
|
+
export const nameLabel = (code) => (code === 0 ? "Not assigned" : ACTIONS[code] ?? `?${code}`);
|
|
9
|
+
|
|
10
|
+
// geometry
|
|
11
|
+
export const M = { CX: 220, CY: 220, RO: 140, RI: 82, RM: 111, CTR: 54, GAP: 3.2, CORNER: 14,
|
|
12
|
+
STICK_DIST: 182, STICK_R: 30, THUMB_R: 14, PORT_R: 17, PORT_ARC: 186 };
|
|
13
|
+
export const ORIENT_ROT = { 0: 0, 1: 270, 2: 180, 3: 90 };
|
|
14
|
+
export const rotV = (x, y, t) => [x * Math.cos(t) - y * Math.sin(t), x * Math.sin(t) + y * Math.cos(t)];
|
|
15
|
+
export const rad = (d) => d * Math.PI / 180;
|
|
16
|
+
|
|
17
|
+
export function roundedWedge(cx, cy, ri, ro, a0, a1, cr) {
|
|
18
|
+
const pol = (r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
|
19
|
+
const f = (p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
|
|
20
|
+
cr = Math.min(cr, (ro - ri) / 2 - 1, ((a1 - a0) * ri) / 2 - 1);
|
|
21
|
+
const dO = Math.asin(cr / (ro - cr)), dI = Math.asin(cr / (ri + cr));
|
|
22
|
+
const roS = (ro - cr) * Math.cos(dO), riS = (ri + cr) * Math.cos(dI);
|
|
23
|
+
const P1 = pol(ro, a0 + dO), P2 = pol(ro, a1 - dO), P3 = pol(roS, a1), P4 = pol(riS, a1),
|
|
24
|
+
P5 = pol(ri, a1 - dI), P6 = pol(ri, a0 + dI), P7 = pol(riS, a0), P8 = pol(roS, a0);
|
|
25
|
+
return `M${f(P1)} A${ro},${ro} 0 0 1 ${f(P2)} A${cr},${cr} 0 0 1 ${f(P3)} L${f(P4)} `
|
|
26
|
+
+ `A${cr},${cr} 0 0 1 ${f(P5)} A${ri},${ri} 0 0 0 ${f(P6)} A${cr},${cr} 0 0 1 ${f(P7)} `
|
|
27
|
+
+ `L${f(P8)} A${cr},${cr} 0 0 1 ${f(P1)} Z`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build a neutral controller SVG. Live highlight (data-btn → .on) + thumb motion are applied
|
|
31
|
+
// by the page. `focus` = {type:'button'|'port'|'stick', index} outlines one input.
|
|
32
|
+
export function profileSVG(profile, { focus = null } = {}) {
|
|
33
|
+
if (!profile) return "";
|
|
34
|
+
const stick = profile.ports[0];
|
|
35
|
+
const orient = stick.kind === "stick" ? stick.orientation : 3;
|
|
36
|
+
const oDeg = ORIENT_ROT[orient] ?? 0, theta = rad(oDeg);
|
|
37
|
+
const R = (x, y) => { const [rx, ry] = rotV(x - M.CX, y - M.CY, theta); return [M.CX + rx, M.CY + ry]; };
|
|
38
|
+
const seg = (f) => `seg${f ? " foc" : ""}`;
|
|
39
|
+
let s = `<svg viewBox="0 0 440 440" xmlns="http://www.w3.org/2000/svg">`;
|
|
40
|
+
for (let i = 0; i < 8; i++) {
|
|
41
|
+
const ca = rad(90 - i * 45 + oDeg);
|
|
42
|
+
const d = roundedWedge(M.CX, M.CY, M.RI, M.RO, ca - rad(22.5 - M.GAP), ca + rad(22.5 - M.GAP), M.CORNER);
|
|
43
|
+
const b = profile.buttons[i];
|
|
44
|
+
s += `<path d="${d}" class="${seg(focus?.type === "button" && focus.index === i)}" data-btn="${i}"/>`;
|
|
45
|
+
s += `<text x="${(M.CX + M.RM * Math.cos(ca)).toFixed(1)}" y="${(M.CY + M.RM * Math.sin(ca) + 7).toFixed(1)}" class="lab">${symLabel(b.map1)}</text>`;
|
|
46
|
+
}
|
|
47
|
+
s += `<circle cx="${M.CX}" cy="${M.CY}" r="${M.CTR}" class="${seg(focus?.type === "button" && focus.index === 8)}" data-btn="8"/>`;
|
|
48
|
+
s += `<text x="${M.CX}" y="${M.CY + 8}" class="lab big">${symLabel(profile.buttons[8].map1)}</text>`;
|
|
49
|
+
for (let p = 1; p <= 4; p++) {
|
|
50
|
+
const a = rad(-90 + (p - 2.5) * 24 + oDeg);
|
|
51
|
+
const [x, y] = [M.CX + M.PORT_ARC * Math.cos(a), M.CY + M.PORT_ARC * Math.sin(a)];
|
|
52
|
+
const port = profile.ports[p];
|
|
53
|
+
const lbl = port.kind === "stick" ? "stk" : port.kind === "button" ? symLabel(port.map1) : `E${p}`;
|
|
54
|
+
s += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${M.PORT_R}" class="${seg(focus?.type === "port" && focus.index === p)}"/>`;
|
|
55
|
+
s += `<text x="${x.toFixed(1)}" y="${(y + 5).toFixed(1)}" class="lab sm">${lbl}</text>`;
|
|
56
|
+
}
|
|
57
|
+
const [sx, sy] = R(M.CX, M.CY + M.STICK_DIST);
|
|
58
|
+
const stickFoc = focus?.type === "stick" || (focus?.type === "button" && focus.index === 9);
|
|
59
|
+
s += `<circle cx="${sx.toFixed(1)}" cy="${sy.toFixed(1)}" r="${M.STICK_R}" class="stickwell${stickFoc ? " foc" : ""}"/>`;
|
|
60
|
+
s += `<circle cx="${sx.toFixed(1)}" cy="${sy.toFixed(1)}" r="${M.STICK_R - 8}" class="thumb${stickFoc ? " foc" : ""}" data-bx="${sx.toFixed(1)}" data-by="${sy.toFixed(1)}" data-btn="9"/>`;
|
|
61
|
+
s += `</svg>`;
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Physical button names by index (0-7 perimeter 1-8, 8 center, 9 stick-click). Reverse-engineered.
|
|
66
|
+
export const PHYS_NAMES = ["1", "2", "3", "4", "5", "6", "7", "8", "Center", "Stick-click"];
|
|
67
|
+
|
|
68
|
+
// Decode physical button + stick state from a raw input-report data view (report id excluded):
|
|
69
|
+
// byte 15 bits 0-7 = perimeter 1-8; byte 16 bit 0 = center, bit 1 = stick-click; bytes 0/1 = stick X/Y;
|
|
70
|
+
// byte 16 bit 3 (0x08) = profile-switch button; byte 39 = active on-device profile (1-based).
|
|
71
|
+
export function decodePhysical(data) {
|
|
72
|
+
const buttons = new Set();
|
|
73
|
+
for (let bit = 0; bit < 8; bit++) if (data[15] & (1 << bit)) buttons.add(bit);
|
|
74
|
+
if (data[16] & 0x01) buttons.add(8);
|
|
75
|
+
if (data[16] & 0x02) buttons.add(9);
|
|
76
|
+
const dz = (v) => (Math.abs(v) < 0.16 ? 0 : v);
|
|
77
|
+
const profile = data.length > 39 && data[39] >= 1 && data[39] <= 3 ? data[39] : 0; // 0 = unknown
|
|
78
|
+
return { buttons, axes: [dz((data[0] - 128) / 128), dz((data[1] - 128) / 128)], profile };
|
|
79
|
+
}
|