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
446 changes: 446 additions & 0 deletions docs/mchose-protocol.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,10 @@
"types": "./dist/lamzu/index.d.ts",
"import": "./dist/lamzu/index.js"
},
"./mchose": {
"types": "./dist/mchose/index.d.ts",
"import": "./dist/mchose/index.js"
},
"./logitech": {
"types": "./dist/logitech/index.d.ts",
"import": "./dist/logitech/index.js"
Expand Down
127 changes: 127 additions & 0 deletions src/drivers/mchose/dock-hid.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { MchoseDockHidClient } from "./dock-hid.ts";
import { MchoseHidClient } from "./hid.ts";
import { MCHOSE_DOCK_COMMAND, MCHOSE_DOCK_EFFECT } from "@openmouse/protocol/mchose";

/** The lighting block a real MagDock returned: cycling, brightness 2, red. */
const LIGHTING = [0x01, 0x03, 0x06, 0x02, 0x02, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00];

function fakeDock(overrides?: Partial<HIDDevice>) {
const state = { params: [...LIGHTING] };
const listeners: Array<(event: unknown) => void> = [];

const reply = (command: number): Uint8Array => {
const buf = new Uint8Array(63);
buf.set([0xaa, command, 0x02, 0x00, 0x00, 0x1e], 0);
buf.set(state.params, 6);
return buf;
};

const device = {
vendorId: 0x3837,
productId: 0x1012,
productName: "MCHOSE MagDock",
opened: true,
collections: [
{ usagePage: 0xff00, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 },
],
open: async () => {},
close: async () => {},
addEventListener: (_type: string, fn: (event: unknown) => void) => { listeners.push(fn); },
removeEventListener: (_type: string, fn: (event: unknown) => void) => {
const at = listeners.indexOf(fn);
if (at >= 0) listeners.splice(at, 1);
},
sendReport: async (_id: number, data: ArrayBuffer | ArrayLike<number>) => {
const frame = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
const command = frame[1]!;
if (command === MCHOSE_DOCK_COMMAND.writeLighting) {
// The dock takes the whole block at once.
state.params = [...frame.subarray(6, 6 + 10), state.params[10]!];
}
// Answer on the next tick, as the real device does.
const answer = reply(command === MCHOSE_DOCK_COMMAND.writeLighting
? MCHOSE_DOCK_COMMAND.writeLighting
: command);
setTimeout(() => {
for (const fn of [...listeners]) fn({ data: new DataView(answer.buffer), reportId: 0 });
}, 0);
},
...overrides,
} as unknown as HIDDevice;

return { device, state };
}

describe("MchoseDockHidClient", () => {
it("claims the MagDock on its own usage page", () => {
assert.equal(MchoseDockHidClient.isSupported(fakeDock().device), true);
});

it("does not claim the mouse, and the mouse driver does not claim it", () => {
const dock = fakeDock().device;
assert.equal(MchoseHidClient.isSupported(dock), false, "mouse driver rejects the dock");

const mouse = fakeDock({
productId: 0x100b,
collections: [
{ usagePage: 0xff01, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 },
],
} as unknown as Partial<HIDDevice>).device;
assert.equal(MchoseDockHidClient.isSupported(mouse), false, "dock driver rejects the mouse");
});

it("rejects another vendor's device on the same usage page", () => {
const { device } = fakeDock({ vendorId: 0x046d } as Partial<HIDDevice>);
assert.equal(MchoseDockHidClient.isSupported(device), false);
});

it("reads the base lighting and shapes it as a non-mouse status", async () => {
const status = await new MchoseDockHidClient(fakeDock().device).readStatus();
assert.equal(status.brand, "MCHOSE");
assert.equal(status.name, "MCHOSE MagDock");
assert.equal(status.ui?.settingsReady, false, "not a mouse: no settings grid");
assert.equal(status.lighting?.zone, "Base");
assert.equal(status.lighting?.mode, "Cycling");
assert.equal(status.lighting?.color, "#ff0000");
assert.equal(status.lighting?.brightness, 2);
assert.ok(status.lighting?.modes.includes("Off"));
});

it("setLighting writes the whole block, carrying over what is unchanged", async () => {
const { device, state } = fakeDock();
const client = new MchoseDockHidClient(device);
const status = await client.readStatus();
await client.setLighting({ ...status.lighting!, mode: "Static", color: "#00ff00" });

assert.equal(state.params[0], 1, "still on");
assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.static);
assert.deepEqual(state.params.slice(6, 9), [0x00, 0xff, 0x00], "green");
assert.equal(state.params[3], 2, "speed carried over");
assert.equal(state.params[2], 6, "effect count carried over");
});

it("Off switches the base off without losing the effect", async () => {
const { device, state } = fakeDock();
const client = new MchoseDockHidClient(device);
const status = await client.readStatus();
await client.setLighting({ ...status.lighting!, mode: "Off" });
assert.equal(state.params[0], 0, "disabled");
assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.cycling, "effect remembered");
assert.equal((await client.readStatus()).lighting?.mode, "Off");
});

it("selecting Reactive turns on the music sync flag", async () => {
const { device, state } = fakeDock();
const client = new MchoseDockHidClient(device);
const status = await client.readStatus();
await client.setLighting({ ...status.lighting!, mode: "Reactive" });
assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.music);
assert.equal(state.params[5], 1, "music sync set");
});

