Skip to content
Merged
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
72 changes: 72 additions & 0 deletions captures/incott-8k-wireless/macro-loop-modes-2026-09-14.hex
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Macro loop modes, captured 2026-09-14
#
# Taken the same way as macro-upload-2026-09-11.hex: Incott's own configurator
# at incott.net/mouse/ instrumented with tools/hid-probes/vendor-macro-capture.js
# (IncottHub). The SAME ten-step macro was saved three times, once per loop
# mode, to buffer 3 (the Forward button), and the mouse was watched running
# each one. All three behave as their names say on this unit.
#
# WHY THIS EXISTS. The 2026-09-11 capture only ever showed loop 1 with a
# single cycle, so the other two wire values came from reading the vendor's
# `juji_to_hw` rather than from hardware, and no capture showed a repeat count
# above 1 at all. These three pin the whole mapping.
#
# THE OFF-BY-ONE. The vendor's exported macro file numbers the modes from 1:
#
# looptype:1 lpUntilKeyRelease -> wire byte 1 = 0
# looptype:2 lpUntilAnyKey -> wire byte 1 = 1
# looptype:3 lpCycle -> wire byte 1 = 2
#
# so a `looptype:` read straight off an exported file would shift every mode
# one place. Bytes 2..3 are the repeat count, LE16, and are sent as 1 in the
# two modes that do not use them.
#
# The macro, identical in all three (V, L, A+D overlapping, I — "vladi"):
# item[0]:V,53,true item[5]:D,43,true
# item[1]:V,350,false item[6]:A,70,false
# item[2]:L,84,true item[7]:D,1,false
# item[3]:L,31,false item[8]:I,55,true
# item[4]:A,70,true item[9]:I,0,false
#
# ---------------------------------------------------------------------------
# Only the differing bytes are reproduced below: chunks 2-8 are all zeros and
# chunk 9 (the trailer) is byte-identical in all three runs, including the uid
# — the vendor reuses one uid per macro across re-saves.

# --- looptype 3, "cycle 3 times" -------------------------------------------
TX feature id=0x09 07 0a 00 20 03 00 00 00
TX OUTPUT id=0x09 03 02 03 00 01 19 35 00 81 19 5e 01 01 0f 54 00 81 0f 1f 00 01 04 46 00 01 07 2b 00 81 04 46 00
# ^^ ^^ ^^ ^^ buffer 3, loop 2 (cycle), 3 cycles
TX feature id=0x09 07 0a 01 20 03 00 00 00
TX OUTPUT id=0x09 81 07 01 00 01 0c 37 00 81 0c 00 00 00 00 ... (zeros to 32)

# --- looptype 1, "while held" ----------------------------------------------
TX feature id=0x09 07 0a 00 20 03 00 00 00
TX OUTPUT id=0x09 03 00 01 00 01 19 35 00 81 19 5e 01 01 0f 54 00 81 0f 1f 00 01 04 46 00 01 07 2b 00 81 04 46 00
# ^^ ^^ ^^ ^^ buffer 3, loop 0 (until key release), count unused

# --- looptype 2, "until a key is pressed" ----------------------------------
TX feature id=0x09 07 0a 00 20 03 00 00 00
TX OUTPUT id=0x09 03 01 01 00 01 19 35 00 81 19 5e 01 01 0f 54 00 81 0f 1f 00 01 04 46 00 01 07 2b 00 81 04 46 00
# ^^ ^^ ^^ ^^ buffer 3, loop 1 (until any key), count unused

# --- the trailer and the binding, identical in all three -------------------
TX feature id=0x09 07 0a 09 20 03 00 00 00
TX OUTPUT id=0x09 4d 61 63 72 6f 34 00 00 00 00 00 00 00 00 00 00 ac 00 00 00 10 00 e8 e8 70 19 d9 9f 14 00 0a 00
# "Macro4" ac=172=(10+1)*4+128 uid 14=20 0a=10 steps
TX feature id=0x09 06 04 09 00 03 00 00 00 wire button 4 -> 0x00030009 = macro slot 3

