Skip to content
Open
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
25 changes: 25 additions & 0 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,24 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
</div>
) : null}

{status.sensorMode != null && !traits.teevolution ? (
<div id="sensor-mode-row" className="field-label spaced">
<span>Sensor sampling mode</span>
<select
id="sensor-mode"
value={status.sensorMode}
disabled={status.sensorModeEditable === false}
onChange={(event) => control.applySensorMode(
event.currentTarget.value as NonNullable<typeof status.sensorMode>,
)}
>
{(["Eco", "High", "Ultra"] as const).map((mode) => (
<option key={mode} value={mode}>{mode}</option>
))}
</select>
</div>
) : null}

<SwitchRow
id="motion-sync-toggle"
label="Motion Sync"
Expand Down Expand Up @@ -327,6 +345,13 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
hidden={status.hyperMode == null}
onChange={(next) => control.applyPulsarToggle("hyperMode", next)}
/>
<SwitchRow
id="long-range-toggle"
label="Ultra Long Range"
value={status.longRangeMode}
hidden={status.longRangeMode == null}
onChange={(next) => control.applyPulsarToggle("longRangeMode", next)}
/>

{traits.teevolution && teevolutionProfile ? (
<label id="teevolution-performance-duration-row" className="field-label spaced">
Expand Down
47 changes: 47 additions & 0 deletions src/app/cards/PerformanceCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ export function SensorCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNo

{pair && showPair ? (
<AsymmetricLiftOff snapshot={snapshot} />
) : status.liftOffScale ? (
<LiftOffScale snapshot={snapshot} />
) : (
<div id="lod-single">
<Segmented
Expand Down Expand Up @@ -242,6 +244,51 @@ export function SensorCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNo
);
}


/**
* Lift-off for a mouse that reports a continuous range instead of the three
* shared stops. The slider works in raw device codes and labels them with the
* millimetres the driver supplies, so no scale is hard-coded here.
*/
function LiftOffScale({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const scale = snapshot.status?.liftOffScale;
const [code, setCode] = useState(scale?.value ?? 0);

useEffect(() => {
if (scale) setCode(scale.value);
}, [scale?.value]);

if (!scale) return null;

const span = Math.max(1, scale.max - scale.min);
const millimetres = scale.minMillimetres
+ ((code - scale.min) * (scale.maxMillimetres - scale.minMillimetres)) / span;

return (
<div id="lod-scale" className="lod-sliders">
<label>
Lift-off
<output id="lod-scale-value">{millimetres.toFixed(1)} mm</output>
<span className="glass-slider-rail">
<input
id="lod-scale-input"
type="range"
min={scale.min}
max={scale.max}
step={1}
value={code}
disabled={snapshot.settingsPending}
style={{ "--fill": `${((code - scale.min) / span) * 100}%` }}
onChange={(event) => setCode(Number(event.currentTarget.value))}
onPointerUp={() => control.applyLiftOffScale(code)}
onKeyUp={() => control.applyLiftOffScale(code)}
/>
</span>
</label>
</div>
);
}

function BunnyHop({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const entry = snapshot.profile.entry;
if (!snapshot.profile.bunnyHopSupported || !entry) return null;
Expand Down
1 change: 1 addition & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
|| (status.performanceMode != null && !traits.eggFamily && !traits.finalmouse)
|| status.hyperMode != null
|| status.sensorMode != null || status.performanceDuration != null
|| status.longRangeMode != null
);

const eggs = host && traits.eggControls;
Expand Down
54 changes: 51 additions & 3 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
isEggWeClient,
type EggWeHidClient,
} from "@openmouse/protocol/drivers/endgame/egg-we-control";
import { AtkBitmouseHidClient } from "@openmouse/protocol/drivers/atk/bitmouse-hid";
import { AtkHidClient } from "@openmouse/protocol/drivers/atk/hid";
import { LamzuHidClient } from "@openmouse/protocol/drivers/lamzu/hid";
import {
Expand Down Expand Up @@ -182,7 +183,7 @@ function activeAs<T>(...classes: ClientClass<T>[]): T | null {
return null;
}

const DM_CLASSES = [WLMouseHidClient, LamzuHidClient, AtkHidClient, NinjutsoHidClient] as const;
const DM_CLASSES = [WLMouseHidClient, LamzuHidClient, AtkHidClient, AtkBitmouseHidClient, 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 DEDICATED = [
Expand All @@ -194,8 +195,8 @@ const logitechClient = (): LogitechHidppClient | null => activeAs(LogitechHidppC
const eggClient = (): EggOp1HidClient | null => activeAs(EggOp1HidClient);
const eggWeClient = (): EggWeHidClient | null =>
active !== null && isEggWeClient(active) ? active : null;
const dmClient = (): WLMouseHidClient | LamzuHidClient | AtkHidClient | NinjutsoHidClient | null =>
activeAs<WLMouseHidClient | LamzuHidClient | AtkHidClient | NinjutsoHidClient>(...DM_CLASSES);
const dmClient = (): WLMouseHidClient | LamzuHidClient | AtkHidClient | AtkBitmouseHidClient | NinjutsoHidClient | null =>
activeAs<WLMouseHidClient | LamzuHidClient | AtkHidClient | AtkBitmouseHidClient | NinjutsoHidClient>(...DM_CLASSES);
const razerClient = (): RazerHidClient | RazerViperMiniHidClient | RazerViperHidClient | RazerCobraHidClient | null =>
activeAs<RazerHidClient | RazerViperMiniHidClient | RazerViperHidClient | RazerCobraHidClient>(...RAZER_CLASSES);
const viperClient = (): RazerViperV4ProHidClient | null => activeAs(RazerViperV4ProHidClient);
Expand Down Expand Up @@ -2965,6 +2966,7 @@ function settingLabel(setting: PulsarToggleSetting): string {
rippleControl: "ripple control",
performanceMode: teevolutionClient() ? "highest performance" : "performance mode",
hyperMode: "Hyper mode",
longRangeMode: "Ultra Long Range",
} as const)[setting];
}

Expand All @@ -2974,6 +2976,7 @@ const PULSAR_TOGGLE_METHOD: Record<PulsarToggleSetting, string> = {
rippleControl: "setRippleControl",
performanceMode: "setPerformanceMode",
hyperMode: "setHyperMode",
longRangeMode: "setLongRangeMode",
};

export function applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean): void {
Expand Down Expand Up @@ -3161,6 +3164,51 @@ export function applyTeevolutionSensorMode(mode: NonNullable<MouseStatus["sensor
});
}

/**
* Sensor sampling mode for drivers outside the Teevolution profile, where the
* mode is a plain device setting rather than one the polling rate can lock.
*/
/**
* Lift-off for mice that tune it continuously rather than in three stops. The
* value is a raw device code; the driver owns what it means in millimetres.
*/
export function applyLiftOffScale(code: number): void {
const scale = latestDeviceStatus?.liftOffScale;
if (!hasActiveClient() || !scale) return;
if (!Number.isInteger(code) || code < scale.min || code > scale.max) return;
const millimetres = scale.minMillimetres
+ ((code - scale.min) * (scale.maxMillimetres - scale.minMillimetres)) / Math.max(1, scale.max - scale.min);
const label = `Lift-off ${millimetres.toFixed(1)} mm`;
stageChange({
key: "lift-off-scale",
label,
command: `Set lift-off to ${millimetres.toFixed(1)} mm`,
progress: `Setting lift-off to ${millimetres.toFixed(1)} mm…`,
preview: (status) => {
if (!status.liftOffScale) return;
status.liftOffScale = { ...status.liftOffScale, value: code, millimetres };
},
apply: async () => {
await requireClientMethod("setLiftOffScale", "lift-off distance").setLiftOffScale(code);
},
});
}

export function applySensorMode(mode: NonNullable<MouseStatus["sensorMode"]>): void {
if (!hasActiveClient()) return;
stageChange({
key: "sensor-mode",
label: `Sensor mode ${mode}`,
command: `Set sensor mode to ${mode}`,
progress: `Setting sensor mode to ${mode}…`,
preview: (status) => {
status.sensorMode = mode;
},
apply: async () => {
await requireClientMethod("setSensorMode", "the sensor mode").setSensorMode(mode);
},
});
}
export function applyTeevolutionPerformanceDuration(duration: number): void {
if (!teevolutionClient()) return;
const label = sleepLabel(duration * 10);
Expand Down
1 change: 1 addition & 0 deletions src/device/traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const BY_FAMILY: Readonly<Record<string, Partial<DriverTraits>>> = {
"attack-shark": DIRECT_MODE,
crdrako: DIRECT_MODE,
atk: DIRECT_MODE,
"atk-bitmouse": DIRECT_MODE,
ninjutso: { ...DIRECT_MODE, ninjutso: true },
"keychron-nape": { advancedSection: true, sleep: true, directMode: true },
fantech: { advancedSection: true, sleep: true, directMode: true },
Expand Down
3 changes: 2 additions & 1 deletion src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export type PulsarToggleSetting =
| "angleSnapping"
| "rippleControl"
| "performanceMode"
| "hyperMode";
| "hyperMode"
| "longRangeMode";

export interface TeevolutionProfile {
sleepOptions: readonly number[];
Expand Down
2 changes: 2 additions & 0 deletions src/supported-mice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import { DEVICE_DRIVERS } from "@openmouse/protocol/drivers/registry";
import { WLMOUSE_PRODUCTS, GLORIOUS_PRODUCTS, GLORIOUS_CLASSIC_PRODUCTS, VENDOR_ID } from "@openmouse/protocol/drivers/vendors";
import { BITMOUSE_PRODUCT_IDS } from "@openmouse/protocol/bitmouse";
import { EGG_DEVICE_PROFILES } from "@openmouse/protocol/endgame-gear-op1";
import { KEYCHRON_NAPE_PRODUCTS } from "@openmouse/protocol/keychron";
import { LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu";
Expand Down Expand Up @@ -77,6 +78,7 @@ test("supported / PR / quickwin claims require a registered driver brand", () =>
// is exempt. The moment the protocol pins those PIDs, the row is flipped to
// "supported" and this check proves the PIDs are real.
const PID_UNIVERSE = new Set<number>([
...BITMOUSE_PRODUCT_IDS,
...WLMOUSE_PRODUCTS.keys(),
...LAMZU_PRODUCTS.keys(),
...LOGITECH_DIRECT_PRODUCT_IDS,
Expand Down
5 changes: 4 additions & 1 deletion src/supported-mice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,11 @@ export const MICE: Mouse[] = [
note: "ATK driver (0x373b) likely covers — needs hardware test" },
{ brand: "ATK", model: "VXE Mad R Major", status: "likely", req: 1,
note: "ATK driver (0x373b) likely covers — needs hardware test" },
{ brand: "ATK", model: "ZERO", status: "supported", req: 1,
pids: [0x1154, 0x1155],
note: "BITMOUSE driver — wired 0x1154, 8K receiver 0x1155, usagePage 0xff05. Verified on hardware: DPI, poll rate, debounce, sleep, motion sync, ripple. Lift-off not decoded" },
{ brand: "ATK", model: "VXE Zero Normal", status: "likely", req: 1,
note: "ATK driver (0x373b) likely covers — needs hardware test" },
note: "The 0xff02 ATK driver does not reach the ZERO family — it uses the BITMOUSE channel on 0xff05; needs its own PID confirmed on hardware" },
{ brand: "VGN", model: "Dragonfly F2 Master+", status: "supported", req: 1,
pids: [0xfb56, 0xfb57],
note: "VGN F2 driver — PIDs 0xfb56/0xfb57" },
Expand Down
3 changes: 3 additions & 0 deletions src/ui/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const DEVICE_IMAGES: ReadonlyMap<string, string> = new Map([
["373e:006a", "crdrako-ko-one.png"],
["373e:006b", "crdrako-ko-one.png"],
// Attack Shark R5 Ultra wired and wireless transports share the same shell.
// ATK ZERO wired and its 8K receiver are the same shell.
["373b:1154", "atk-zero.png"],
["373b:1155", "atk-zero.png"],
["373e:0046", "attackshark-r5-ultra.png"],
["373e:0047", "attackshark-r5-ultra.png"],
// OP1 8K, Purple Frost, and v2. XM2 models use different shells.
Expand Down