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
5 changes: 4 additions & 1 deletion build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ const BUDGET_BYTES: Record<string, number> = {
// replaced. 765 kB leaves ~15 kB of headroom over the measured aggregate.
// 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,
// Raised to 800 kB for the ATK ZERO driver (AtkCards.tsx, device/atk.ts)
// and the mouse-reported lift-off range plumbing: the measured aggregate is
// 790.6 kB, leaving ~9 kB of headroom.
".js": 800_000,
};

const ASSETS = join("dist", "assets");
Expand Down
18 changes: 18 additions & 0 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,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
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 @@ -99,6 +99,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
|| status.angleTuning != null
|| status.longRangeMode != null
|| status.sensorMode != null || status.performanceDuration != null
|| status.longRangeMode != null
);

const eggs = host && traits.eggControls;
Expand Down
52 changes: 49 additions & 3 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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 @@ -191,7 +192,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, MchoseHidClient, MchoseDockHidClient] as const;
const PULSAR_CLASSES = [PulsarHidClient, PulsarProHidClient, PulsarXs1HidClient] as const;
Expand All @@ -200,8 +201,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 @@ -3330,6 +3331,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
2 changes: 2 additions & 0 deletions src/supported-mice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import { DEVICE_DRIVERS } from "@openmouse/protocol/drivers/registry";
import { ATK_COMPX_PRODUCT_IDS } from "@openmouse/protocol/drivers/atk/products";
import { WLMOUSE_PRODUCTS, GLORIOUS_PRODUCTS, GLORIOUS_CLASSIC_PRODUCTS } 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 @@ -80,6 +81,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 @@ -432,8 +432,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 @@ -55,6 +55,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