Build step 1 of the Ableton scale-lighting brief: a dependency-free Node script that drives the keyboard directly, with the scale passed on the command line. Live and Max for Live come later. Lighting uses one event with 14 context-color handlers — a background zone covering every key except the 13 note keys, plus one handler per note key addressed by USB HID code. Colors travel in the event frame, so handlers are bound once and a scale change is a single POST, which is what the M4L step will need. Chose custom zones over bitmap mode: bitmap's 22x6 grid has no documented index-to-key table per model, while HID codes are exact. Painting the whole board with the background zone covers the same blackout caveat bitmap was suggested for. tools/fake-gamesense.js stands in for the GameSense server and renders the resolved frame as ANSI colour, so the mapping is verifiable without hardware or Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.5 KiB
JavaScript
206 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* A stand-in for the SteelSeries GameSense server.
|
|
*
|
|
* It speaks enough of the real API to exercise the client, and it resolves the
|
|
* bound handlers against each incoming frame to render the keyboard as ANSI
|
|
* colour in the terminal. That makes the lighting logic verifiable without an
|
|
* Apex 7 (or Windows) in front of you.
|
|
*
|
|
* node tools/fake-gamesense.js --port 51000
|
|
* node bin/apex7-scale.js --address 127.0.0.1:51000 --root D --scale dorian
|
|
*/
|
|
|
|
const http = require('http');
|
|
const { HID } = require('../src/hid');
|
|
|
|
const args = process.argv.slice(2);
|
|
const portArg = args.indexOf('--port');
|
|
const PORT = portArg >= 0 ? Number(args[portArg + 1]) : 51000;
|
|
const QUIET = args.includes('--quiet');
|
|
const ONCE = args.includes('--once'); // exit after the first rendered frame
|
|
|
|
// hid -> [label, width]
|
|
const LAYOUT = [
|
|
[['esc', HID.escape], null, ['f1', HID.f1], ['f2', HID.f2], ['f3', HID.f3], ['f4', HID.f4], null,
|
|
['f5', HID.f5], ['f6', HID.f6], ['f7', HID.f7], ['f8', HID.f8], null,
|
|
['f9', HID.f9], ['f10', HID.f10], ['f11', HID.f11], ['f12', HID.f12], null,
|
|
['prt', HID.printScreen], ['scr', HID.scrollLock], ['brk', HID.pause]],
|
|
|
|
[['`', HID.backquote], ['1', HID['1']], ['2', HID['2']], ['3', HID['3']], ['4', HID['4']],
|
|
['5', HID['5']], ['6', HID['6']], ['7', HID['7']], ['8', HID['8']], ['9', HID['9']],
|
|
['0', HID['0']], ['-', HID.minus], ['=', HID.equal], ['bsp', HID.backspace], null,
|
|
['ins', HID.insert], ['hom', HID.home], ['pup', HID.pageUp], null,
|
|
['num', HID.numLock], ['/', HID.numpadDivide], ['*', HID.numpadMultiply], ['-', HID.numpadSubtract]],
|
|
|
|
[['tab', HID.tab], ['q', HID.q], ['w', HID.w], ['e', HID.e], ['r', HID.r], ['t', HID.t],
|
|
['y', HID.y], ['u', HID.u], ['i', HID.i], ['o', HID.o], ['p', HID.p],
|
|
['[', HID.bracketLeft], [']', HID.bracketRight], ['\\', HID.backslash], null,
|
|
['del', HID.delete], ['end', HID.end], ['pdn', HID.pageDown], null,
|
|
['7', HID.numpad7], ['8', HID.numpad8], ['9', HID.numpad9], ['+', HID.numpadAdd]],
|
|
|
|
[['cap', HID.capsLock], ['a', HID.a], ['s', HID.s], ['d', HID.d], ['f', HID.f], ['g', HID.g],
|
|
['h', HID.h], ['j', HID.j], ['k', HID.k], ['l', HID.l], [';', HID.semicolon],
|
|
["'", HID.quote], ['ent', HID.enter], null, null, null, null, null, null,
|
|
['4', HID.numpad4], ['5', HID.numpad5], ['6', HID.numpad6]],
|
|
|
|
[['sft', HID.shiftLeft], ['z', HID.z], ['x', HID.x], ['c', HID.c], ['v', HID.v], ['b', HID.b],
|
|
['n', HID.n], ['m', HID.m], [',', HID.comma], ['.', HID.period], ['/', HID.slash],
|
|
['sft', HID.shiftRight], null, null, null, null, ['up', HID.arrowUp], null, null,
|
|
['1', HID.numpad1], ['2', HID.numpad2], ['3', HID.numpad3], ['ent', HID.numpadEnter]],
|
|
|
|
[['ctl', HID.controlLeft], ['win', HID.metaLeft], ['alt', HID.altLeft],
|
|
['spc', HID.space], ['alt', HID.altRight], ['win', HID.metaRight],
|
|
['men', HID.application], ['ctl', HID.controlRight], null, null, null, null, null,
|
|
['lft', HID.arrowLeft], ['dn', HID.arrowDown], ['rgt', HID.arrowRight], null,
|
|
['0', HID.numpad0], ['.', HID.numpadDecimal]],
|
|
];
|
|
|
|
const state = {
|
|
games: new Map(), // game -> { metadata, events: Map<event, handlers> }
|
|
lastColors: new Map(), // hid -> {red,green,blue}
|
|
};
|
|
|
|
function cell(label, color) {
|
|
const text = ` ${String(label).slice(0, 3).padEnd(3)} `;
|
|
if (!color) return `\x1b[2m${text}\x1b[0m`;
|
|
const { red, green, blue } = color;
|
|
// Relative luminance, to keep the label readable on any background.
|
|
const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
const fg = luma > 140 ? '30' : '97';
|
|
return `\x1b[48;2;${red};${green};${blue}m\x1b[${fg}m${text}\x1b[0m`;
|
|
}
|
|
|
|
function render() {
|
|
const lines = LAYOUT.map((row) =>
|
|
row.map((key) => (key ? cell(key[0], state.lastColors.get(key[1])) : ' ')).join('')
|
|
);
|
|
console.log(`\n${lines.join('\n')}\n`);
|
|
}
|
|
|
|
/** Resolve bound handlers against an incoming frame -> hid => color. */
|
|
function applyFrame(handlers, frame) {
|
|
const colors = new Map();
|
|
for (const handler of handlers) {
|
|
if (handler['device-type'] !== 'rgb-per-key-zones') continue;
|
|
|
|
let color = null;
|
|
if (handler.mode === 'context-color') {
|
|
color = frame[handler['context-frame-key']] || null;
|
|
} else if (handler.color) {
|
|
color = handler.color;
|
|
}
|
|
if (!color) continue;
|
|
|
|
for (const hid of handler['custom-zone-keys'] || []) colors.set(hid, color);
|
|
}
|
|
return colors;
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
try {
|
|
resolve(text ? JSON.parse(text) : {});
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function log(...parts) {
|
|
if (!QUIET) console.log(...parts);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
let body;
|
|
try {
|
|
body = await readBody(req);
|
|
} catch (err) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: `bad JSON: ${err.message}` }));
|
|
return;
|
|
}
|
|
|
|
const reply = (code, payload) => {
|
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
|
|
const game = body.game;
|
|
const entry = () => {
|
|
if (!state.games.has(game)) {
|
|
state.games.set(game, { metadata: null, events: new Map() });
|
|
}
|
|
return state.games.get(game);
|
|
};
|
|
|
|
switch (req.url) {
|
|
case '/game_metadata':
|
|
if (!game) return reply(400, { error: 'missing game' });
|
|
entry().metadata = body;
|
|
log(`[metadata] ${game} "${body.game_display_name}" by ${body.developer}`);
|
|
return reply(200, { game });
|
|
|
|
case '/bind_game_event': {
|
|
if (!game || !body.event) return reply(400, { error: 'missing game or event' });
|
|
const handlers = body.handlers || [];
|
|
entry().events.set(body.event, handlers);
|
|
const zoneKeys = handlers.reduce(
|
|
(n, h) => n + (h['custom-zone-keys'] || []).length,
|
|
0
|
|
);
|
|
log(
|
|
`[bind] ${game}/${body.event}: ${handlers.length} handlers, ${zoneKeys} keys addressed`
|
|
);
|
|
return reply(200, { game, event: body.event });
|
|
}
|
|
|
|
case '/game_event': {
|
|
const known = state.games.get(game);
|
|
const handlers = known && known.events.get(body.event);
|
|
if (!handlers) return reply(404, { error: `no handler for ${game}/${body.event}` });
|
|
|
|
const frame = (body.data && body.data.frame) || {};
|
|
const missing = handlers
|
|
.filter((h) => h.mode === 'context-color' && !(h['context-frame-key'] in frame))
|
|
.map((h) => h['context-frame-key']);
|
|
if (missing.length) {
|
|
log(`[warn] frame is missing keys: ${missing.join(', ')}`);
|
|
}
|
|
|
|
const colors = applyFrame(handlers, frame);
|
|
state.lastColors = colors;
|
|
log(`[event] ${game}/${body.event} value=${body.data && body.data.value}`);
|
|
render();
|
|
if (ONCE) setTimeout(() => process.exit(0), 50);
|
|
return reply(200, { game, event: body.event });
|
|
}
|
|
|
|
case '/game_heartbeat':
|
|
log(`[heartbeat] ${game}`);
|
|
return reply(200, { game });
|
|
|
|
case '/remove_game':
|
|
state.games.delete(game);
|
|
state.lastColors = new Map();
|
|
log(`[remove] ${game}`);
|
|
return reply(200, { game });
|
|
|
|
default:
|
|
return reply(404, { error: `unhandled endpoint ${req.url}` });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
console.log(`fake GameSense server on 127.0.0.1:${PORT}`);
|
|
console.log(` GAMESENSE_ADDRESS=127.0.0.1:${PORT} node bin/apex7-scale.js --root C --scale major\n`);
|
|
});
|