it("getDpiOptions is callable, as the app calls it for every client", () => {
assert.deepEqual(new MchoseDockHidClient(fakeDock().device).getDpiOptions(), []);
});
});
208 changes: 208 additions & 0 deletions src/drivers/mchose/dock-hid.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
import {
MCHOSE_DOCK_COMMAND,
MCHOSE_DOCK_COLOR_MODES,
MCHOSE_DOCK_LEVELS,
MCHOSE_DOCK_MODE_LABELS,
MCHOSE_DOCK_PRODUCT_ID,
MCHOSE_DOCK_REPORT_ID,
MCHOSE_DOCK_USAGE,
MCHOSE_DOCK_USAGE_PAGE,
mchoseDockColorFromHex,
mchoseDockColorToHex,
mchoseDockDecodeLighting,
mchoseDockEffectFor,
mchoseDockEncode,
mchoseDockEncodeLighting,
mchoseDockModeLabel,
mchoseDockPayload,
type MchoseDockLighting,
} from "@openmouse/protocol/mchose";
import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts";
import { VENDOR_ID } from "../vendors.ts";

/**
* MCHOSE MagDock — the charging base, which is where this family's RGB lives.
* The A7 V2 mice have no controllable LEDs of their own.
*
* It is not a mouse, so it follows the non-mouse pattern: a `MouseStatus` with
* `ui.settingsReady: false` so the settings grid stays hidden, carrying only
* the lighting the device actually has.
*/

const REPLY_TIMEOUT_MS = 700;
const WRITE_SETTLE_MS = 400;
const READ_ATTEMPTS = 6;

const delay = (ms: number): Promise<void> => new Promise((resolve) => { setTimeout(resolve, ms); });

export class MchoseDockHidClient {
readonly device: HIDDevice;

private queue: Promise<unknown> = Promise.resolve();
private lastKnown: MchoseDockLighting | null = null;

constructor(device: HIDDevice) {
this.device = device;
}

static isSupported(device: HIDDevice): boolean {
const search = (collection: HIDCollectionInfo): boolean =>
(collection.usagePage === MCHOSE_DOCK_USAGE_PAGE
&& collection.usage === MCHOSE_DOCK_USAGE)
|| collection.children.some(search);
return device.vendorId === VENDOR_ID.mchose
&& device.productId === MCHOSE_DOCK_PRODUCT_ID
&& device.collections.some(search);
}

async open(): Promise<void> {
if (!this.device.opened) await this.device.open();
}

async close(): Promise<void> {
this.lastKnown = null;
if (this.device.opened) await this.device.close();
}

/** The dock pushes no unsolicited state the panel can use. */
async startNotifications(): Promise<boolean> {
return false;
}

displayName(): string {
return this.device.productName?.trim() || "MCHOSE MagDock";
}

/** Called for every connected client, dock or not. */
getDpiOptions(): number[] {
return [];
}

/** Send a request and wait for the matching reply on the input report. */
private request(command: number, params: readonly number[] = []): Promise<Uint8Array | null> {
const run = async (): Promise<Uint8Array | null> => {
const frame = mchoseDockEncode(command, params);
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
const reply = await new Promise<Uint8Array | null>((resolve) => {
const timer = setTimeout(() => {
this.device.removeEventListener("inputreport", listener);
resolve(null);
}, REPLY_TIMEOUT_MS);
const listener = (event: Event): void => {
const report = event as HIDInputReportEvent;
const payload = mchoseDockPayload(new Uint8Array(report.data.buffer), command);
if (!payload) return;
clearTimeout(timer);
this.device.removeEventListener("inputreport", listener);
resolve(payload);
};
this.device.addEventListener("inputreport", listener);
this.device.sendReport(MCHOSE_DOCK_REPORT_ID, frame).catch(() => {
clearTimeout(timer);
this.device.removeEventListener("inputreport", listener);
resolve(null);
});
});
if (reply) return reply;
}
return null;
};
const next = this.queue.then(run, run);
this.queue = next.catch(() => undefined);
return next;
}

