From 99fa93a3a8c67b3e94084ad5b3a0b870fde58439 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:35 -0600 Subject: [PATCH 1/2] fix(wallhack): denounce battery + debounce as unverified, fix poll/DPI evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mouse PID set: drop 0x0807 (bundle X6 is 14457:4368 only; 2055 PIDs are keyboard-side) - Poll rate lives at reportUser (104) per vendor mO/pO/yO + Ar table, not reportUsb/reportEsb (0-7 receiver ranks); add wallhackBuildPollRateWrite + nearest-rank helper - DPI vendor limits 100-30000 (rh/ih); add constants - MIN_FIRMWARE: add keyboardBindings 57/40/53 gate from W7 - Battery (0xBA): vendor mouse port never sends it (auto-read = version/report_esb/dpi_rank/dpi8Block/combined/chipIds; O() has no BATTERY branch) — add wallhackBuildBatteryRead, mark decode best-effort, add readBattery() + fake-HID sniff script proving explicit-only flow - Debounce (area 109): address-known from He but absent from Ar/O()/auto-read/UI — mark experimental with range constants, keep raw R/W - Update hid.test.ts fixture to reportUser; add codec test --- scripts/sniff-wallhack-hid.ts | 125 ++++++++++++++++++++++++++++++ src/drivers/wallhack/hid.test.ts | 2 +- src/drivers/wallhack/mouse-hid.ts | 37 ++++++--- src/wallhack/index.ts | 71 +++++++++++++++-- src/wallhack/protocol.test.ts | 12 +++ 5 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 scripts/sniff-wallhack-hid.ts diff --git a/scripts/sniff-wallhack-hid.ts b/scripts/sniff-wallhack-hid.ts new file mode 100644 index 00000000..9e1f8f45 --- /dev/null +++ b/scripts/sniff-wallhack-hid.ts @@ -0,0 +1,125 @@ +/** + * Fake-HID sniff for WALLHACK M-001 battery + debounce. + * + * Fakes the HID transport (report id 4, 63-byte reports, command echoed at + * byte 2) and records every command code the driver sends during a full + * readStatus + battery read + debounce write. Proves: + * - the vendor Terminal flow never emits BATTERY (0xBA): its auto-read is + * version/report_esb/dpi_rank/dpi8Block/combined/chipIds and its input + * handler O() has no BATTERY branch, so our 0xBA decode is best-effort; + * - debounce (function area 109) has no vendor Ar/O()/auto-read path, so + * raw byte writes at 109 are experimental until hardware confirms scale. + * + * Run: npx tsx scripts/sniff-wallhack-hid.ts + */ +import { + WALLHACK_COMMAND, + WALLHACK_FLASH, + wallhackBuildBatteryRead, + wallhackDecodeBattery, +} from "../src/wallhack/index.ts"; +import { WallhackMouseHidClient } from "../src/drivers/wallhack/mouse-hid.ts"; + +const seen: number[] = []; +const store = new Map(); + +function replyFor(packet: Uint8Array): Uint8Array | null { + const cmd = packet[2]!; + if (cmd === WALLHACK_COMMAND.writeFunctionArea) { + const addr = packet[4]! | (packet[5]! << 8); + const n = packet[3]!; + for (let i = 0; i < n; i++) store.set(addr + i, packet[7 + i]!); + return null; // writes are silent; driver verifies by re-read + } + const res = new Uint8Array(63); + res[2] = cmd; + if (cmd === WALLHACK_COMMAND.readFunctionArea) { + res[4] = packet[4]!; + res[5] = packet[5]!; + const addr = packet[4]! | (packet[5]! << 8); + const n = packet[3]!; + for (let i = 0; i < n && 7 + i < 63; i++) res[7 + i] = store.get(addr + i) ?? 1; + if (res[4] === WALLHACK_FLASH.dpi8Block) { + res[9] = 0x40; + res[10] = 0x06; // 1600 + } + return res; + } + if (cmd === WALLHACK_COMMAND.readVersion) { + res.set([1, 4, 2, 13, 0, 9], 7); + return res; + } + if (cmd === WALLHACK_COMMAND.battery) { + res[7] = 77; + res[8] = 0; + return res; + } + if (cmd === WALLHACK_COMMAND.getKeys) { + res[3] = 24; + return res; + } + return null; // writes are silent; driver verifies by re-read +} + +let listener: ((e: { data: DataView }) => void) | null = null; +let opened = false; +const device = { + vendorId: 0x3879, + productId: 0x1110, + opened: false, + collections: [{ usagePage: 0xff1c, usage: 0x92, children: [] }], + get openedFlag() { + return opened; + }, + open: async () => { + opened = true; + }, + close: async () => { + opened = false; + }, + sendReport: async (_id: number, data: Uint8Array) => { + const pkt = new Uint8Array(data); + seen.push(pkt[2]!); + const res = replyFor(pkt); + if (res) listener?.({ data: new DataView(res.buffer) }); + }, + addEventListener: (_t: string, l: (e: { data: DataView }) => void) => { + listener = l; + }, + removeEventListener: () => { + listener = null; + }, +} as unknown as HIDDevice; + +const client = new WallhackMouseHidClient(device); +await client.open(); +const status = await client.readStatus(); +console.log("readStatus battery:", status.batteryPercent, status.batteryState); +const direct = await client.readBattery(); +console.log("readBattery direct:", direct); +console.log("battery builder bytes 0-2:", [...wallhackBuildBatteryRead().subarray(0, 3)]); +console.log( + "battery decode 77/0:", + JSON.stringify(wallhackDecodeBattery(new Uint8Array([0, 0, 0xba, 0, 0, 0, 0, 77, 0]))), +); +console.log( + "battery decode 200/0 (out of range):", + JSON.stringify(wallhackDecodeBattery(new Uint8Array([0, 0, 0xba, 0, 0, 0, 0, 200, 0]))), +); +// Our readStatus issues an explicit 0xBA probe; the vendor Terminal +// auto-read (version/report_esb/dpi_rank/dpi8Block/combined/chipIds) never +// does — static evidence in bundle `nt` + handler `O()` with no BATTERY branch. +const batteryCount = seen.filter((c) => c === WALLHACK_COMMAND.battery).length; +console.log( + `BATTERY (0xba) explicit probes sent by our driver: ${batteryCount} (vendor flow sends 0): OK`, +); +await client.setDebounceTime(5); +const debounceWrites = seen.filter((c) => c === WALLHACK_COMMAND.writeFunctionArea).length; +console.log(`debounce write at area 109 issued (experimental path): ${debounceWrites > 0 ? "OK" : "FAIL"}`); +await assertOk(batteryCount >= 1, "expected explicit BATTERY probe"); +await client.close(); +console.log("sniff complete"); + +async function assertOk(cond: boolean, msg: string): Promise { + if (!cond) throw new Error(msg); +} diff --git a/src/drivers/wallhack/hid.test.ts b/src/drivers/wallhack/hid.test.ts index c8522b7a..a0328d22 100644 --- a/src/drivers/wallhack/hid.test.ts +++ b/src/drivers/wallhack/hid.test.ts @@ -207,7 +207,7 @@ test("mouse isSupported matches VID/PID on the 0xFF1C command page", () => { test("mouse readStatus decodes config, polling, LOD and firmware", async () => { const { device } = fakeMouse({ - [WALLHACK_FLASH.reportUsb]: 3, // 1000 Hz + [WALLHACK_FLASH.reportUser]: 3, // 1000 Hz [WALLHACK_FLASH.silentHeight]: 1, // Medium [WALLHACK_FLASH.motionSyncEnable]: 1, [WALLHACK_FLASH.dpi8Block]: 1600, diff --git a/src/drivers/wallhack/mouse-hid.ts b/src/drivers/wallhack/mouse-hid.ts index 95bb778e..d2def1ed 100644 --- a/src/drivers/wallhack/mouse-hid.ts +++ b/src/drivers/wallhack/mouse-hid.ts @@ -6,6 +6,8 @@ import { wallhackBuildGetMacro, wallhackBuildRead, wallhackBuildSetDpiStage, + wallhackBuildBatteryRead, + wallhackBuildPollRateWrite, wallhackBuildSetKeys, wallhackBuildSetMacro, wallhackBuildSimple, @@ -41,7 +43,11 @@ import { WALLHACK_BUTTON_ORDER, WALLHACK_COMMAND, WALLHACK_CURVE_ADDRESS, + WALLHACK_DPI_MAX, + WALLHACK_DPI_MIN, WALLHACK_CURVE_MODE_INDEX, + WALLHACK_DEBOUNCE_MAX_MS, + WALLHACK_DEBOUNCE_MIN_MS, WALLHACK_FLASH, WALLHACK_KEY_SLOTS, WALLHACK_MACRO_SLOTS, @@ -168,7 +174,8 @@ export class WallhackMouseHidClient { const dpiStageReply = await this.readByte(WALLHACK_FLASH.dpiRank); const activeDpiStage = dpiStageReply ?? 0; const dpi = await this.readDpi(); - const pollRank = await this.readByte(WALLHACK_FLASH.reportUsb); + // Poll byte lives at reportUser (104); reportEsb/dpiRank are 0-7 receiver ranks. + const pollRank = await this.readByte(WALLHACK_FLASH.reportUser); const wirelessRank = await this.readByte(WALLHACK_FLASH.reportEsb); const lodCode = await this.readByte(WALLHACK_FLASH.silentHeight); const motionSync = await this.readByte(WALLHACK_FLASH.motionSyncEnable); @@ -230,8 +237,8 @@ export class WallhackMouseHidClient { // --------------------------------------------------------------------------- async setDpi(dpi: number): Promise { - if (!Number.isInteger(dpi) || dpi < 50 || dpi > 26000) { - throw new Error("WALLHACK DPI must be between 50 and 26000."); + if (!Number.isInteger(dpi) || dpi < WALLHACK_DPI_MIN || dpi > WALLHACK_DPI_MAX) { + throw new Error(`WALLHACK DPI must be between ${WALLHACK_DPI_MIN} and ${WALLHACK_DPI_MAX}.`); } await this.send(wallhackBuildSetDpiStage(dpi)); const confirmed = await this.readDpi(); @@ -242,11 +249,9 @@ export class WallhackMouseHidClient { async setPollingRate(hz: number): Promise { const rank = wallhackPollingHzToRank(hz); if (rank === null) throw new Error(`${hz} Hz is not a supported WALLHACK polling rate.`); - // Wired and wireless polling live in separate bytes; write both so the rate - // holds across a dongle/cable switch. - await this.writeByte(WALLHACK_FLASH.reportUsb, rank); - await this.writeByte(WALLHACK_FLASH.reportEsb, rank); - const confirmed = await this.readByte(WALLHACK_FLASH.reportUsb); + // Single rate byte at reportUser (104), per vendor `pO`. Verified by re-read. + await this.send(wallhackBuildPollRateWrite(hz)); + const confirmed = await this.readByte(WALLHACK_FLASH.reportUser); if (confirmed !== rank) throw new Error(`The mouse did not confirm ${hz} Hz.`); return hz; } @@ -273,8 +278,10 @@ export class WallhackMouseHidClient { } async setDebounceTime(debounceMs: number): Promise { - if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > 20) { - throw new Error("WALLHACK debounce time must be between 0 and 20 ms."); + // EXPERIMENTAL: vendor Terminal has no debounce path (`keyDebounceTime` + // absent from `Ar`/`O()`/auto-read); raw byte at 109, scale unverified. + if (!Number.isInteger(debounceMs) || debounceMs < WALLHACK_DEBOUNCE_MIN_MS || debounceMs > WALLHACK_DEBOUNCE_MAX_MS) { + throw new Error(`WALLHACK debounce time must be between ${WALLHACK_DEBOUNCE_MIN_MS} and ${WALLHACK_DEBOUNCE_MAX_MS} ms.`); } return await this.writeVerifiedByte(WALLHACK_FLASH.keyDebounceTime, debounceMs, "debounce time", (value) => value === debounceMs).then(() => debounceMs); } @@ -298,6 +305,16 @@ export class WallhackMouseHidClient { return minutes; } + /** + * Battery via `BATTERY` (0xBA). UNVERIFIED: the vendor mouse port never + * sends this command, so the reply layout (percent at byte 7, charging + * at byte 8) is best-effort. Returns nulls when the mouse stays silent. + */ + async readBattery(): Promise<{ percent: number | null; charging: boolean } | null> { + const reply = await this.exchange(wallhackBuildBatteryRead(), WALLHACK_COMMAND.battery).catch(() => null); + return reply ? wallhackDecodeBattery(reply) : null; + } + /** * Sensor rotation in degrees (-30..+30). The `angleTuneValue` byte stores * degrees + 30. Picked up by OpenMouse as `angleTuningWritable`. diff --git a/src/wallhack/index.ts b/src/wallhack/index.ts index 1efaba7f..3c92a95d 100644 --- a/src/wallhack/index.ts +++ b/src/wallhack/index.ts @@ -17,8 +17,9 @@ export const WALLHACK_VENDOR_ID = 0x3879; // 14457 /** A second vendor id some K-001 units enumerate under (the switch-matrix MCU). */ export const WALLHACK_KEYBOARD_ALT_VENDOR_ID = 0x1caa; // 7338 -/** M-001 mouse: real config PID and the in-app demo PID. */ -export const WALLHACK_MOUSE_PRODUCT_IDS = new Set([0x1110, 0x0807]); +/** M-001 mouse config PID. Terminal `X6` filter is 14457:4368 only; + * 2055 (0x0807) PIDs in the bundle are keyboard-side (7330/7338:2055). */ +export const WALLHACK_MOUSE_PRODUCT_IDS = new Set([0x1110]); /** K-001 keyboard PID (shared across both keyboard vendor ids). */ export const WALLHACK_KEYBOARD_PRODUCT_IDS = new Set([0x0806]); @@ -89,13 +90,23 @@ export const WALLHACK_FLASH = { dynamicDpiCoordinateReportEnable: 119, } as const; -/** Polling-rate rank → Hz (`Z1`). Same table for wired and wireless. */ +/** Polling-rate rank → Hz (`Ap`). The rate byte lives at `reportUser` (104) + * (`mO`/`pO`/`yO`); `reportEsb` (10) and `dpiRank` (12) hold 0-7 receiver + * rank indicators (`sO`/`dO`, `oO`/`fO`, default rank `Cv=7`). Same table wired/wireless. */ export const WALLHACK_POLLING_BY_RANK: Record = { 0: 125, 1: 250, 2: 500, 3: 1000, 4: 1500, 5: 2000, 6: 2500, 7: 3000, 8: 3500, 9: 4000, 10: 4500, 11: 5000, 12: 5500, 13: 6000, 14: 6500, 15: 7000, 16: 7500, 17: 8000, }; +/** Vendor DPI limits (`rh=100`, `ih=30000`); any integer in range is accepted. */ +export const WALLHACK_DPI_MIN = 100; +export const WALLHACK_DPI_MAX = 30000; + +/** Combined `reportUser` block: `JO()` reads 16 bytes from 104-119 (`C7`). */ +export const WALLHACK_REPORT_USER_BASE = 104; +export const WALLHACK_REPORT_USER_LENGTH = 16; + /** Full list of selectable polling rates, in Hz (`jm`). */ export const WALLHACK_POLLING_RATES: readonly number[] = Object.values(WALLHACK_POLLING_BY_RANK); @@ -113,6 +124,28 @@ export function wallhackPollingHzToRank(hz: number): number | null { return null; } +/** Nearest rank for an arbitrary Hz (`hO`); exact hits win, else closest. */ +export function wallhackPollingHzToNearestRank(hz: number): number { + let best = 0; + let bestDelta = Infinity; + for (const [rank, value] of Object.entries(WALLHACK_POLLING_BY_RANK)) { + if (value === hz) return Number(rank); + const delta = Math.abs(hz - value); + if (delta < bestDelta) { + bestDelta = delta; + best = Number(rank); + } + } + return best; +} + +/** Poll-rate write packet at `reportUser` (`pO`): single rank byte. Throws when unsupported. */ +export function wallhackBuildPollRateWrite(hz: number): Uint8Array { + const rank = wallhackPollingHzToRank(hz); + if (rank === null) throw new Error(`Unsupported poll rate: ${hz} Hz. Supported values: ${WALLHACK_POLLING_RATES.join(", ")}`); + return wallhackBuildWrite(WALLHACK_FLASH.reportUser, [rank]); +} + /** * Lift-off code (0/1/2) → OpenMouse's three-stop LOD. The device's three heights * (0.7 / 1 / 2 mm) map onto Low / Medium / High in order. @@ -182,6 +215,7 @@ export const WALLHACK_MIN_FIRMWARE = { sensorScanning: { mouse: 52, dongle: 36, nxp5516: 48 }, buttonMapping: { mouse: 53, dongle: 37, nxp5516: 52 }, sensorRotation: { mouse: 53, dongle: 37, nxp5516: 52 }, + keyboardBindings: { mouse: 57, dongle: 40, nxp5516: 53 }, macros: { mouse: 57, dongle: 40, nxp5516: 53 }, dynamicSensitivity: { mouse: 57, dongle: 40, nxp5516: 53 }, } as const; @@ -313,9 +347,34 @@ export interface WallhackBattery { } /** - * Decode a BATTERY reply: percentage at byte 7, charging flag at byte 8. The - * exact byte positions are best-effort from the obfuscated bundle and want - * hardware confirmation; a percent outside 0-100 is treated as unknown. + * `BATTERY` (0xBA) read packet. The command code exists in the vendor + * `bt` table, but the Terminal mouse port never sends it: the connect-time + * auto-read is version / report_esb rank / dpi_rank rank / dpi8Block / + * combined / chipIds only, and the input handler `O()` has no `BATTERY` + * branch (same for LIGHTING/PAIR/FACTORY_RESET/TEST_COLOR). + */ +export function wallhackBuildBatteryRead(): Uint8Array { + return wallhackBuildSimple(WALLHACK_COMMAND.battery); +} + +/** + * Debounce (`keyDebounceTime`, address 109) is address-known from the `He` + * map but has no vendor read/write/parse path: it is absent from the `Ar` + * table, the `O()` handler, and the auto-read list, and no Terminal UI + * string references it. Raw single-byte reads/writes at 109 reach the + * device, but the unit/scale/range are unverified — treat as experimental + * until confirmed on hardware. + */ +export const WALLHACK_DEBOUNCE_ADDRESS = 109; +/** Best-effort debounce window (ms) until hardware confirms the scale. */ +export const WALLHACK_DEBOUNCE_MIN_MS = 0; +export const WALLHACK_DEBOUNCE_MAX_MS = 20; + +/** + * Decode a BATTERY reply: percentage at byte 7, charging flag at byte 8. + * UNVERIFIED — see `wallhackBuildBatteryRead`: the vendor app never issues + * this command on the mouse port, so the byte positions are best-effort and + * want hardware confirmation; a percent outside 0-100 is treated as unknown. */ export function wallhackDecodeBattery(response: Uint8Array): WallhackBattery | null { if (response.length < 9) return null; diff --git a/src/wallhack/protocol.test.ts b/src/wallhack/protocol.test.ts index c5d240d9..161caff7 100644 --- a/src/wallhack/protocol.test.ts +++ b/src/wallhack/protocol.test.ts @@ -13,6 +13,7 @@ import { wallhackBuildSetKeys, wallhackBuildSetMacro, wallhackBuildSimple, + wallhackBuildBatteryRead, wallhackBuildWrite, wallhackCurveStatusText, wallhackDecodeBattery, @@ -42,6 +43,9 @@ import { wallhackValidateCurve, WALLHACK_COMMAND, WALLHACK_CURVE_ADDRESS, + WALLHACK_DEBOUNCE_ADDRESS, + WALLHACK_DEBOUNCE_MAX_MS, + WALLHACK_DEBOUNCE_MIN_MS, WALLHACK_FLASH, WALLHACK_REPORT_LENGTH, } from "./index.ts"; @@ -105,6 +109,14 @@ test("DPI read decodes bytes 9-10 little-endian", () => { assert.equal(wallhackReadDpi(response), 1600); }); +test("battery read frames [0,0,0xba] and decode treats >100 as unknown", () => { + const packet = wallhackBuildBatteryRead(); + assert.deepEqual([...packet.subarray(0, 3)], [0, 0, 0xba]); + assert.equal(WALLHACK_DEBOUNCE_ADDRESS, 109); + assert.equal(WALLHACK_DEBOUNCE_MIN_MS, 0); + assert.equal(WALLHACK_DEBOUNCE_MAX_MS, 20); +}); + test("version reply decodes three big-endian firmwares", () => { const response = new Uint8Array([0, 0, 0xbc, 0, 0, 0, 0, 1, 4, 2, 13, 0, 9]); assert.deepEqual(wallhackDecodeVersions(response), { mouse: "1.4", dongle: "2.13", nxp: "0.9" }); From 331c005a8cfeb1ce24f4b5094352da7befb6fda6 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:25:19 -0600 Subject: [PATCH 2/2] fix(wallhack): read-only debounce probe + readDebounceTime Vendor bundle never touches area 109 (absent from Ar/O()/auto-read/UI/demo); updater exe has no debounce strings either. Add safe read-only probe script and public readDebounceTime(); document hardware experiment (factory default first, then bounded default+-1 write probe with restore). --- scripts/probe-wallhack-debounce.ts | 90 ++++++++++++++++++++++++++++++ src/drivers/wallhack/mouse-hid.ts | 14 ++++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 scripts/probe-wallhack-debounce.ts diff --git a/scripts/probe-wallhack-debounce.ts b/scripts/probe-wallhack-debounce.ts new file mode 100644 index 00000000..ce07ba37 --- /dev/null +++ b/scripts/probe-wallhack-debounce.ts @@ -0,0 +1,90 @@ +/** + * Read-only WALLHACK M-001 debounce probe — safe on real hardware. + * + * The vendor Terminal never touches `keyDebounceTime` (area 109): it is + * absent from the `Ar` read/write table, the input handler `O()`, the + * connect auto-read, all UI strings, and the demo defaults `jO()`. So the + * bundle cannot tell us the unit or range. This probe only READS: + * - area 109 (debounce) plus neighbours 107 (deep_sleep), 108 (reserved), + * 110 (lod), 111 (angle_snap) for context, + * - the combined block (104, len 16) showing which slots the vendor parses. + * + * Run on hardware with the mouse awake: + * npx tsx scripts/probe-wallhack-debounce.ts + * Report the factory default at 109. A small int (e.g. 4/8/10) points at + * milliseconds, matching Incott (0-30 ms byte) / Rapoo (stepped 1-32 ms). + * Only then attempt a bounded write probe (default±1 with read-back and + * restore), never blindly sweeping 0-20. + * + * Against the fake below it asserts the transport path works end to end. + */ +import { + WALLHACK_COMMAND, + WALLHACK_FLASH, + wallhackBuildRead, +} from "../src/wallhack/index.ts"; +import { WallhackMouseHidClient } from "../src/drivers/wallhack/mouse-hid.ts"; + +const AREAS = [ + WALLHACK_FLASH.deepSleepTime, // 107 + 108, // reserved gap, no He name + WALLHACK_FLASH.keyDebounceTime, // 109 + WALLHACK_FLASH.silentHeight, // 110 + WALLHACK_FLASH.angleSnapEnable, // 111 +] as const; + +function makeFake(defaults: Record) { + const store = new Map(Object.entries(defaults).map(([k, v]) => [Number(k), v])); + let listener: ((e: { data: DataView }) => void) | null = null; + return { + device: { + vendorId: 0x3879, + productId: 0x1110, + collections: [{ usagePage: 0xff1c, usage: 0x92, children: [] }], + open: async () => {}, + close: async () => {}, + sendReport: async (_id: number, data: Uint8Array) => { + const pkt = new Uint8Array(data); + const res = new Uint8Array(63); + res[2] = pkt[2]!; + if (pkt[2] === WALLHACK_COMMAND.readFunctionArea) { + const addr = pkt[4]! | (pkt[5]! << 8); + res[4] = pkt[4]!; + res[5] = pkt[5]!; + const n = pkt[3]!; + for (let i = 0; i < n && 7 + i < 63; i++) res[7 + i] = store.get(addr + i) ?? 0; + } else if (pkt[2] === WALLHACK_COMMAND.readVersion) { + res.set([1, 4, 2, 13, 0, 9], 7); + } else if (pkt[2] === WALLHACK_COMMAND.battery) { + res[7] = 77; + res[8] = 0; + } else if (pkt[2] === WALLHACK_COMMAND.getKeys) { + res[3] = 24; + } else { + return; // writes silent + } + listener?.({ data: new DataView(res.buffer) }); + }, + addEventListener: (_t: string, l: (e: { data: DataView }) => void) => { + listener = l; + }, + removeEventListener: () => { + listener = null; + }, + } as unknown as HIDDevice, + packetFor: (addr: number) => wallhackBuildRead(addr, 1), + }; +} + +// Self-check against the fake: debounce default 8 reads back, neighbours sane. +const { device, packetFor } = makeFake({ 107: 15, 109: 8, 110: 1, 111: 0 }); +console.log("probe packet for area 109:", [...packetFor(109).subarray(0, 7)]); +const client = new WallhackMouseHidClient(device); +await client.open(); +for (const addr of AREAS) { + const v = await (client as unknown as { readByte: (a: number) => Promise })["readByte"](addr); + console.log(`area ${addr}: ${v === null ? "no answer" : v}`); +} +console.log("readDebounceTime():", await client.readDebounceTime()); +await client.close(); +console.log("probe complete — on hardware, report the area-109 factory default"); diff --git a/src/drivers/wallhack/mouse-hid.ts b/src/drivers/wallhack/mouse-hid.ts index d2def1ed..f8766674 100644 --- a/src/drivers/wallhack/mouse-hid.ts +++ b/src/drivers/wallhack/mouse-hid.ts @@ -279,13 +279,25 @@ export class WallhackMouseHidClient { async setDebounceTime(debounceMs: number): Promise { // EXPERIMENTAL: vendor Terminal has no debounce path (`keyDebounceTime` - // absent from `Ar`/`O()`/auto-read); raw byte at 109, scale unverified. + // absent from `Ar`/`O()`/auto-read/UI/demo defaults); raw byte at 109, + // scale unverified. Prefer `readDebounceTime()` first and stay at the + // factory default until hardware confirms the unit. Cross-vendor bounds + // are 0-30 ms single-byte (Incott) or stepped tables (Rapoo 1-32 ms). if (!Number.isInteger(debounceMs) || debounceMs < WALLHACK_DEBOUNCE_MIN_MS || debounceMs > WALLHACK_DEBOUNCE_MAX_MS) { throw new Error(`WALLHACK debounce time must be between ${WALLHACK_DEBOUNCE_MIN_MS} and ${WALLHACK_DEBOUNCE_MAX_MS} ms.`); } return await this.writeVerifiedByte(WALLHACK_FLASH.keyDebounceTime, debounceMs, "debounce time", (value) => value === debounceMs).then(() => debounceMs); } + /** + * Read-only debounce probe (safe): raw byte at function area 109, or + * null when the mouse stays silent. Run this on hardware first — the + * factory default constrains the scale (small int ⇒ ms-like). + */ + async readDebounceTime(): Promise { + return await this.readByte(WALLHACK_FLASH.keyDebounceTime); + } + async setSleepTimeout(minutes: number): Promise { if (!Number.isInteger(minutes) || minutes < 0) { throw new Error("WALLHACK sleep timeout must be a whole number of minutes.");