# ---------------------------------------------------------------------------
# THE TIMING, which is the other thing these confirm
#
# +8423ms header 0
# +8505ms chunk 0 ~85 ms after its header
# +8509ms header 1 ~4 ms after the chunk
# +8591ms chunk 1
#
# The gap sits BETWEEN a header and its chunk, not after the pair, and the
# vendor never lets anything else onto the device in that window — it has no
# background polling. A driver that does poll has to make each header/chunk
# pair one indivisible operation; queueing the two halves separately lets a
# refresh land between them, and since nothing acknowledges a chunk, the loss
# is silent. See IncottHidClient.uploadMacro and IncottTransactionQueue.sendChunk.
17 changes: 10 additions & 7 deletions docs/incott-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,9 @@ same approach `src/mchose/buttons.ts` already takes in this repo. 127 actions
in total. This was initially recorded as impossible to offer; that was wrong,
and the MCHOSE driver was the counter-example sitting in the same tree.

**Still NOT offered:** macros (`slot << 16 | 9`), which need the `0x07`
upload command.
Macros (`displayIndex << 16 | 9`) are offered as a single `Macro` action.
`setButtonMapping` resolves it through `incottMacroBindingForButton`, using
the button's display index so it runs its own buffer.

### Battery (2026-09-07, DISPROVEN 2026-09-08 — see the top of this document)

Expand Down Expand Up @@ -1251,7 +1252,7 @@ instrumentation, and `captures/incott-8k-wireless/macro-upload-2026-09-11.hex`
for the capture and its decode.

There is no macro READ command, so `uploadMacro` is the one writer in this
driver with nothing to verify against. Binding a button to `Macro <n>` and
driver with nothing to verify against. Binding a button to `Macro` and
pressing it is the only confirmation available.

### The correction this forced
Expand All @@ -1269,10 +1270,12 @@ did. The encoder is now pinned byte-for-byte to that capture.

The capture ended with `06 04 09 00 03 00`, binding wire button 4 to
`0x00030009` — the `fJuji` encoding `slot << 16 | 9`.
`incottButtonActionLabel` now names these (`Macro 4`) instead of reporting
raw hex. `incottButtonActionCode` deliberately does NOT reverse them: nothing
can assign a macro until there is a UI to author one, so an existing macro
binding reads back correctly and is left alone.
`incottButtonActionLabel` names these `Macro`. `buttonOptions` offers only
that single action; `setButtonMapping` resolves it through
`incottMacroBindingForButton` using the button's display index, which differs
from its wire index. `incottButtonActionCode` cannot resolve `Macro` without
that button context. A button can only run its own buffer, so numbered-slot
choices would offer bindings the hardware silently ignores.

### Why there is no UI for this either