private async readLighting(): Promise<MchoseDockLighting | null> {
await this.open();
const payload = await this.request(MCHOSE_DOCK_COMMAND.readLighting);
const decoded = payload ? mchoseDockDecodeLighting(payload) : null;
if (decoded) this.lastKnown = decoded;
return decoded;
}

async readStatus(): Promise<MouseStatus> {
const lighting = await this.readLighting();

return {
brand: "MCHOSE",
name: this.displayName(),
batteryPercent: null,
batteryState: "Unknown",
dpi: 0,
pollingRateHz: 0,
activeProfile: null,
liftOffDistance: null,
connectionType: "Wired",
firmware: [],
lighting: lighting ? this.toMouseLighting(lighting) : undefined,
ui: {
family: "mchose-dock",
// Not a mouse: the settings grid would render nothing but blanks.
settingsReady: false,
defaultDisplayName: "MCHOSE MagDock",
statusNote: lighting
? "Charging base — lighting only. The A7 V2 mice have no LEDs of their own."
: "Charging base — the lighting state could not be read.",
},
};
}

private toMouseLighting(state: MchoseDockLighting): MouseLighting {
const modes = MCHOSE_DOCK_MODE_LABELS.map(([, label]) => label as MouseLightingMode);
return {
zone: "Base",
modes: ["Off", ...modes],
mode: state.enabled ? (mchoseDockModeLabel(state.effect) as MouseLightingMode) : "Off",
color: mchoseDockColorToHex(state.color),
color2: null,
colorModes: MCHOSE_DOCK_COLOR_MODES as MouseLightingMode[],
dualColorModes: [],
reactiveModes: [],
speeds: [...MCHOSE_DOCK_LEVELS],
speed: state.speed,
brightness: state.brightness,
brightnessLevels: [...MCHOSE_DOCK_LEVELS],
};
}

/**
* The dock takes its whole lighting block in one write, so anything the panel
* does not specify is carried over from the last read rather than zeroed.
*/
async setLighting(next: MouseLighting): Promise<void> {
const current = this.lastKnown ?? await this.readLighting();
if (!current) throw new Error("The dock did not report its lighting state.");

const off = next.mode === "Off";
const effect = off ? current.effect : mchoseDockEffectFor(next.mode ?? "") ?? current.effect;
const color = next.color ? mchoseDockColorFromHex(next.color) : null;

const state: MchoseDockLighting = {
enabled: !off,
effect,
effectCount: current.effectCount,
speed: next.speed ?? current.speed,
brightness: next.brightness ?? current.brightness,
// The music effect is what the sync flag is for.
musicSync: effect === mchoseDockEffectFor("Reactive"),
color: color ?? current.color,
direction: current.direction,
};

await this.open();
const frame = mchoseDockEncodeLighting(state);
const send = async (): Promise<void> => {
await this.device.sendReport(MCHOSE_DOCK_REPORT_ID, frame);
await delay(WRITE_SETTLE_MS);
};
const queued = this.queue.then(send, send);
this.queue = queued.catch(() => undefined);
await queued;

const after = await this.readLighting();
if (!after) throw new Error("The dock did not confirm the lighting change.");
if (after.enabled !== state.enabled || (state.enabled && after.effect !== state.effect)) {
throw new Error("The dock did not accept the lighting change.");
}
}
}
Loading