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
6 changes: 6 additions & 0 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import { join } from "node:path";
// L7 Pro+, Paro Aurora and Corsair Scimitar RGB Elite, about 70 kB of JS
// (3,743,762 -> 3,813,797 bytes measured); main is 1,384,701 bytes, below its cap.
//
// 2026-11-06: Incott macro card (library, recorder, step editor, button
// assignment) and its strings in every locale add about 33 kB of JS, measured
// against the protocol build it needs: 3,892,361 -> 3,925,511 bytes (the rest
// of that build's growth is protocol drivers released since 0.52.0). Main is
// 1,431,450 bytes, below its cap; CSS is 169,383 bytes, below its cap.
//
// 2026-11-07: @openmouse/protocol 0.52.0 -> 0.58.0 (six releases of new and
// extended drivers) adds about 77.5 kB of JS (3,816,912 -> 3,894,416 bytes
// measured in the Update Mouse Protocol workflow); main grew 27.7 kB to
Expand Down
54 changes: 53 additions & 1 deletion src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
type SupportedClient,
} from "./device-clients";
import { closestDpiOption } from "./dpi-presets";
import { forgetMouseReceiver, rememberMouseReceiver } from "./mouse-receivers";
import { forgetMouseReceiver, receiverKey, rememberMouseReceiver } from "./mouse-receivers";
import { rememberDeviceName, rememberedDeviceName } from "./device-names";
import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts";
import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts";
Expand Down Expand Up @@ -143,6 +143,7 @@ import type { WLMouseHidClient } from "@openmouse/protocol/drivers/wlmouse/hid";
import type { WLMouseBeastX4kHidClient } from "@openmouse/protocol/drivers/wlmouse/beast-x-4k-hid";
import type { DareuHidClient } from "@openmouse/protocol/drivers/dareu/hid";
import type { IncottHidClient } from "@openmouse/protocol/drivers/incott/hid";
import type { IncottMacro } from "@openmouse/protocol/incott";
import { BASILISK_V3_PRO_BLUETOOTH, HID_FILTERS } from "./hid-filters";
import { VENDOR_ID } from "@openmouse/protocol/drivers/vendors";
import { RAZER_PRODUCTS } from "@openmouse/protocol/razer-devices";
Expand Down Expand Up @@ -6792,6 +6793,10 @@ export function applyPowerMode(mode: string): void {
* above, which reassigns a Logitech control by id.
*/
export function applyDeviceButtonMapping(button: string, action: string): void {
// A staged Incott macro upload ends by binding the button to Macro. A later
// remap to anything else supersedes it, and must do so even when stageChange
// drops the remap as "already matches", or Apply would still bind Macro.
if (action !== "Macro") dropPendingChange(`incott-macro-${button}`);
stageChange({
key: `button-${button}`,
label: `${button}: ${action}`,
Expand Down Expand Up @@ -7203,6 +7208,53 @@ export function applyFinalmouseProfileName(index: number, name: string): void {
});
}

/**
* Uploads one Incott macro into the buffer belonging to its button.
*
* The buffer is not a free choice: a button runs the buffer carrying its own
* index and no other, so the caller passes the button and `uploadMacro` is
* given the matching `bufferId` (see `incottMacroSlotForButton`).
*
* The device accepts a macro buffer and offers no command to return one, so
* this stages and applies like the rest but carries no preview. A macro is not
* part of MouseStatus, and matchesDeviceStatus treats a preview that changes
* nothing as "already matches" and drops the change, which silently discarded
* every upload before it reached the mouse. Without a preview it also cannot
* join a game profile draft, which is right: a profile cannot restore a macro.
*
* The button is re-bound after every upload, even when it already runs its
* macro. HARDWARE-CONFIRMED 2026-11-06: the loop mode takes effect when the
* binding is written, and the separate binding change is dropped as "already
* matches" once a button is on Macro, so without this new steps played under
* the OLD loop mode. The vendor writes the binding after every upload too
* (`captures/incott-8k-wireless/macro-loop-modes-2026-09-14.hex`).
*/
export function applyIncottMacro(button: string, macro: IncottMacro, onApplied?: () => void): void {
stageChange({
key: `incott-macro-${button}`,
label: `${button} macro: ${macro.steps.length} steps`,
command: `Save the ${button} button's macro to the mouse`,
progress: `Saving the ${button} macro…`,
apply: async () => {
const client = incottClient();
if (!client) throw new Error(st("ctl.gone"));
await client.uploadMacro(macro);
await client.setButtonMapping(button, "Macro");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// The caller records "this button runs this macro" only once the mouse
// has it, so a discarded or failed change leaves nothing stale behind.
onApplied?.();
},
});
}

/**
* The key a saved macro assignment is filed under: the connected mouse, so one
* Incott mouse's assignments are never read as another's. Null with no device.
*/
export function incottMacroDeviceKey(): string | null {
return activeDevice ? receiverKey(activeDevice) : null;
}

/**
* Incott's 2.4 GHz receiver LED (0 connect & polling rate, 1 battery status,
* 2 battery warning). Wireless only — the driver omits the field entirely
Expand Down
175 changes: 175 additions & 0 deletions src/device/incott-macros.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import test from "node:test";

import { INCOTT_MACRO_MAX_STEPS, INCOTT_MACRO_MOUSE_KEYS, incottMacroKeyLabel } from "@openmouse/protocol/incott";

import {
INCOTT_MACRO_KEY_CHOICES,
appendRecordedStep,
closeHeldKeys,
emptyMacro,
incottMacroAssignment,
incottMacroKeyForCode,
incottMacroKeyForMouseButton,
macroDurationMs,
parseStore,
stepsForDevice,
unreleasedKeys,
type MacroStore,
} from "./incott-macros.ts";

test("macro duration is the sum of its delays", () => {
assert.equal(macroDurationMs([]), 0);
assert.equal(macroDurationMs([
{ key: 0xf0, press: true, delayMs: 40 },
{ key: 0xf0, press: false, delayMs: 1200 },
]), 1240);
});

test("a fixed delay replaces the recorded timing without destroying it", () => {
// The mode is stored beside the steps, not written into them, so switching
// to a fixed gap and back returns the recorded timing intact.
const macro = {
...emptyMacro("click"),
delay: { mode: "fixed" as const, fixedMs: 25 },
steps: [
{ key: 0xf0, press: true, delayMs: 84 },
{ key: 0xf0, press: false, delayMs: 252 },
],
};
assert.deepEqual(stepsForDevice(macro).map((step) => step.delayMs), [25, 25]);
assert.deepEqual(macro.steps.map((step) => step.delayMs), [84, 252], "the recording is untouched");
assert.deepEqual(
stepsForDevice({ ...macro, delay: { mode: "recorded", fixedMs: 25 } }).map((step) => step.delayMs),
[84, 252],
);
});

test("no delay sends zeros, keeping the keys and their order", () => {
const macro = {
...emptyMacro("burst"),
delay: { mode: "none" as const, fixedMs: 60 },
steps: [
{ key: 0xf0, press: true, delayMs: 84 },
{ key: 0xf0, press: false, delayMs: 1 },
],
};
const sent = stepsForDevice(macro);
assert.deepEqual(sent.map((step) => step.delayMs), [0, 0]);
assert.deepEqual(
sent.map((step) => [step.key, step.press]),
macro.steps.map((step) => [step.key, step.press]),
);
});

test("a new macro starts empty, playing once, with its recorded timing", () => {
const macro = emptyMacro("Macro 1");
assert.deepEqual(macro.steps, []);
assert.equal(macro.loop, "cycle");
assert.equal(macro.cycles, 1);
assert.equal(macro.delay.mode, "recorded");
});

test("a recorded gap closes the previous step, not the new one", () => {
let steps = appendRecordedStep([], 0x04, true, 9999);
assert.deepEqual(steps, [{ key: 0x04, press: true, delayMs: 0 }], "the first event has nothing before it to close");
steps = appendRecordedStep(steps, 0x04, false, 85);
assert.deepEqual(steps, [
{ key: 0x04, press: true, delayMs: 85 },
{ key: 0x04, press: false, delayMs: 0 },
]);
});

test("recording stops growing at the buffer's step limit and clamps long gaps", () => {
let steps = appendRecordedStep([], 0x04, true, 0);
for (let index = 1; index < INCOTT_MACRO_MAX_STEPS + 5; index += 1) {
steps = appendRecordedStep(steps, 0x04, index % 2 === 0, 70000);
}
assert.ok(steps.length <= INCOTT_MACRO_MAX_STEPS);
assert.equal(steps[0]!.delayMs, 0xffff, "an LE16 field cannot hold more");
});

test("at the step limit a held key still gets its release", () => {
let steps = appendRecordedStep([], 0x04, true, 0);
// Fill with taps of another key until the next press would not leave room.
for (let index = 0; index < INCOTT_MACRO_MAX_STEPS; index += 1) {
steps = appendRecordedStep(steps, 0x05, true, 1);
steps = appendRecordedStep(steps, 0x05, false, 1);
}
assert.ok(steps.length < INCOTT_MACRO_MAX_STEPS, "room is held back for the key still down");
steps = appendRecordedStep(steps, 0x04, false, 1);
assert.equal(steps.at(-1)!.key, 0x04);
assert.equal(steps.at(-1)!.press, false);
assert.ok(steps.length <= INCOTT_MACRO_MAX_STEPS);
});

test("the store survives garbage and drops assignments to missing macros", () => {
assert.deepEqual(parseStore(null), { macros: [], assignments: {} });
assert.deepEqual(parseStore("{not json"), { macros: [], assignments: {} });
assert.deepEqual(parseStore("42"), { macros: [], assignments: {} });

const store = parseStore(JSON.stringify({
macros: [
{ id: "a", name: "Tap", loop: "bogus", cycles: -3, steps: [{ key: 4, press: true, delayMs: 90000 }] },
{ id: "b", name: "Empty", steps: [] },
],
devices: { "mouse-1": { Forward: "a", Back: "b", Nonsense: "a" }, "mouse-2": { Left: "a" } },
}), "mouse-1");
assert.equal(store.macros.length, 1, "a macro with no steps is not kept");
assert.equal(store.macros[0]!.loop, "cycle");
assert.equal(store.macros[0]!.cycles, 1);
assert.equal(store.macros[0]!.steps[0]!.delayMs, 0xffff);
assert.deepEqual(store.assignments, { Forward: "a" });
});

test("assignments belong to one mouse, so another mouse's Macro button is not this one's", () => {
const raw = JSON.stringify({
macros: [{ id: "a", name: "Tap", steps: [{ key: 4, press: true, delayMs: 0 }] }],
devices: { "mouse-1": { Back: "a" } },
});
assert.deepEqual(parseStore(raw, "mouse-1").assignments, { Back: "a" });
assert.deepEqual(parseStore(raw, "mouse-2").assignments, {});
assert.deepEqual(parseStore(raw, null).assignments, {}, "no device, no assignments");
assert.equal(parseStore(raw, "mouse-1").macros.length, 1, "the library is shared");
});

test("a key still down when recording stops is released", () => {
const steps = appendRecordedStep([], 0x04, true, 0);
assert.deepEqual(unreleasedKeys(steps), [0x04]);
const closed = closeHeldKeys(steps, 120);
assert.deepEqual(closed, [
{ key: 0x04, press: true, delayMs: 120 },
{ key: 0x04, press: false, delayMs: 0 },
]);
assert.deepEqual(unreleasedKeys(closed), []);
});

test("an assignment only counts while the mouse reports the button running a macro", () => {
const store: MacroStore = { macros: [{ ...emptyMacro("Tap"), id: "a" }], assignments: { Forward: "a" } };
assert.equal(incottMacroAssignment(store, "Forward", undefined), "a", "no mapping read: the store is all there is");
assert.equal(incottMacroAssignment(store, "Forward", { Forward: "Macro" }), "a");
assert.equal(
incottMacroAssignment(store, "Forward", { Forward: "Forward" }),
null,
"remapped elsewhere: the button no longer runs it",
);
assert.equal(incottMacroAssignment(store, "Back", { Back: "Macro" }), "elsewhere");
assert.equal(
incottMacroAssignment(store, "Back", { Back: "Macro (buffer 2)" }),
null,
"a binding to another button's buffer never fires, so it is not shown as a macro",
);
});

test("the recorder maps browser input onto keys the editor can name", () => {
assert.equal(incottMacroKeyForCode("KeyA"), 0x04);
assert.equal(incottMacroKeyForCode("Digit0"), 0x27);
assert.equal(incottMacroKeyForCode("ShiftLeft"), 0xe1);
assert.equal(incottMacroKeyForCode("NumpadAdd"), null);
assert.equal(incottMacroKeyForMouseButton(0), INCOTT_MACRO_MOUSE_KEYS.left);
assert.equal(incottMacroKeyForMouseButton(2), INCOTT_MACRO_MOUSE_KEYS.right);
assert.equal(incottMacroKeyForMouseButton(3), null);
for (const key of [...INCOTT_MACRO_KEY_CHOICES.mouse, ...INCOTT_MACRO_KEY_CHOICES.keyboard]) {
assert.notEqual(incottMacroKeyLabel(key), null, `key 0x${key.toString(16)} has a name`);
}
});
Loading
Loading