Port phase 1 to TypeScript
Move the standalone GameSense scale lighting to TypeScript ahead of the
Max for Live work, so step 3 gets type declarations at the seam.
Build emits CommonJS at ES2020 into dist/, because Node for Max loads CJS
on a Node version we do not control. bin/apex7-scale.js becomes a plain-JS
launcher so `node bin/apex7-scale.js` keeps working; the CLI itself moves
to src/cli.ts.
Two modules make previously implicit structure explicit:
- src/protocol.ts, the GameSense wire types, so the transport and the
payload construction agree on shapes neither of them owns
- src/errors.ts, since `catch (err)` binds `unknown` under strict
Typing surfaced a few real fixes:
- ScaleLighting.started was a public field the CLI set by hand to make
--off work without start(); that is now release(), with started
private behind isStarted
- readAddress() trusted JSON.parse output; the address is now checked
for being a non-empty string
- res.statusCode is number|undefined, so the old `>= 200` comparison
coerced silently; a missing status now rejects
- parseIntervals claimed to dedupe and sort, and never did (comment
corrected; pitchClassesFor is what dedupes)
Behavior is otherwise unchanged: all 18 tests pass and --once, --off,
--demo, --key-test, the resident stdin loop and the error paths were
verified against tools/fake-gamesense.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
226
tools/fake-gamesense.ts
Normal file
226
tools/fake-gamesense.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* npm run stub -- --port 51000
|
||||
* node bin/apex7-scale.js --address 127.0.0.1:51000 --root D --scale dorian
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
import { errorMessage } from '../src/errors';
|
||||
import { HID } from '../src/hid';
|
||||
import type { Frame, Handler, Rgb } from '../src/protocol';
|
||||
|
||||
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
|
||||
|
||||
/** A printable key, or a gap in the row. */
|
||||
type Cell = readonly [label: string, hid: number] | null;
|
||||
|
||||
const LAYOUT: readonly (readonly Cell[])[] = [
|
||||
[['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]],
|
||||
];
|
||||
|
||||
interface GameEntry {
|
||||
metadata: unknown;
|
||||
events: Map<string, readonly Handler[]>;
|
||||
}
|
||||
|
||||
const state = {
|
||||
games: new Map<string, GameEntry>(),
|
||||
lastColors: new Map<number, Rgb>(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Request bodies are whatever the client POSTs. This stub only ever talks to
|
||||
* our own client on localhost, so it reads the fields it cares about rather
|
||||
* than validating the whole shape.
|
||||
*/
|
||||
interface GameSenseRequest {
|
||||
game?: string;
|
||||
event?: string;
|
||||
handlers?: readonly Handler[];
|
||||
data?: { value?: number; frame?: Frame };
|
||||
game_display_name?: string;
|
||||
developer?: string;
|
||||
}
|
||||
|
||||
function cell(label: string, color: Rgb | undefined): string {
|
||||
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(): void {
|
||||
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: readonly Handler[], frame: Frame): Map<number, Rgb> {
|
||||
const colors = new Map<number, Rgb>();
|
||||
for (const handler of handlers) {
|
||||
if (handler['device-type'] !== 'rgb-per-key-zones') continue;
|
||||
if (handler.mode !== 'context-color') continue;
|
||||
|
||||
const color = frame[handler['context-frame-key']];
|
||||
if (!color) continue;
|
||||
|
||||
for (const hid of handler['custom-zone-keys'] || []) colors.set(hid, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<GameSenseRequest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
try {
|
||||
resolve(text ? (JSON.parse(text) as GameSenseRequest) : {});
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function log(...parts: unknown[]): void {
|
||||
if (!QUIET) console.log(...parts);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const reply = (code: number, payload: unknown): void => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
let body: GameSenseRequest;
|
||||
try {
|
||||
body = await readBody(req);
|
||||
} catch (err) {
|
||||
reply(400, { error: `bad JSON: ${errorMessage(err)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const game = body.game;
|
||||
const entry = (name: string): GameEntry => {
|
||||
let found = state.games.get(name);
|
||||
if (!found) {
|
||||
found = { metadata: null, events: new Map() };
|
||||
state.games.set(name, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
switch (req.url) {
|
||||
case '/game_metadata':
|
||||
if (!game) return reply(400, { error: 'missing game' });
|
||||
entry(game).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(game).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 = game !== undefined ? state.games.get(game) : undefined;
|
||||
const handlers = known && body.event ? known.events.get(body.event) : undefined;
|
||||
if (!handlers) return reply(404, { error: `no handler for ${game}/${body.event}` });
|
||||
|
||||
const frame = 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(', ')}`);
|
||||
}
|
||||
|
||||
state.lastColors = applyFrame(handlers, frame);
|
||||
log(`[event] ${game}/${body.event} value=${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':
|
||||
if (game !== undefined) 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`);
|
||||
});
|
||||
Reference in New Issue
Block a user