Expand Down
116 changes: 115 additions & 1 deletion src/drivers/incott/hid.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import { INCOTT_PRODUCT_ID, INCOTT_PRODUCT_ID_WIRED, INCOTT_REPORT_ID, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../../incott/index.ts";
import { INCOTT_MACRO_CHUNK_SETTLE_MS, INCOTT_PRODUCT_ID, INCOTT_PRODUCT_ID_WIRED, INCOTT_REPORT_ID, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../../incott/index.ts";
import {
IncottHidClient,
IncottTransactionQueue,
Expand Down Expand Up @@ -1529,6 +1529,90 @@ test("uploadMacro interleaves ten headers with ten 32-byte output reports", asyn
assert.deepEqual(rejoined.slice(0, 8), [0x03, 0x01, 0x01, 0x00, 0x01, 0x0f, 0x7c, 0x00]);
});

test("uploadMacro stops before sending a chunk whose header failed", async () => {
const { device, outputs } = fakeDevice();
const sendFeatureReport = device.sendFeatureReport.bind(device);
const failure = new Error("macro header failed");
let headers = 0;
device.sendFeatureReport = async (reportId, data) => {
if (++headers === 2) throw failure;
return sendFeatureReport(reportId, data);
};
const client = new IncottHidClient(device, fast);
await assert.rejects(
() => client.uploadMacro({ bufferId: 0, loop: "cycle", cycles: 1, uid: 0, steps: [] }),
(error: unknown) => error === failure,
);
assert.equal(headers, 2, "no later chunk headers are sent");
assert.equal(outputs.length, 1, "only the chunk with a successful header is sent");
// A rejected upload must not poison the transaction queue.
assert.ok(await client.getFireKey());
});

test("uploadMacro waits the vendor's 80 ms between each header and its chunk", async () => {
// Load-bearing, not cosmetic: nothing acknowledges a chunk, so one sent
// before the device is ready is dropped in silence and that slice of the
// buffer keeps what it held before — a short macro then replays the tail of
// the longer one it was meant to replace.
const { device } = fakeDevice();
const waits: number[] = [];
const client = new IncottHidClient(device, {
settleMs: 0,
attempts: 3,
sleep: async (ms: number) => { waits.push(ms); },
});
await client.uploadMacro({
bufferId: 0, loop: "cycle", cycles: 1, uid: 1,
steps: [{ key: 0x04, press: true, delayMs: 10 }],
});
assert.equal(
waits.filter((ms) => ms === INCOTT_MACRO_CHUNK_SETTLE_MS).length,
10,
"one 80 ms gap per chunk",
);
});

test("nothing can interleave between a chunk header and its chunk", async () => {
// The app polls the mouse in the background. A header and its chunk queued
// as two entries look ordered but are not: awaiting the header yields to
// the event loop, so a refresh queued meanwhile runs between them and the
// device gets a feature query where it is waiting for an output report.
// Nothing acknowledges a chunk, so that damage is silent — a macro whose
// steps arrive intact while its loop mode quietly does not.
const { device } = fakeDevice();
const timeline: string[] = [];
const record = {
...device,
sendFeatureReport: async (reportId: number, data: BufferSource) => {
const bytes = new Uint8Array(ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: new Uint8Array(data as ArrayBuffer));
timeline.push(bytes[0] === 0x07 ? "header" : "feature");
return device.sendFeatureReport(reportId, data);
},
sendReport: async (reportId: number, data: BufferSource) => {
timeline.push("chunk");
return device.sendReport!(reportId, data);
},
} as unknown as HIDDevice;

const client = new IncottHidClient(record, fast);
const upload = client.uploadMacro({
bufferId: 0, loop: "untilKeyRelease", cycles: 1, uid: 1,
steps: [{ key: 0x04, press: true, delayMs: 10 }],
});
// A background read, started while the upload is in flight.
const poll = client.getFireKey();
await Promise.all([upload, poll]);

timeline.forEach((entry, index) => {
if (entry === "header") {
assert.equal(timeline[index + 1], "chunk", `a ${timeline[index + 1]} cut in after a chunk header`);
}
});
assert.equal(timeline.filter((entry) => entry === "header").length, 10);
});

test("uploadMacro fails clearly on a transport with no output-report support", async () => {
const { device } = fakeDevice();
delete (device as unknown as { sendReport?: unknown }).sendReport;
Expand All @@ -1555,3 +1639,33 @@ test("readStatus keeps the full ceiling when identity cannot be read", async ()
const status = await new IncottHidClient(device, fast).readStatus();
assert.equal(status.ui?.dpiStageEditor?.maxDpi, 45000);
});

test("one Macro action is offered, not a slot per buffer", async () => {
// A button can only run the buffer with its own index, so a numbered list
// would offer combinations the device ignores.
const { device } = fakeDevice();
const status = await new IncottHidClient(device, fast).readStatus();
assert.ok(status.buttonOptions?.includes("Macro"));
assert.equal(status.buttonOptions?.includes("Macro 1"), false);
});

test("Macro binds a button to the buffer carrying its own display index", async () => {
const { device, state } = fakeDevice();
const client = new IncottHidClient(device, fast);
await client.setButtonMapping("Forward", "Macro");
// Forward is display index 3 and wire index 4: the binding names 3 and is
// written at 4. Hardware-confirmed — naming 0 there never fires.
assert.equal(state.buttons[4], 0x00030009);
const status = await client.readStatus();
assert.equal(status.buttonMappings?.Forward, "Macro");
});

test("a macro binding pointing at another button's buffer is reported, not shown as working", async () => {
// Forward is display index 3, so a binding naming buffer 0 was written by
// something with the model wrong and will not fire.
const { device } = fakeDevice({
state: { buttons: [0x00f00001, 0x00f10001, 0x00f20001, 0x00f30001, 0x00000009, 0x00030007] },
});
const status = await new IncottHidClient(device, fast).readStatus();
assert.equal(status.buttonMappings?.Forward, "Macro (buffer 0)");
});
82 changes: 70 additions & 12 deletions src/drivers/incott/hid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { MouseStatus, MouseUiHints } from "../mouse-types.ts";
import {
incottButtonActionCode,
incottMacroBindingForButton,
incottMacroSlotFromBinding,
incottDecodeButtonBinding,
incottDecodeDebounce,
incottDecodeDpiStage,
Expand Down Expand Up @@ -42,6 +44,7 @@ import {
incottPerformanceModeToWire,
incottValidateDpi,
INCOTT_BUTTON_ACTIONS,
INCOTT_MACRO_ACTION_LABEL,
INCOTT_BUTTON_NAMES,
INCOTT_BUTTON_WIRE_INDEX,
INCOTT_CMD_QUERY_BUTTON,
Expand All @@ -60,6 +63,7 @@ import {
INCOTT_DPI_STAGE_COUNT,
INCOTT_DPI_STEP,
INCOTT_INPUT_REPORT_ID,
INCOTT_MACRO_CHUNK_SETTLE_MS,
INCOTT_PERFORMANCE_MODE_NAMES,
INCOTT_POLLING_STEPS_HZ,
INCOTT_POLLING_STEPS_HZ_WIRED,
Expand Down Expand Up @@ -177,18 +181,29 @@ export class IncottTransactionQueue {
}

/**
* Sends a 32-byte OUTPUT report, the macro upload's data path. Queued with
* everything else so it cannot interleave with the 8-byte header that
* announces it, and followed by a settle delay because the vendor waits
* 80 ms between the two halves of each chunk.
* Sends one macro chunk: the 8-byte feature report announcing it, the gap
* the device needs to get ready, then the 32-byte OUTPUT report carrying
* it — as ONE queue entry.
*
* The two halves must be one entry, not two. Queueing them separately looks
* ordered and is not: `await`ing the header hands control back to the event
* loop, so anything queued meanwhile — a status refresh, a battery read,
* any of the app's background polling — runs between a header and the chunk
* it announced. The device is waiting for an output report at that point
* and gets a feature query instead. Nothing acknowledges a chunk, so the
* damage is silent.
*/
sendOutput(reportId: number, payload: Uint8Array): Promise<void> {
sendChunk(header: Uint8Array, reportId: number, chunk: Uint8Array, settleMs = this.settleMs): Promise<void> {
const run = this.tail.then(async () => {
if (!this.transport.sendReport) {
throw new Error("This transport cannot send output reports, which the macro upload needs.");
}
await this.transport.sendReport(reportId, toArrayBuffer(payload));
await this.sleep(this.settleMs);
await this.transport.sendFeatureReport(INCOTT_REPORT_ID, toArrayBuffer(header));
// The settle happens BETWEEN the header and the chunk. The vendor waits
// there — not after — which reads as the device needing that gap to get
// ready for the output report.
await this.sleep(settleMs);
await this.transport.sendReport(reportId, toArrayBuffer(chunk));
});
this.tail = run.catch(() => undefined);
return run;
Expand Down Expand Up @@ -907,7 +922,19 @@ export class IncottHidClient {
// when it has the current assignments AND the list of actions it may
// write back.
...(buttonMappings !== null
? { buttonMappings, buttonOptions: INCOTT_BUTTON_ACTIONS.map(([label]) => label) }
? {
buttonMappings,
// One "Macro" entry, not ten numbered slots: a button can only
// run the buffer carrying its own index, so offering a choice of
// slot would list combinations the device ignores. Nothing can
// ask which buffers hold a macro, so the option is always
// present; choosing it on a button with no macro uploaded is
// accepted and does nothing, as in the vendor tool.
buttonOptions: [
...INCOTT_BUTTON_ACTIONS.map(([label]) => label),
INCOTT_MACRO_ACTION_LABEL,
],
}
: {}),
motionSync,
rippleControl,
Expand Down Expand Up @@ -977,13 +1004,29 @@ export class IncottHidClient {
* The device does not acknowledge any of it, so unlike every other setter
* here there is nothing to verify against: there is no macro read command.
* Bind a button to `Macro <n>` and press it — that is the only confirmation
* available.
* available. That makes both halves of `sendChunk` load-bearing: a chunk
* sent too early, or after something else has been allowed in between, is
* dropped in silence and the buffer keeps whatever that region held before
* — so a short macro can replay the tail of a longer one it replaced, or a
* setting can survive the upload meant to change it.
*
* Re-bind the button AFTER every upload, even when it is already on
* `Macro`: `setButtonMapping(button, "Macro")`. HARDWARE-CONFIRMED
* 2026-11-06: the loop mode and repeat count take effect when the binding is
* written, not when the buffer is. Uploading alone replaced the steps but
* kept the old loop mode, so a macro set to repeat ran only while held. The
* vendor writes the binding after the buffer on every save, which is why
* its own captures never show the problem.
*/
async uploadMacro(macro: IncottMacro): Promise<void> {
const chunks = incottMacroChunks(incottEncodeMacroBuffer(macro));
for (const [index, chunk] of chunks.entries()) {
await this.write(incottEncodeMacroChunkHeader(index, macro.bufferId));
await this.queue.sendOutput(INCOTT_REPORT_ID, chunk);
await this.queue.sendChunk(
incottEncodeMacroChunkHeader(index, macro.bufferId),
INCOTT_REPORT_ID,
chunk,
INCOTT_MACRO_CHUNK_SETTLE_MS,
);
}
}

Expand Down Expand Up @@ -1161,6 +1204,17 @@ export class IncottHidClient {
const index = INCOTT_BUTTON_WIRE_INDEX[name];
const binding = incottDecodeButtonBinding(await this.query(INCOTT_CMD_QUERY_BUTTON, index), index);
if (binding === null) return null;
// A macro binding is only meaningful against the button it was read
// from: the slot it names should be that button's own index. One
// pointing elsewhere was written by something that had the model wrong,
// so report it rather than showing it as a working macro.
const macroSlot = incottMacroSlotFromBinding(binding.code);
if (macroSlot !== null) {
mappings[name] = macroSlot === INCOTT_BUTTON_NAMES.indexOf(name)
? INCOTT_MACRO_ACTION_LABEL
: `Macro (buffer ${macroSlot})`;
continue;
}
mappings[name] = binding.label ?? `Unknown (0x${binding.code.toString(16).padStart(8, "0")})`;
}
return mappings;
Expand All @@ -1178,7 +1232,11 @@ export class IncottHidClient {
async setButtonMapping(button: string, actionLabel: string): Promise<void> {
const name = INCOTT_BUTTON_NAMES.find((candidate) => candidate === button);
if (name === undefined) throw new Error(`This mouse has no "${button}" button.`);
const code = incottButtonActionCode(actionLabel);
// "Macro" resolves per button: the buffer a button runs is its own
// display index, so the binding cannot be looked up from the label alone.
const code = actionLabel === INCOTT_MACRO_ACTION_LABEL
? incottMacroBindingForButton(name)
: incottButtonActionCode(actionLabel);
if (code === null) throw new Error(`Unknown button action "${actionLabel}".`);

const index = INCOTT_BUTTON_WIRE_INDEX[name];
Expand Down
Loading
Loading