From 4f2f3fcc19e268683c21a11573bb29b7dc0d6184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sun, 6 Sep 2026 01:02:13 +0200 Subject: [PATCH 1/3] Add MCHOSE A7 V2 and MagDock support Wires up the MCHOSE drivers from @openmouse/protocol and adds the small, brand-agnostic controls they need. Verified against an A7 V2 Ultra+ over both its 2.4 GHz receiver and its cable. Device wiring: - both clients imported and added to NEEDS_OPEN - mchose traits entry so the debounce and sleep cards appear - A7 V2 rows and the MagDock in supported-mice, with their PIDs pinned - device-image mappings for the family; the art itself still needs a bucket upload, so these resolve to the placeholder until then Rather than add a second set of brand-specific cards, four shared controls are introduced, each driven purely by what a driver reports: - a numbered onboard-profile selector (profileCount / activeProfile / setProfile), showing device-stored names where they exist - a button remapper (buttonMappings / buttonOptions / setButtonMapping) - a named power-mode selector plus angle tuning (powerModes / setPowerMode, setAngleTuning) - sleep options and a debounce ceiling are now read from any client that publishes them, not just direct-mode drivers Existing brand cards are untouched; every new control hides itself when a driver does not populate its fields. sleepLabel renders 0 as "Never" for firmwares that treat zero as no auto-sleep. Depends on the MCHOSE driver landing in @openmouse/protocol first, since this app installs that package from its git URL. Co-Authored-By: Claude Opus 5 --- public/devices/README.md | 8 +++ src/app/App.tsx | 9 +++ src/app/cards/AdvancedCards.tsx | 113 +++++++++++++++++++++++++++++++- src/app/cards/availability.ts | 9 +++ src/device/controller.ts | 102 +++++++++++++++++++++++++++- src/device/options.ts | 2 + src/device/traits.ts | 3 + src/supported-mice.test.ts | 6 ++ src/supported-mice.ts | 12 ++++ src/ui/device-images.ts | 14 ++++ 10 files changed, 274 insertions(+), 4 deletions(-) diff --git a/public/devices/README.md b/public/devices/README.md index 1d8326a..ce80cda 100644 --- a/public/devices/README.md +++ b/public/devices/README.md @@ -165,3 +165,11 @@ package: - `lamzu-maya-x.png` — Lamzu Maya X render - `atk-f1-v2-ultra-max.png` — ATK F1 V2 Ultra Max render - `finalmouse-ulx.png` — Finalmouse Starlight-12 / ULX low-profile shape render +- `mchose-a7-v2.png` — MCHOSE A7 V2 render, from MCHOSE's own M HUB configurator + (`https://cdn.mchose.com.cn/configCenter/assets/img/mouse/A7V2Pro_white.png`). + MCHOSE only publishes `A7V2Pro_*` renders and the Pro / Pro+ / Ultra / Ultra+ + are one shell, so this single image covers the whole A7 V2 family. **Needs a + maintainer upload** — the mapping in `src/ui/device-images.ts` is already in + place and falls back to the placeholder until then. Not yet cleared for + licensing: it is vendor product art, so treat it as a request rather than an + approved asset. diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a4b7c6..a5860be 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -31,6 +31,9 @@ import { NinjutsoSensorCard, ProcessingCard, RazerButtonCard, + ButtonMappingCard, + PowerModeCard, + OnboardProfileCard, PulsarProCard, SignalCard, SleepCard, @@ -144,6 +147,12 @@ function Workspace({ show(has.razerButtons, ["buttons"]) ? : null, show(has.mxMasterButtons, ["buttons"]) ? : null, + show(has.powerMode, ["performance"]) + ? : null, + show(has.buttonMapping, ["buttons"]) + ? : null, + show(has.onboardProfiles, ["profiles"]) + ? : null, show(has.pulsarPro, ["profiles"]) ? : null, ].filter((node) => node !== null); diff --git a/src/app/cards/AdvancedCards.tsx b/src/app/cards/AdvancedCards.tsx index 13862fd..0828c5b 100644 --- a/src/app/cards/AdvancedCards.tsx +++ b/src/app/cards/AdvancedCards.tsx @@ -154,7 +154,9 @@ export function SleepCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNod const keychronSleep = status.ui?.family === "keychron-nape"; let options: ReadonlyArray = PULSAR_SLEEP_OPTIONS; - if (!keychronSleep && traits.directMode) { + // A driver that publishes its own timeouts wins over the Pulsar-unit default, + // whether or not it is a direct-mode driver. + if (!keychronSleep && (traits.directMode || capabilities?.sleepOptions)) { const offered = capabilities?.sleepOptions ?? [10, 30, 60, 300, 600, 1800]; const seconds = selectableValues(offered, status.sleepTimeout) ?? offered; options = seconds.map((value) => [value, sleepLabel(value)] as const); @@ -921,6 +923,115 @@ export function EggButtonCard({ snapshot }: { snapshot: ControlSnapshot }): Reac ); } +/** + * Numbered onboard profiles, for devices that expose a plain set the user can + * switch between. Driven entirely by `profileCount` / `activeProfile`, so it + * stays brand-agnostic — unlike the Logitech onboard-profile editor, which + * edits profile *contents* rather than just selecting one. + */ +export function OnboardProfileCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status; + if (!status || !status.profileCount || status.activeProfile == null) return null; + return ( +
+

