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
4 changes: 3 additions & 1 deletion build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ const BUDGET_BYTES: Record<string, number> = {
// 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");
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
NinjutsoSensorCard,
ProcessingCard,
RazerButtonCard,
ButtonMappingCard,
PowerModeCard,
OnboardProfileCard,
PulsarProCard,
SignalCard,
SleepCard,
Expand Down Expand Up @@ -144,6 +147,12 @@ function Workspace({
show(has.razerButtons, ["buttons"]) ? <RazerButtonCard key="razerbuttons" snapshot={snapshot} /> : null,
show(has.mxMasterButtons, ["buttons"])
? <MxMasterButtonsCard key="mxmaster-buttons" snapshot={snapshot} /> : null,
show(has.powerMode, ["performance"])
? <PowerModeCard key="power-mode" snapshot={snapshot} /> : null,
show(has.buttonMapping, ["buttons"])
? <ButtonMappingCard key="button-mapping" snapshot={snapshot} /> : null,
show(has.onboardProfiles, ["profiles"])
? <OnboardProfileCard key="onboard-profile" snapshot={snapshot} /> : null,
show(has.pulsarPro, ["profiles"]) ? <PulsarProCard key="pulsarpro" snapshot={snapshot} /> : null,
].filter((node) => node !== null);

Expand Down
113 changes: 112 additions & 1 deletion src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ export function SleepCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNod
const keychronSleep = status.ui?.family === "keychron-nape";

let options: ReadonlyArray<readonly [number, string]> = 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);
Expand Down Expand Up @@ -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 (
<article id="onboard-profile-settings" className="setting-card">
<div className="setting-heading compact"><div><h2>Profile</h2></div></div>
<label className="field-label spaced">
Active profile
<select
id="onboard-profile-select"
value={status.activeProfile}
onChange={(event) => control.applyProfileSelection(Number(event.currentTarget.value))}
>
{Array.from({ length: status.profileCount }, (_, index) => index + 1).map((value) => (
// Devices that store their own names show them; the rest number.
<option key={value} value={value}>
{status.profileNames?.[value - 1] ?? `Profile ${value}`}
</option>
))}
</select>
</label>
<p className="field-note">
Each profile stores its own DPI stages and polling rate, so those values
change with the profile.
</p>
</article>
);
}

/**
* 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 (
<article id="button-mapping-settings" className="setting-card">
<div className="setting-heading compact"><div><p>BUTTONS</p><h2>Remap</h2></div></div>
{Object.entries(status.buttonMappings).map(([button, assigned]) => (
<label key={button} className="field-label spaced">
{button}
<select
id={`button-${button.toLowerCase()}-select`}
value={options.includes(assigned) ? assigned : ""}
onChange={(event) => control.applyDeviceButtonMapping(button, event.currentTarget.value)}
>
{/* A macro or an assignment this build cannot name still shows. */}
{!options.includes(assigned) && <option value="">{assigned}</option>}
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</label>
))}
<p className="field-note">
&ldquo;Default&rdquo; restores a button&rsquo;s factory function.
</p>
</article>
);
}

/**
* 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 (
<article id="power-mode-settings" className="setting-card">
<div className="setting-heading compact"><div><p>SENSOR</p><h2>Mode</h2></div></div>
{modes?.length ? (
<Segmented
className={modes.length === 3 ? "three" : undefined}
ariaLabel="Performance mode"
options={modes.map((mode) => ({ value: mode, label: mode }))}
value={status.powerMode ?? modes[0]!}
onChange={(next) => control.applyPowerMode(String(next))}
/>
) : null}
{tuning != null ? (
<label className="field-label spaced">
Angle tuning
<select
id="angle-tuning-select"
value={tuning}
onChange={(event) => control.applyAngleTuning(Number(event.currentTarget.value))}
>
{Array.from({ length: 61 }, (_, index) => index - 30).map((angle) => (
<option key={angle} value={angle}>{angle}°</option>
))}
</select>
</label>
) : null}
</article>
);
}

export function PulsarProCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
if (!status) return null;
Expand Down
9 changes: 9 additions & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 99 additions & 3 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -186,7 +188,7 @@ function activeAs<T>(...classes: ClientClass<T>[]): 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);
Expand Down Expand Up @@ -519,18 +521,34 @@ function requireClientMethod<K extends string>(
return client as Extract<SupportedClient, Record<K, unknown>>;
}

/** Read an optional numeric getter off whatever client is connected. */
function clientNumber(method: string): number | null {
const client = active as unknown as Record<string, (() => 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<string, (() => 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>(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,
Expand Down Expand Up @@ -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<unknown>;
};
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,
Expand Down
2 changes: 2 additions & 0 deletions src/device/options.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/device/traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const BY_FAMILY: Readonly<Record<string, Partial<DriverTraits>>> = {
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<Record<string, string>> = {
Expand Down
6 changes: 6 additions & 0 deletions src/supported-mice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -137,6 +138,11 @@ const PID_UNIVERSE = new Set<number>([
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(),
]);
Expand Down
Loading