#!/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; } const state = { games: new Map(), lastColors: new Map(), }; /** * 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 { const colors = new Map(); 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 { 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`); });