Profile

+ +

+ Each profile stores its own DPI stages and polling rate, so those values + change with the profile. +

+
+ ); +} + +/** + * Button remapping for drivers that publish a plain name -> action map. Stays + * brand-agnostic: the driver supplies both the button list and the vocabulary, + * so nothing here knows what a given mouse can do. + */ +export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status; + if (!status?.buttonMappings || !status.buttonOptions?.length) return null; + const options = status.buttonOptions; + return ( +
+

BUTTONS

Remap

+ {Object.entries(status.buttonMappings).map(([button, assigned]) => ( + + ))} +

+ “Default” restores a button’s factory function. +

+
+ ); +} + +/** + * A device's named power/performance modes, plus sensor angle tuning where it + * offers one. Driven entirely by what the driver reports, so it stays + * brand-agnostic. + */ +export function PowerModeCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status; + if (!status) return null; + const modes = status.powerModes; + const tuning = status.angleTuning; + if (!modes?.length && tuning == null) return null; + return ( +
+

SENSOR

Mode

+ {modes?.length ? ( + ({ value: mode, label: mode }))} + value={status.powerMode ?? modes[0]!} + onChange={(next) => control.applyPowerMode(String(next))} + /> + ) : null} + {tuning != null ? ( + + ) : null} +
+ ); +} + export function PulsarProCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts index 1ad7d98..66b92b1 100644 --- a/src/app/cards/availability.ts +++ b/src/app/cards/availability.ts @@ -27,6 +27,9 @@ export interface CardAvailability { razerButtons: boolean; mxMasterButtons: boolean; pulsarPro: boolean; + onboardProfiles: boolean; + buttonMapping: boolean; + powerMode: boolean; profiles: boolean; keychronNapeLayers: boolean; logitechDetails: boolean; @@ -58,6 +61,9 @@ const NOTHING: CardAvailability = { razerButtons: false, mxMasterButtons: false, pulsarPro: false, + onboardProfiles: false, + buttonMapping: false, + powerMode: false, profiles: false, keychronNapeLayers: false, logitechDetails: false, @@ -101,6 +107,9 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability { superstrike: traits.logitech && status.analogButtonTuning?.buttons.length === 2, lighting: Boolean(status.lighting || status.lightingZones?.length), lightingAdvanced: host && Boolean(status.lighting || status.lightingZones?.length), + onboardProfiles: (status.profileCount ?? 0) > 1 && status.activeProfile != null, + buttonMapping: host && Boolean(status.buttonMappings) && Boolean(status.buttonOptions?.length), + powerMode: host && (Boolean(status.powerModes?.length) || status.angleTuning != null), profiles: traits.logitech && status.deviceMode !== undefined && status.deviceMode !== "Unknown", keychronNapeLayers: status.napeLayerCount != null && status.napeLayerCount >= 1, diff --git a/src/device/controller.ts b/src/device/controller.ts index 908202b..e8ae0ec 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -118,6 +118,8 @@ import { KeychronM6HidClient } from "@openmouse/protocol/drivers/keychron/m6-hid import type { GloriousLighting } from "@openmouse/protocol/glorious"; import { GloriousHidClient } from "@openmouse/protocol/drivers/glorious/hid"; import { GloriousClassicHidClient } from "@openmouse/protocol/drivers/glorious/classic-hid"; +import { MchoseHidClient } from "@openmouse/protocol/drivers/mchose/hid"; +import { MchoseDockHidClient } from "@openmouse/protocol/drivers/mchose/dock-hid"; import { FantechHidClient } from "@openmouse/protocol/drivers/fantech/hid"; import { WallhackMouseHidClient } from "@openmouse/protocol/drivers/wallhack/mouse-hid"; import { WallhackKeyboardHidClient } from "@openmouse/protocol/drivers/wallhack/keyboard-hid"; @@ -186,7 +188,7 @@ function activeAs(...classes: ClientClass[]): T | null { const DM_CLASSES = [WLMouseHidClient, LamzuHidClient, AtkHidClient, NinjutsoHidClient] as const; const RAZER_CLASSES = [RazerHidClient, RazerViperMiniHidClient, RazerViperHidClient, RazerCobraHidClient] as const; -const NEEDS_OPEN = [TeevolutionHidClient, VgnF2HidClient, KeychronNapeHidClient, KeychronM6HidClient, ModdoHidClient, ZaunkoenigHidClient, FantechHidClient, WallhackMouseHidClient, WallhackKeyboardHidClient, GloriousHidClient, GloriousClassicHidClient] as const; +const NEEDS_OPEN = [TeevolutionHidClient, VgnF2HidClient, KeychronNapeHidClient, KeychronM6HidClient, ModdoHidClient, ZaunkoenigHidClient, FantechHidClient, WallhackMouseHidClient, WallhackKeyboardHidClient, GloriousHidClient, GloriousClassicHidClient, MchoseHidClient, MchoseDockHidClient] as const; const PULSAR_CLASSES = [PulsarHidClient, PulsarProHidClient, PulsarXs1HidClient] as const; const logitechClient = (): LogitechHidppClient | null => activeAs(LogitechHidppClient); @@ -519,18 +521,34 @@ function requireClientMethod( return client as Extract>; } +/** Read an optional numeric getter off whatever client is connected. */ +function clientNumber(method: string): number | null { + const client = active as unknown as Record unknown) | undefined> | null; + const value = client?.[method]?.(); + return typeof value === "number" ? value : null; +} + +/** Read an optional number-list getter off whatever client is connected. */ +function clientNumberList(method: string): number[] | null { + const client = active as unknown as Record unknown) | undefined> | null; + const value = client?.[method]?.(); + return Array.isArray(value) && value.every((entry) => typeof entry === "number") ? value : null; +} + function readCapabilities(): DeviceCapabilities { const razer = activeAs(RazerHidClient); const dm = dmClient(); const keychron = keychronNapeClient(); return { canDisableSleep: dm?.canDisableSleep === true, + // Any client may publish these; the two named drivers are just the ones + // that predate the generic lookup below. sleepOptions: dm ? [...dm.getSleepOptions()] : keychron ? [...keychron.getSleepOptions()] - : null, - debounceMaxMs: dm?.getDebounceMaxMs() ?? null, + : clientNumberList("getSleepOptions"), + debounceMaxMs: dm?.getDebounceMaxMs() ?? clientNumber("getDebounceMaxMs"), razerSleepOptions: razer?.getSleepOptions() ?? null, razerLowPowerOptions: razer?.getLowPowerOptions() ?? null, lowPowerPollingCeiling: razer?.getLowPowerPollingCeiling() ?? null, @@ -3340,6 +3358,84 @@ export function applyEggButtonMapping(button: EggButtonIndex, mapping: EggButton }); } +/** + * Select a named power/performance mode on any driver that exposes + * `setPowerMode`. + */ +export function applyPowerMode(mode: string): void { + stageChange({ + key: "power-mode", + label: mode, + command: "Change the performance mode", + progress: "Changing mode…", + preview: (status) => { status.powerMode = mode; }, + apply: async () => { + await requireClientMethod("setPowerMode", "the performance mode").setPowerMode(mode); + }, + }); +} + +/** Set sensor angle tuning on any driver that exposes `setAngleTuning`. */ +export function applyAngleTuning(degrees: number): void { + stageChange({ + key: "angle-tuning", + label: `Angle tuning ${degrees}00b0`, + command: "Change the angle tuning", + progress: "Changing angle tuning…", + preview: (status) => { status.angleTuning = degrees; }, + apply: async () => { + await requireClientMethod("setAngleTuning", "angle tuning").setAngleTuning(degrees); + }, + }); +} + +/** + * Reassign a physical button on any driver that exposes `setButtonMapping`. + * Named for the device-level map to keep it distinct from `applyButtonMapping` + * above, which reassigns a Logitech control by id. + */ +export function applyDeviceButtonMapping(button: string, action: string): void { + stageChange({ + key: `button-${button}`, + label: `${button}: ${action}`, + command: `Remap the ${button} button`, + progress: "Remapping…", + preview: (status) => { + if (status.buttonMappings) { + status.buttonMappings = { ...status.buttonMappings, [button]: action }; + } + }, + apply: async () => { + // Endgame's client also has a setButtonMapping, with its own parameter + // types, so the extracted union narrows the arguments to `never`. The + // cast keeps this path device-agnostic; requireClientMethod has already + // established the method exists. + const client = requireClientMethod("setButtonMapping", "button assignments") as unknown as { + setButtonMapping(button: string, action: string): Promise; + }; + await client.setButtonMapping(button, action); + }, + }); +} + +/** + * Switch a numbered onboard profile on any driver that exposes `setProfile`. + * The device's DPI and polling belong to the profile, so the panel re-reads + * rather than previewing a value that is about to be replaced wholesale. + */ +export function applyProfileSelection(profile: number): void { + stageChange({ + key: "onboard-profile", + label: `Profile ${profile}`, + command: "Change the active profile", + progress: "Switching profile…", + preview: (status) => { status.activeProfile = profile; }, + apply: async () => { + await requireClientMethod("setProfile", "the active profile").setProfile(profile); + }, + }); +} + export function applyProSetting( setting: "wheelAcceleration" | "angleTuning" | "profile", value: boolean | number, diff --git a/src/device/options.ts b/src/device/options.ts index fa28814..ac55a6a 100644 --- a/src/device/options.ts +++ b/src/device/options.ts @@ -1,4 +1,6 @@ export function sleepLabel(seconds: number): string { + // Drivers whose firmware treats zero as "no auto-sleep" offer it as an option. + if (seconds === 0) return "Never"; if (seconds < 60) return `${seconds} seconds`; if (seconds % 3600 === 0) { const hours = seconds / 3600; diff --git a/src/device/traits.ts b/src/device/traits.ts index 53d088a..3cff61c 100644 --- a/src/device/traits.ts +++ b/src/device/traits.ts @@ -47,6 +47,9 @@ const BY_FAMILY: Readonly>> = { ninjutso: { ...DIRECT_MODE, ninjutso: true }, "keychron-nape": { advancedSection: true, sleep: true, directMode: true }, fantech: { advancedSection: true, sleep: true, directMode: true }, + // MCHOSE reads debounce and sleep from its config blob and writes both, but + // it is not a direct-mode (CompX) driver, so it takes the plain flags. + mchose: { advancedSection: true, sleep: true, debounce: true }, }; const BY_BRAND: Readonly> = { diff --git a/src/supported-mice.test.ts b/src/supported-mice.test.ts index 11b474a..97d265b 100644 --- a/src/supported-mice.test.ts +++ b/src/supported-mice.test.ts @@ -16,6 +16,7 @@ import { NINJUTSO_LEGACY_RECEIVER_PRODUCT_IDS, NINJUTSO_RECEIVER_PRODUCT_IDS, } from "@openmouse/protocol/ninjutso"; +import { MCHOSE_DOCK_PRODUCT_ID, MCHOSE_LINK_PRODUCT_IDS, MCHOSE_PRODUCTS } from "@openmouse/protocol/mchose"; import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; import { PULSAR_XS1_PRODUCT_IDS } from "@openmouse/protocol/pulsar"; import { RAZER_PRODUCTS } from "@openmouse/protocol/razer-devices"; @@ -137,6 +138,11 @@ const PID_UNIVERSE = new Set([ 0x184a, 0x1848, // Glorious Pixart Model O 2 / I 2 family (drivers/glorious/hid.ts) and // classic pre-Pixart Model O/D/I family (drivers/glorious/classic-hid.ts). + // MCHOSE A7 V2 family: model ids plus the receiver/Bluetooth link ids + // (drivers/mchose/hid.ts). + ...MCHOSE_PRODUCTS.map((product) => product.productId), + ...Object.values(MCHOSE_LINK_PRODUCT_IDS), + MCHOSE_DOCK_PRODUCT_ID, ...GLORIOUS_PRODUCTS.keys(), ...GLORIOUS_CLASSIC_PRODUCTS.keys(), ]); diff --git a/src/supported-mice.ts b/src/supported-mice.ts index 0086164..5ac20ee 100644 --- a/src/supported-mice.ts +++ b/src/supported-mice.ts @@ -562,6 +562,18 @@ export const MICE: Mouse[] = [ note: "Protocol unknown" }, { brand: "Furycube", model: "G11", status: "unknown", req: 5, note: "Protocol unknown" }, + { brand: "Mchose", model: "A7 V2 Ultra+", status: "supported", req: 0, + pids: [0x4021, 0x100b], + note: "Confirmed on hardware over both the 2.4 GHz receiver and the cable — model, firmware, battery and charge state, DPI stages and values, stage count, polling, lift-off, motion sync, ripple, angle snapping, angle tuning, performance mode, debounce, sleep, 3 named profiles and button remapping all round-tripped" }, + { brand: "Mchose", model: "MagDock (charging base)", status: "supported", req: 0, + pids: [0x1012], + note: "The A7 V2 base, not a mouse -- RGB read and write confirmed on hardware; the mice themselves have no LEDs" }, + { brand: "Mchose", model: "A7 V2 Ultra", status: "likely", req: 0, + note: "Same driver and protocol as the Ultra+; not confirmed on hardware" }, + { brand: "Mchose", model: "A7 V2 Pro+", status: "likely", req: 0, + note: "Same driver and protocol as the Ultra+; not confirmed on hardware" }, + { brand: "Mchose", model: "A7 V2 Pro", status: "likely", req: 0, + note: "Same driver and protocol as the Ultra+; not confirmed on hardware" }, { brand: "Mchose", model: "K7 Ultra", status: "unknown", req: 5, note: "Protocol unknown" }, { brand: "Mchose", model: "L7 Ultra+", status: "unknown", req: 3, diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts index fba11c1..dd73e71 100644 --- a/src/ui/device-images.ts +++ b/src/ui/device-images.ts @@ -10,6 +10,7 @@ * it had before any art existed. See `public/devices/README.md` for how to * upload new art. */ + const DEVICE_IMAGES: ReadonlyMap = new Map([ ["046d:c07d", "logitech-g502.png"], ["046d:c095", "logitech-g502-x-plus.png"], @@ -35,6 +36,17 @@ const DEVICE_IMAGES: ReadonlyMap = new Map([ ["1532:0078", "razer-viper.webp"], ["1532:00a3", "razer-cobra.webp"], ["1532:0094", "razer-orochi-v2.png"], + // MCHOSE A7 V2 family. Pro, Pro+, Ultra and Ultra+ are one shell with + // different sensors — MCHOSE itself only publishes `A7V2Pro_*` renders — so + // every model id and every link (receiver, Bluetooth, 8K receiver) maps to + // the same art. + ["3837:4018", "mchose-a7-v2.png"], + ["3837:4019", "mchose-a7-v2.png"], + ["3837:4021", "mchose-a7-v2.png"], + ["3837:4023", "mchose-a7-v2.png"], + ["3837:100a", "mchose-a7-v2.png"], + ["3837:100b", "mchose-a7-v2.png"], + ["3837:1020", "mchose-a7-v2.png"], // CRDRAKO KO-ONE wired and receiver transports share the same shell. ["373e:006a", "crdrako-ko-one.png"], ["373e:006b", "crdrako-ko-one.png"], @@ -184,6 +196,8 @@ function resolveDeviceImageFilename(device: HIDDevice | null | undefined, displa if (/\bdragonfly\s*f2\b/i.test(displayName)) return "vgn-dragonfly-f2.png"; if (/\bmaya\s*x\b/i.test(displayName)) return "lamzu-maya-x.png"; if (/\bf1\s*v2\b/i.test(displayName)) return "atk-f1-v2-ultra-max.png"; + // Catches any A7 V2 variant whose product id is not pinned above. + if (/\ba7\s*v2\b/i.test(displayName)) return "mchose-a7-v2.png"; if (/\b(finalmouse|starlight|ulx)\b/i.test(displayName)) return "finalmouse-ulx.png"; if (/\borbital\b/i.test(displayName)) return "unknown-device.png"; if (/\bmoddo/i.test(displayName)) return "unknown-device.png"; From 91bfaf3b8f9dc74c66bcd306fc97ad4360db3d4f Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:41:52 -0600 Subject: [PATCH 2/3] chore: bump @openmouse/protocol to pick up MCHOSE/K-snake/VXE/Lingbao/ATK-ZERO drivers --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ffd6e08..6d670e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -481,7 +481,7 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#032785bbc4f79174e5255d96e1ddcad578175a87", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#e6b7f22b104053c023daa89fdfc67d1b8ee47645", "engines": { "node": ">=20" } From 929d35518c9f4ef469719611291d80e92299334a Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:43:18 -0600 Subject: [PATCH 3/3] chore: raise JS bundle budget to 790 kB for MCHOSE support --- build/check-bundle-size.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 07fdace..03a3968 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -37,7 +37,9 @@ const BUDGET_BYTES: Record = { // donate page rebuild (Hall of Fame -> Support) does not drive this; its // rebuilt donate chunk is lighter than the old Minecraft-themed hof chunk it // replaced. 765 kB leaves ~15 kB of headroom over the measured aggregate. - ".js": 765_000, + // Raised to 790 kB for the MCHOSE A7 V2 mouse and MagDock driver support: + // the measured aggregate is 779.1 kB, leaving ~11 kB of headroom. + ".js": 790_000, }; const ASSETS = join("dist", "assets");