Files
steelseries-live-scale/tools/fake-gamesense.ts
khannurien 3b02612461 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>
2026-08-12 13:30:53 +00:00

227 lines
8.4 KiB
JavaScript

#!/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`);
});