Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/shims/node-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,35 @@ const ExtendedHID = {

readP = promisify((arg: any) => this.read(arg));

// Like read(), but gives up after `ms` and removes its waiter so a
// report that never comes cannot wedge the command queue. Used by the
// resync loop in KeyboardAPI._hidCommand.
readWithTimeout(ms: number): Promise<Uint8Array> {
return new Promise((res, rej) => {
this.fastForwardGlobalBuffer(lastWriteTimestamp);
if (globalBuffer[this.path].length > 0) {
res(globalBuffer[this.path].shift()?.message as Uint8Array);
return;
}
let settled = false;
const waiter = (data: Uint8Array) => {
if (settled) return;
settled = true;
clearTimeout(timer);
res(data);
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
eventWaitBuffer[this.path] = eventWaitBuffer[this.path].filter(
(w) => w !== waiter,
);
rej(new Error(`HID read timed out after ${ms}ms`));
}, ms);
eventWaitBuffer[this.path].push(waiter);
});
}

// The idea is discard any messages that have happened before the time a command was issued
// since time-travel is not possible yet...
fastForwardGlobalBuffer(time: number) {
Expand Down
41 changes: 39 additions & 2 deletions src/utils/keyboard-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export {KeyboardValue} from './keyboard-values';
// VIA Command IDs

const COMMAND_START = 0x00; // This is really a HID Report ID
// MYVIA resync tuning (see _hidCommand). Round trips on a healthy board are
// 3-25ms, so 250ms is generous without stalling the UI on a dead command.
const RESYNC_MAX_STALE_READS = 4;
const RESYNC_READ_TIMEOUT_MS = 250;
const HID_UNHANDLED_COMMAND = 0xff; // QMK via.c id_unhandled
const PER_KEY_RGB_CHANNEL_COMMAND = [0, 1];

enum APICommand {
Expand Down Expand Up @@ -759,8 +764,40 @@ export class KeyboardAPI {

await this.getHID().write(paddedArray);

const buffer = Array.from(await this.getByteBuffer());
const bufferCommandBytes = buffer.slice(0, commandBytes.length - 1);
// MYVIA resync: the stock implementation reads exactly one report and
// throws on an echo mismatch, never draining the stale reply. One stray
// report (a second tab, a concurrent rescan) then shifts every following
// response by one until the page is reloaded (the-via/app#228).
// Here we discard replies that clearly belong to another command and
// read again, bounded by RESYNC_MAX_STALE_READS and a per-read timeout.
// A reply starting with 0xFF (QMK id_unhandled) is a real answer to this
// command and is still treated as an error immediately.
let buffer = Array.from(await this.getByteBuffer());
let bufferCommandBytes = buffer.slice(0, commandBytes.length - 1);
let staleReads = 0;
while (
!eqArr(commandBytes.slice(1), bufferCommandBytes) &&
buffer[0] !== HID_UNHANDLED_COMMAND &&
staleReads < RESYNC_MAX_STALE_READS
) {
staleReads++;
console.warn(
`Stale HID report for ${this.kbAddr}, resyncing (${staleReads}/${RESYNC_MAX_STALE_READS})`,
'expected:',
commandBytes.slice(1),
'got:',
buffer,
);
try {
buffer = Array.from(
await this.getHID().readWithTimeout(RESYNC_READ_TIMEOUT_MS),
);
} catch (e) {
// Nothing else arrived: the mismatched report was the only answer.
break;
}
bufferCommandBytes = buffer.slice(0, commandBytes.length - 1);
}
logCommand(this.kbAddr, commandBytes, buffer);
if (!eqArr(commandBytes.slice(1), bufferCommandBytes)) {
console.error(
Expand Down