@yee94/qk80mk2 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,11 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {createHash} from 'node:crypto';
3
+
4
+ const reference = new URL('../reference/',import.meta.url);
5
+ const manifest = JSON.parse(await readFile(new URL('manifest.json',reference),'utf8'));
6
+ for (const entry of manifest.files) {
7
+ const data = await readFile(new URL(entry.path,reference));
8
+ const sha = createHash('sha256').update(data).digest('hex');
9
+ if (data.length !== entry.bytes || sha !== entry.sha256) throw new Error(`Integrity mismatch: ${entry.path}`);
10
+ console.log(`PASS: ${entry.path} (${data.length} bytes)`);
11
+ }
@@ -0,0 +1,42 @@
1
+ // Offline golden-file verification, never accesses USB.
2
+ import fs from 'node:fs';
3
+ import vm from 'node:vm';
4
+ import path from 'node:path';
5
+ import {createRequire} from 'node:module';
6
+ import sharp from 'sharp';
7
+ import {encodeImage} from '../src/image.js';
8
+
9
+ const [assets,wasm,input,out] = process.argv.slice(2);
10
+ if (!assets || !wasm || !input) throw new Error('Usage: node scripts/verify-official.js ASSETS WASM INPUT [GOLDEN_OUTPUT]');
11
+ // Feed exactly the same normalized PNG to both encoders, including non-native-size inputs.
12
+ const png = await sharp(input).autoOrient().resize(320,172,{fit:'cover',position:'centre'})
13
+ .flatten({background:'#000'}).toColourspace('srgb').removeAlpha().png().toBuffer();
14
+ const bundle = fs.readFileSync(path.join(assets,'index.js'),'utf8');
15
+ const start = bundle.indexOf('var createModule='), end = bundle.indexOf(';let module,loading=',start);
16
+ if (start < 0 || end < 0) throw new Error('Upstream JS module boundary changed');
17
+ const context = vm.createContext({
18
+ console,process,Buffer,URL,WebAssembly,TextDecoder,TextEncoder,performance,
19
+ setTimeout,clearTimeout,setInterval,clearInterval,
20
+ require:createRequire(import.meta.url),__dirname:path.resolve(assets),
21
+ __vitePreload:async () => ({createRequire}),self:{location:import.meta.url},
22
+ });
23
+ vm.runInContext(bundle.slice(start,end+1).replaceAll('import.meta.url',JSON.stringify(import.meta.url)),context);
24
+ const module = await context.createModule({wasmBinary:fs.readFileSync(wasm)});
25
+ const config = JSON.parse(fs.readFileSync(path.join(assets,'configs.json')))
26
+ .find(c => Number(c.vendorId) === 0x514b && Number(c.productId) === 0x4d02);
27
+ const vector = list => { const result = new module.IntVec(); list.forEach(n => result.push_back(n)); return result; };
28
+ module.FS.writeFile('/input.png',png);
29
+ const size = vector([320,172]), origin = vector([0,0]), extra = new module.U8Vec();
30
+ for (const target of ['theme','custom']) {
31
+ const format = target === 'theme' ? 'ABKT' : 'ABKG';
32
+ const area = config.screen.fileTypes.find(c => c.type === 'image' && c.format === format);
33
+ const status = module.convert_image_ext('/input.png','/out.bin',area.orientations,size,size,origin,
34
+ format,config.screen.swapBit ? 1 : 0,extra);
35
+ if (status !== 0) throw new Error(`Official encoder returned ${status}`);
36
+ const official = Buffer.from(module.FS.readFile('/out.bin'));
37
+ const ours = await encodeImage(png,{target});
38
+ if (!ours.equals(official)) throw new Error(`FAIL: ${format} differs from official output`);
39
+ if (out && target === 'theme') fs.writeFileSync(out,official);
40
+ console.log(`PASS: ${format}, ${official.length} bytes identical to official WASM`);
41
+ }
42
+ size.delete(); origin.delete(); extra.delete();
package/src/cli.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import {parseArgs} from 'node:util';
3
+ import {readFile,writeFile,mkdir} from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {devices,encodeImage,upload,validateImage} from './index.js';
6
+
7
+ const help = `qk80-screen — Node.js USB CDC static-image tool
8
+
9
+ node src/cli.js devices
10
+ node src/cli.js encode photo.png --out output/theme.abkt
11
+ node src/cli.js upload photo.png [--target theme|custom] [--port /dev/cu.usbmodem...]
12
+ node src/cli.js upload output/theme.abkt
13
+
14
+ Upload tuning: --packet-delay-ms 6 --settle-ms 2000 --timeout-ms 5000
15
+ JSON results on stdout; progress on stderr. --json suppresses progress.
16
+ Default destination: theme (ABKT). No HTTP server or browser required.
17
+ `;
18
+
19
+ const controller = new AbortController();
20
+ const abort = () => controller.abort(new Error('Upload interrupted'));
21
+ process.once('SIGINT',abort); process.once('SIGTERM',abort);
22
+ try {
23
+ const {values:v,positionals:p} = parseArgs({allowPositionals:true,options:{
24
+ help:{type:'boolean',short:'h'},version:{type:'boolean',short:'v'},json:{type:'boolean'},
25
+ out:{type:'string'},target:{type:'string'},port:{type:'string'},
26
+ 'packet-delay-ms':{type:'string'},'settle-ms':{type:'string'},'timeout-ms':{type:'string'},
27
+ }});
28
+ const [command,input] = p;
29
+ if (v.version) console.log(JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')).version);
30
+ else if (v.help || !command) console.log(help);
31
+ else if (command === 'devices') console.log(JSON.stringify(await devices(),null,2));
32
+ else {
33
+ if (!['encode','upload'].includes(command)) throw new Error('Unknown command');
34
+ if (p.length > 2) throw new Error('Unexpected positional arguments');
35
+ const image = input;
36
+ if (!image) throw new Error('An input image is required');
37
+ const encoded = /\.(abkt|abkg)$/i.test(image);
38
+ const file = encoded ? await readFile(image) : await encodeImage(image,{target:v.target ?? 'theme'});
39
+ validateImage(file);
40
+ if (encoded && v.target && (v.target === 'theme' ? 'ABKT' : v.target === 'custom' ? 'ABKG' : '') !== file.toString('ascii',0,4))
41
+ throw new Error('--target does not match encoded file');
42
+ if (command === 'upload') {
43
+ let last = -10;
44
+ const numeric = (key,fallback) => v[key] === undefined ? fallback : Number(v[key]);
45
+ const result = await upload(file,{port:v.port,signal:controller.signal,
46
+ packetDelayMs:numeric('packet-delay-ms',6),settleMs:numeric('settle-ms',2000),
47
+ timeoutMs:numeric('timeout-ms',5000),onProgress:event => {
48
+ const pct = Math.floor(event.sent/event.total*100);
49
+ if (!v.json && (pct >= last+10 || event.stage === 'settling')) {
50
+ console.error(`${event.stage}: ${pct}% (flowControl=${event.flowControl})`); last = pct;
51
+ }
52
+ }});
53
+ console.log(JSON.stringify(result,null,2));
54
+ } else {
55
+ const extension = file.toString('ascii',0,4).toLowerCase();
56
+ const out = path.resolve(v.out ?? `output/theme.${extension}`);
57
+ await mkdir(path.dirname(out),{recursive:true}); await writeFile(out,file);
58
+ console.log(JSON.stringify({encoded:out,bytes:file.length},null,2));
59
+ }
60
+ }
61
+ } catch (error) {
62
+ console.error(JSON.stringify({status:'error',message:error.message}));
63
+ process.exitCode = controller.signal.aborted ? 130 : 1;
64
+ } finally {
65
+ process.removeListener('SIGINT',abort); process.removeListener('SIGTERM',abort);
66
+ }
package/src/image.js ADDED
@@ -0,0 +1,32 @@
1
+ import sharp from 'sharp';
2
+
3
+ export const WIDTH = 320, HEIGHT = 172, FILE_SIZE = 20 + WIDTH * HEIGHT * 2;
4
+
5
+ export async function encodeImage(input, { target = 'theme' } = {}) {
6
+ if (!['theme', 'custom'].includes(target)) throw new Error('target must be theme or custom');
7
+ const {data, info} = await sharp(input, {limitInputPixels: 40_000_000})
8
+ .autoOrient().resize(WIDTH, HEIGHT, {fit:'cover', position:'centre'})
9
+ .flatten({background:'#000000'}).toColourspace('srgb').removeAlpha()
10
+ .raw().toBuffer({resolveWithObject:true});
11
+ if (info.channels !== 3) throw new Error('Expected RGB pixels');
12
+ const file = Buffer.alloc(FILE_SIZE);
13
+ file.write(target === 'theme' ? 'ABKT' : 'ABKG');
14
+ file.writeUInt16LE(20,4); file.writeUInt16LE(20,6);
15
+ file.writeUInt32LE(FILE_SIZE,8);
16
+ file.writeUInt16LE(WIDTH,12); file.writeUInt16LE(HEIGHT,14);
17
+ file.writeUInt16LE(1,18);
18
+ for (let p = 0; p < WIDTH * HEIGHT; p++) {
19
+ const i = p * 3;
20
+ file.writeUInt16LE(((data[i] >> 3) << 11) | ((data[i+1] >> 2) << 5) | (data[i+2] >> 3), 20 + p * 2);
21
+ }
22
+ return file;
23
+ }
24
+
25
+ export function validateImage(file) {
26
+ if (!Buffer.isBuffer(file) || file.length !== FILE_SIZE ||
27
+ !['ABKT','ABKG'].includes(file.toString('ascii',0,4)) ||
28
+ file.readUInt16LE(4) !== 20 || file.readUInt16LE(6) !== 20 ||
29
+ file.readUInt32LE(8) !== FILE_SIZE || file.readUInt16LE(12) !== WIDTH ||
30
+ file.readUInt16LE(14) !== HEIGHT || file.readUInt16LE(16) !== 0 || file.readUInt16LE(18) !== 1)
31
+ throw new Error('Invalid 320x172 static ABKT/ABKG file');
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export {encodeImage,validateImage} from './image.js';
2
+ export {devices,upload} from './serial.js';
@@ -0,0 +1,55 @@
1
+ import {setTimeout as sleep} from 'node:timers/promises';
2
+ import {validateImage} from './image.js';
3
+
4
+ export function packet(command, args = Buffer.alloc(0)) {
5
+ if (args.length > 63) throw new Error('Packet payload exceeds 63 bytes');
6
+ const result = Buffer.alloc(64); result[0] = command; args.copy(result,1); return result;
7
+ }
8
+
9
+ function echo(response, command, args) {
10
+ if (response.length !== 64 || response[0] !== command || !response.subarray(1,1+args.length).equals(args))
11
+ throw new Error(`Invalid CDC response for 0x${command.toString(16)}`);
12
+ }
13
+
14
+ // IO exposes write/read/drain; read must buffer split and coalesced serial events.
15
+ // Wire protocol has no render-complete command: never report visual success here.
16
+ export async function transfer(io, file, {
17
+ signal, packetDelayMs = 6, settleMs = 2000, timeoutMs = 5000, onProgress = () => {},
18
+ } = {}) {
19
+ validateImage(file);
20
+ for (const [key,value] of Object.entries({packetDelayMs,settleMs,timeoutMs})) {
21
+ if (!Number.isFinite(value) || value < 0 || (key === 'timeoutMs' && value === 0))
22
+ throw new Error(`Invalid ${key}`);
23
+ }
24
+ const check = () => signal?.throwIfAborted();
25
+ const delay = ms => sleep(ms, undefined, {signal});
26
+ let started = false;
27
+ try {
28
+ check(); started = true;
29
+ await io.write(packet(0xe0,file.subarray(0,20)), timeoutMs, signal);
30
+ const response = await io.read(64,timeoutMs,signal);
31
+ echo(response,0xe0,file.subarray(0,20));
32
+ if (response[21] === 0xee) throw new Error('Keyboard rejected image header (0xEE)');
33
+ const flowControl = !!response[22];
34
+ onProgress({stage:'sending',sent:0,total:file.length,flowControl});
35
+ for (let offset = 0; offset < file.length; offset += 56) {
36
+ check();
37
+ const chunk = file.subarray(offset,offset+56), args = Buffer.alloc(5+chunk.length);
38
+ args.writeUInt32BE(offset,0); args[4] = chunk.length; chunk.copy(args,5);
39
+ await io.write(packet(0xe1,args),timeoutMs,signal);
40
+ if (flowControl) echo(await io.read(64,timeoutMs,signal),0xe1,args);
41
+ else if (packetDelayMs) await delay(packetDelayMs);
42
+ onProgress({stage:'sending',sent:offset+chunk.length,total:file.length,flowControl});
43
+ }
44
+ await io.drain(timeoutMs,signal);
45
+ onProgress({stage:'settling',sent:file.length,total:file.length,flowControl});
46
+ if (settleMs) await delay(settleMs);
47
+ return {status:'sent',bytes:file.length,target:file.toString('ascii',0,4),flowControl,
48
+ packetDelayMs,settleMs,displayVerified:false};
49
+ } catch (error) {
50
+ if (started) {
51
+ try { await io.write(packet(0xe2),1000); await io.drain(1000); } catch {}
52
+ }
53
+ throw error;
54
+ }
55
+ }
package/src/serial.js ADDED
@@ -0,0 +1,85 @@
1
+ import {SerialPort} from 'serialport';
2
+ import {transfer} from './protocol.js';
3
+
4
+ export async function devices() {
5
+ return (await SerialPort.list()).filter(p => parseInt(p.vendorId,16) === 0x514b && parseInt(p.productId,16) === 0x4d02);
6
+ }
7
+
8
+ function callback(operation, timeoutMs, signal) {
9
+ return new Promise((resolve,reject) => {
10
+ let timer;
11
+ const finish = error => {
12
+ clearTimeout(timer); signal?.removeEventListener('abort',abort);
13
+ error ? reject(error) : resolve();
14
+ };
15
+ const abort = () => finish(signal.reason ?? new Error('Aborted'));
16
+ if (signal?.aborted) return abort();
17
+ signal?.addEventListener('abort',abort,{once:true});
18
+ timer = setTimeout(() => finish(new Error('Serial operation timed out')),timeoutMs);
19
+ try { operation(finish); } catch (error) { finish(error); }
20
+ });
21
+ }
22
+
23
+ export class SerialIO {
24
+ constructor(port) {
25
+ this.port = port; this.buffer = Buffer.alloc(0); this.failure = null; this.waiter = null;
26
+ this.onData = data => {
27
+ this.buffer = Buffer.concat([this.buffer,data]);
28
+ if (this.buffer.length > 65536) this.failure = new Error('Unexpected serial response overflow');
29
+ this.pump();
30
+ };
31
+ this.onError = error => { this.failure = error; this.pump(); };
32
+ this.onClose = () => this.onError(new Error('Keyboard disconnected'));
33
+ port.on('data',this.onData); port.on('error',this.onError); port.on('close',this.onClose);
34
+ }
35
+ pump() {
36
+ if (!this.waiter) return;
37
+ if (this.failure) return this.waiter.finish(this.failure);
38
+ if (this.buffer.length >= this.waiter.size) {
39
+ const result = this.buffer.subarray(0,this.waiter.size);
40
+ this.buffer = this.buffer.subarray(this.waiter.size);
41
+ this.waiter.finish(null,result);
42
+ }
43
+ }
44
+ read(size,timeoutMs,signal) {
45
+ if (this.waiter) return Promise.reject(new Error('Concurrent serial reads are not supported'));
46
+ return new Promise((resolve,reject) => {
47
+ let timer;
48
+ const finish = (error,result) => {
49
+ clearTimeout(timer); signal?.removeEventListener('abort',abort); this.waiter = null;
50
+ error ? reject(error) : resolve(result);
51
+ };
52
+ const abort = () => finish(signal.reason ?? new Error('Aborted'));
53
+ if (signal?.aborted) return abort();
54
+ signal?.addEventListener('abort',abort,{once:true});
55
+ timer = setTimeout(() => finish(new Error(`Timed out waiting for ${size} CDC bytes`)),timeoutMs);
56
+ this.waiter = {size,finish}; this.pump();
57
+ });
58
+ }
59
+ write(bytes,timeoutMs,signal) {
60
+ if (this.failure) return Promise.reject(this.failure);
61
+ return callback(done => this.port.write(bytes,done),timeoutMs,signal);
62
+ }
63
+ drain(timeoutMs,signal) { return callback(done => this.port.drain(done),timeoutMs,signal); }
64
+ dispose() {
65
+ this.waiter?.finish(new Error('Serial connection disposed'));
66
+ this.port.off('data',this.onData); this.port.off('error',this.onError); this.port.off('close',this.onClose);
67
+ }
68
+ }
69
+
70
+ export async function upload(file, options = {}) {
71
+ const found = await devices();
72
+ const match = options.port ? found.find(p => p.path === options.port) : found.length === 1 ? found[0] : null;
73
+ if (!match) throw new Error(found.length > 1 ? 'Multiple keyboards found; specify --port' : 'QK80 MK2 USB serial port not found');
74
+ options.signal?.throwIfAborted();
75
+ const port = new SerialPort({path:match.path,baudRate:115200,autoOpen:false,lock:true});
76
+ const io = new SerialIO(port);
77
+ try {
78
+ // Wait for open to finish before cleanup, even if SIGINT arrives meanwhile.
79
+ await callback(done => port.open(done),options.timeoutMs ?? 5000);
80
+ return {...await transfer(io,file,options),port:match.path};
81
+ } finally {
82
+ try { if (port.isOpen) await callback(done => port.close(done),3000); }
83
+ finally { io.dispose(); }
84
+ }
85
+ }