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
6 changes: 3 additions & 3 deletions build/check-bundle-size.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,9 +7,9 @@ const BUDGET_BYTES: Record<string, number> = {
".css": 46_000,
// Raised from 280 kB for the production Logitech onboard-profile codec,
// guarded flash editor, verification exporter, upstream Finalmouse driver,
// the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power
// protocol support. Preview fixtures remain dev-only.
".js": 320_000,
// the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power plus
// asymmetric lift-off protocol and controls. Preview fixtures remain dev-only.
".js": 325_000,
};

const ASSETS = join("dist", "assets");
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

22 changes: 22 additions & 0 deletions src/control-events.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ export interface ControlEventHandlers {
applyFinalmouseSetting(setting: "dongleLed" | "tournamentScroll" | "tournamentTimeout", value: number): void;
applyPollingRate(rate: number): void;
applyLiftOffDistance(lod: NonNullable<MouseStatus["liftOffDistance"]>): void;
applyLiftOffMode(mode: "single" | "asymmetric"): void;
applyAsymmetricLiftOff(liftOff: number, landing: number): void;
capLandingToLiftOff(): void;
applyGamingSurfaceMode(mode: NonNullable<MouseStatus["gamingSurfaceMode"]>): void;
applyLightforceSwitchMode(mode: NonNullable<MouseStatus["lightforceSwitchMode"]>): void;
// Mode and profile selection apply immediately: both are volatile navigation
Expand DownExpand Up@@ -226,6 +229,25 @@ export function bindControlEvents(handlers: ControlEventHandlers): void {
if (lod) void handlers.applyLiftOffDistance(lod);
});
});
document.querySelectorAll<HTMLButtonElement>("[data-lod-mode]").forEach((button) => {
button.addEventListener("click", () => {
const mode = button.dataset.lodMode;
if (mode === "single" || mode === "asymmetric") handlers.applyLiftOffMode(mode);
});
});
const liftOffSlider = document.querySelector<HTMLInputElement>("#lod-lift-off");
const landingSlider = document.querySelector<HTMLInputElement>("#lod-landing");
for (const slider of [liftOffSlider, landingSlider]) {
// `input` keeps the readout and the landing ceiling honest while dragging;
// `change` fires on release, so a drag stages one change rather than thirty.
slider?.addEventListener("input", () => handlers.capLandingToLiftOff());
slider?.addEventListener("change", () => {
handlers.capLandingToLiftOff();
const liftOff = Number(liftOffSlider?.value);
const landing = Number(landingSlider?.value);
if (Number.isFinite(liftOff) && Number.isFinite(landing)) handlers.applyAsymmetricLiftOff(liftOff, landing);
});
}
document.querySelectorAll<HTMLButtonElement>("[data-gaming-surface]").forEach((button) => {
button.addEventListener("click", () => {
const mode = button.dataset.gamingSurface as MouseStatus["gamingSurfaceMode"];
Expand Down
2 changes: 1 addition & 1 deletion src/control-template.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ export function controlTemplate(buildLabel: string): string {
<section class="settings-grid device-data" aria-label="Mouse status">
<article class="setting-card dpi-card" data-pending-key="dpi"><div class="setting-heading"><div><p>DPI</p><h2>Sensitivity<span class="setting-scope" id="dpi-scope-badge" hidden></span></h2></div><div class="dpi-header-actions"><input id="dpi-output" type="text" inputmode="numeric" value="— DPI" aria-label="DPI value" readonly /><button id="custom-dpi" type="button" disabled>Custom</button></div></div><div id="dpi-presets" class="segmented dpi-presets" role="group" aria-label="Common DPI values"></div><div id="logitech-axis-controls" style="display:none"><div class="axis-grid"><label>X axis<input id="logitech-dpi-x" type="number" min="100" step="50" /></label><label>Y axis<input id="logitech-dpi-y" type="number" min="100" step="50" /></label><button id="apply-logitech-axes" class="axis-apply" type="button">Apply</button></div></div><div id="logitech-dpi-slots" hidden><div class="dpi-slot-header"><span>Slots in use</span><div id="dpi-slot-count" class="dpi-slot-count" role="group" aria-label="Number of DPI slots"></div></div><div class="dpi-slot-rule"></div><div id="dpi-slot-list" class="dpi-slot-list"></div><small id="dpi-slot-note" class="setting-note"></small></div><div class="setting-action"><span id="dpi-pending">Choose a DPI value</span></div></article>
<article id="polling-card" class="setting-card" data-pending-key="polling-rate"><div class="setting-heading"><div><p>POLLING RATE</p><h2>Report frequency<span class="setting-scope" id="rate-scope-badge" hidden></span></h2></div></div><div id="profile-rate-rows" hidden><div id="profile-rate-wireless" class="rate-slider" data-rate-link="wireless"></div><div id="profile-rate-wired" class="rate-slider" data-rate-link="wired"></div></div><div id="host-rate-slider" class="rate-slider"></div><small id="polling-note" class="setting-note">Higher rates update cursor movement more often, but use more battery.</small></article>
<article class="setting-card" data-pending-key="lift-off-distance gaming-surface"><div class="setting-heading tight"><div><p>SENSOR</p></div></div><div id="gaming-surface-row" hidden><div class="setting-heading"><div><h2>Gaming surface</h2></div></div><div class="segmented three" role="group" aria-label="Gaming surface"><button data-gaming-surface="On" disabled>On</button><button data-gaming-surface="Off" disabled>Off</button><button data-gaming-surface="Auto" disabled>Auto</button></div><small class="setting-note">Tunes the sensor for gaming mouse pads. Auto lets the mouse decide; turn it off if tracking misbehaves on a non-gaming surface.</small></div><div id="host-lod-row"><div class="setting-heading"><div><h2>Lift-off distance</h2></div></div><div class="segmented three" role="group" aria-label="Lift-off distance"><button data-lod="Low" disabled>Low</button><button data-lod="Medium" disabled>Medium</button><button data-lod="High" disabled>High</button></div><small id="lod-note" class="setting-note">Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.</small></div></article>
<article class="setting-card" data-pending-key="lift-off-distance gaming-surface"><div class="setting-heading tight"><div><p>SENSOR</p></div></div><div id="gaming-surface-row" hidden><div class="setting-heading"><div><h2>Gaming surface</h2></div></div><div class="segmented three" role="group" aria-label="Gaming surface"><button data-gaming-surface="On" disabled>On</button><button data-gaming-surface="Off" disabled>Off</button><button data-gaming-surface="Auto" disabled>Auto</button></div><small class="setting-note">Tunes the sensor for gaming mouse pads. Auto lets the mouse decide; turn it off if tracking misbehaves on a non-gaming surface.</small></div><div id="host-lod-row"><div class="setting-heading"><div><h2>Lift-off distance</h2></div></div><div id="lod-mode-row" class="lod-mode" hidden><div class="segmented two" role="group" aria-label="Lift-off mode"><button data-lod-mode="single" disabled>Single</button><button data-lod-mode="asymmetric" disabled>Asymmetric</button></div></div><div id="lod-single"><div class="segmented three" role="group" aria-label="Lift-off distance"><button data-lod="Low" disabled>Low</button><button data-lod="Medium" disabled>Medium</button><button data-lod="High" disabled>High</button></div></div><div id="lod-asymmetric" class="lod-sliders" hidden><label>Lift-off<output id="lod-lift-off-value">—</output><input id="lod-lift-off" type="range" min="2" max="26" step="1" /></label><label>Landing<output id="lod-landing-value">—</output><input id="lod-landing" type="range" min="1" max="25" step="1" /></label></div><small id="lod-note" class="setting-note">Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.</small></div></article>
<article id="lightforce-card" class="setting-card" data-pending-key="lightforce-switch-mode" hidden><div class="setting-heading"><div><p>SWITCHES</p><h2>LightForce</h2></div></div><div class="segmented" role="group" aria-label="LightForce switch mode"><button data-lightforce="Hybrid" disabled>Hybrid</button><button data-lightforce="Optical" disabled>Optical only</button></div><small class="setting-note">Hybrid saves power by using the mechanical contact and only waking the optical sensor when needed. Optical only is consistent but uses more battery.</small><div id="bunny-hop-row" hidden><div class="setting-heading"><div><h2>Bunny hop<span class="setting-scope">Per-profile</span></h2></div></div><div class="bunny-hop-controls"><button id="bunny-hop-enabled" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button><input id="bunny-hop-input" type="number" min="100" max="1000" step="10" value="100" aria-label="Bunny hop time in milliseconds" /><span>ms</span></div><small class="setting-note" id="bunny-hop-note"></small></div></article>
</section>
<section id="logitech-device-details" class="device-data" style="display:none">
Expand Down
14 changes: 14 additions & 0 deletions src/control.css
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
@import "./control-devices.css";
@import "./control-profiles.css";

/* Any author-level `display` beats the browser's own `[hidden]` rule, so a
`hidden` property set from script silently does nothing on anything laid out
with grid or flex. Several controls are hidden that way. */
[hidden] { display: none !important; }

:root {
--ink: #09090b;
--sidebar: #0d0d0f;
Expand DownExpand Up@@ -143,6 +148,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }

.segmented { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; }
.segmented.two { grid-template-columns: repeat(2, 1fr); }
.segmented.three { grid-template-columns: repeat(3, 1fr); }
.segmented button { padding: .6rem .4rem; border: 1px solid #303034; border-radius: 7px; background: var(--input); color: var(--muted); font-size: .72rem; }
.segmented button:hover { border-color: var(--ghost); color: #fff; }
.segmented button.selected { border-color: var(--text); background: var(--text); color: var(--ink); }
Expand All@@ -169,6 +175,14 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
.axis-grid input { width: 100%; box-sizing: border-box; margin-top: .2rem; padding: .42rem; border: 1px solid var(--line-strong); border-radius: 6px; background: #171719; color: #eee; }
.axis-apply { padding: .45rem .6rem; border: 1px solid #45454a; border-radius: 6px; background: #202023; color: var(--bright); font-size: .62rem; }
#gaming-surface-row { margin-bottom: .85rem; }
.lod-mode { margin-bottom: .5rem; }
/* Sliders rather than number fields, so a landing above the lift-off is not
expressible. The device stores an inverted pair without complaint. */
.lod-sliders { display: grid; gap: .55rem; }
.lod-sliders label { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: .1rem .5rem; color: var(--faint); font-size: .62rem; }
.lod-sliders output { color: var(--bright); font-family: var(--font-mono); font-size: .72rem; }
.lod-sliders input[type="range"] { grid-column: 1 / -1; width: 100%; margin: .1rem 0 0; accent-color: var(--ui-accent); }
.lod-sliders input[type="range"]:disabled { opacity: .45; }

.switch-button { min-width: 42px; padding: .2rem .45rem; border: 1px solid var(--edge); border-radius: 999px; background: #202023; color: #8b8b90; font-size: .58rem; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .2rem 0; color: var(--dim); font-size: .7rem; }
Expand Down
98 changes: 98 additions & 0 deletions src/control.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -401,6 +401,9 @@ function renderControl(): void {
applyFinalmouseSetting,
applyPollingRate,
applyLiftOffDistance,
applyLiftOffMode,
applyAsymmetricLiftOff,
capLandingToLiftOff,
applyGamingSurfaceMode,
applyLightforceSwitchMode,
flashPendingChanges,
Expand DownExpand Up@@ -1265,6 +1268,7 @@ function showStatus(deviceStatus: MouseStatus): void {
const lodNeedsSurface = ui?.lodRequiresSurface === true && status.gamingSurfaceMode === "Off";
button.disabled = hideLow || unsupported || settingsPending || legacyLogitechLow || lodNeedsSurface;
});
renderAsymmetricLiftOff(status, settingsPending);
// Runs after the DPI controls above so the slot editor, when it is in charge,
// has the last word on which of them are visible.
renderDpiSlots();
Expand DownExpand Up@@ -2931,13 +2935,107 @@ function applyLiftOffDistance(lod: NonNullable<MouseStatus["liftOffDistance"]>):
progress: `Setting ${lod.toLowerCase()} lift-off distance…`,
preview: (status) => {
status.liftOffDistance = lod;
// Writing a tracking level is also how a mouse with a pair leaves
// asymmetric mode, so the switch has to follow.
if (status.asymmetricLiftOff) status.asymmetricLiftOff = { ...status.asymmetricLiftOff, enabled: false };
},
apply: async () => {
await requireClientMethod("setLiftOffDistance", "the lift-off distance").setLiftOffDistance(lod);
},
});
}

/**
* Mice that expose separate cut-off and re-engage heights get a mode switch, in
* the shape the vendor software uses: one control or the other, never both.
* Drivers that do not report the pair never see the switch and keep the plain
* three-stop control.
*/
function renderAsymmetricLiftOff(status: MouseStatus, settingsPending: boolean): void {
const pair = status.asymmetricLiftOff;
const modeRow = document.querySelector<HTMLElement>("#lod-mode-row");
const single = document.querySelector<HTMLElement>("#lod-single");
const asymmetric = document.querySelector<HTMLElement>("#lod-asymmetric");
if (!modeRow || !single || !asymmetric) return;
modeRow.hidden = !pair;
// A driver that cannot establish the mode leaves both buttons unselected
// rather than claiming one, and the single control stays visible. Choosing
// either mode writes it, so one click makes the display true.
const showPair = pair?.enabled === true;
single.hidden = Boolean(pair) && showPair;
asymmetric.hidden = !showPair;
document.querySelectorAll<HTMLButtonElement>("[data-lod-mode]").forEach((button) => {
const isPair = button.dataset.lodMode === "asymmetric";
setSelected(button, pair?.enabled === null ? false : isPair === showPair);
button.disabled = settingsPending || !pair;
});
if (!pair) return;
for (const [selector, value, range] of [
["#lod-lift-off", pair.liftOff, pair.liftOffRange],
["#lod-landing", pair.landing, pair.landingRange],
] as const) {
const input = document.querySelector<HTMLInputElement>(selector);
if (!input) continue;
input.min = String(range.min);
input.max = String(range.max);
input.value = String(value);
input.disabled = settingsPending;
}
capLandingToLiftOff();
}

/**
* Landing is bounded by lift-off on the device, and the vendor software caps its
* own slider the same way. Enforcing it on the control means an invalid pair is
* not expressible, rather than accepted and then quietly altered — the firmware
* stores an inverted pair without complaint, so nothing downstream would catch
* it. The driver still caps as a backstop.
*/
export function capLandingToLiftOff(): void {
const liftOff = document.querySelector<HTMLInputElement>("#lod-lift-off");
const landing = document.querySelector<HTMLInputElement>("#lod-landing");
if (!liftOff || !landing) return;
// Clamp the value, never the range. A range input positions its thumb
// relative to its own bounds, so narrowing `max` to the ceiling made the
// thumb slide across the track whenever lift-off moved even though the
// number under it had not changed. Both sliders keep the device's full range,
// which also lines the two tracks up: they are the same 24 steps wide, offset
// by one, so a landing sitting just below its lift-off reads that way.
const ceiling = Math.max(Number(landing.min), Number(liftOff.value) - 1);
if (Number(landing.value) > ceiling) landing.value = String(ceiling);
setText("#lod-lift-off-value", liftOff.value);
setText("#lod-landing-value", landing.value);
}

/** Both modes are set by writing one, because the mouse honours the last write. */
function applyLiftOffMode(mode: "single" | "asymmetric"): void {
const status = withPendingChanges(latestDeviceStatus ?? ({} as MouseStatus));
const pair = status.asymmetricLiftOff;
if (!latestDeviceStatus || !pair) return;
if (mode === "asymmetric") applyAsymmetricLiftOff(pair.liftOff, pair.landing);
else applyLiftOffDistance(status.liftOffDistance ?? "Medium");
}

function applyAsymmetricLiftOff(liftOff: number, requested: number): void {
if (!hasActiveClient()) return;
// Capped here too, so the staged label and the preview show what will actually
// be written rather than what was asked for.
const landing = Math.min(requested, liftOff - 1);
stageChange({
key: "lift-off-distance",
label: `Lift-off ${liftOff} / landing ${landing}`,
command: `Set lift-off to ${liftOff} and landing to ${landing}`,
progress: `Setting lift-off ${liftOff} and landing ${landing}…`,
preview: (status) => {
if (!status.asymmetricLiftOff) return;
status.asymmetricLiftOff = { ...status.asymmetricLiftOff, enabled: true, liftOff, landing };
},
apply: async () => {
await requireClientMethod("setLiftOff", "the lift-off distance").setLiftOff(liftOff, landing);
},
});
}

/**
* Gaming surface and LightForce are two fields of one 0x8090 byte, so they are
* staged as a group and written together. Each apply sends the whole group's
Expand Down
16 changes: 16 additions & 0 deletions src/devices/mouse-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,22 @@ export interface MouseStatus {
liftOffDistance: "Low" | "Medium" | "High" | null;
/** Explicit LOD choices when a mouse does not support all three common levels. */
supportedLiftOffDistances?: Array<NonNullable<MouseStatus["liftOffDistance"]>>;
/**
* Separate cut-off and re-engage heights, where a mouse offers them instead
* of the single three-stop `liftOffDistance`. Only drivers that have verified
* it on hardware populate this; everywhere else it stays undefined and the
* control does not appear.
*
* `enabled` is which of the two the mouse is actually using, and may be null
* when a driver cannot establish it.
*/
asymmetricLiftOff?: {
enabled: boolean | null;
liftOff: number;
landing: number;
liftOffRange: { min: number; max: number };
landingRange: { min: number; max: number };
} | null;
/** Logitech onboard profile format (HID++ 0x8100), which selects the layout. */
onboardProfileFormat?: {
id: number;
Expand Down
5 changes: 3 additions & 2 deletions src/devices/razer/TESTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,8 +45,9 @@ other control is withheld because no command for it has been confirmed.
5. Change the polling rate on each connection and confirm it persists. The cable
offers 125/500/1000 and the receiver adds 2000/4000/8000; no other rate
should appear.
6. Confirm no lift-off distance buttons, no sensor processing card and no
receiver signal card appear.
6. On the receiver, confirm the lift-off card supports both Single and
Asymmetric modes; on the cable, confirm it stays hidden if class `0x0b` is
unsupported. Confirm no sensor processing card or receiver signal card appears.
7. Change the auto sleep timeout **on the cable and again on the receiver**,
reloading each time to confirm the new value persisted. Unlike polling, both
transports answer `0x07`/`0x83`, so the card belongs on both.
Expand Down
Loading