From 527e5d996d08562657d1a49558f462fa7f050c53 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 05:56:45 +0200 Subject: [PATCH 01/59] HITS tuning: flash at once when instant flash is on A HITS step button only updated on-screen state; the change was staged, and so flashed by instant flash, only when Apply was pressed. With instant flash on, stage the change as soon as a step is picked: both-buttons changes through the shared writer (one profile write), left and right through their own button. Without instant flash it still waits for Apply. --- src/device/controller.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/device/controller.ts b/src/device/controller.ts index 1ae066c1..bb1a666e 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3160,6 +3160,11 @@ export function setAnalogTuningValue( ): void { analogTuning = { ...analogTuning, [group]: { ...analogTuning[group], [setting]: value } }; emit(); + // A HITS step is only on-screen state until it is staged. With instant flash + // on, stage it now, as every other setting does; otherwise it waits for Apply. + if (!interfacePreferences.instantFlash) return; + if (group === "both") applyLogitechAnalogButtons(); + else applyLogitechAnalogButton(group === "left" ? 0 : 1); } /** From 23fa4e65bfa7ad396f41b7f4aa765ad8eabe18c1 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:00:11 +0200 Subject: [PATCH 02/59] HITS tuning: hide the Apply buttons while instant flash is on With instant flash on a HITS step is staged and flashed as soon as it is picked, so the Apply to both buttons and per-button Apply buttons have nothing left to do. Hide them then. They stay while a game-profile draft is open, because HITS changes are not flashed there. --- src/app/Superstrike.tsx | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/app/Superstrike.tsx b/src/app/Superstrike.tsx index 4f7f019d..d87e4d48 100644 --- a/src/app/Superstrike.tsx +++ b/src/app/Superstrike.tsx @@ -174,6 +174,9 @@ export function Superstrike({ snapshot }: { snapshot: ControlSnapshot }): ReactN if (!tuning || tuning.buttons.length !== 2) return null; const locale = snapshot.preferences.locale; const state = snapshot.analogTuning; + // With instant flash on, a step is written the moment it is picked, so there + // is nothing to apply. A game-profile draft stages instead of flashing. + const showApply = !snapshot.preferences.instantFlash || snapshot.gameProfileDraft; return (
- + {showApply ? ( + + ) : null} ))} @@ -223,14 +228,16 @@ export function Superstrike({ snapshot }: { snapshot: ControlSnapshot }): ReactN {t(locale, "super.bothPrimary")}

{t(locale, "super.bothBody")}

- + {showApply ? ( + + ) : null} From fcbde3787547347e021692fd18c1760d34d3c13c Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:07:05 +0200 Subject: [PATCH 03/59] HITS card: first on the Buttons tab, with Bunny Hop inside it The HITS Tuning card was a separate section below the card list, so on a Superstrike the Buttons tab showed the Switches card first and the HITS card under it. Make it the first card in the list, which also keeps the mouse panel beside it, and put Bunny Hop at the bottom of the card. Bunny Hop is a per-profile setting and the Superstrike has no LightForce switches, so it no longer needs the Switches card. --- src/app/OverviewPage.tsx | 3 ++- src/app/Superstrike.tsx | 14 ++++++-------- src/app/cards/PerformanceCards.tsx | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index deff4471..fdc13998 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -542,6 +542,8 @@ export function Workspace({ const powerOverview = status.ui?.powerOverview === true; const performance = [ + // First in the Buttons tab, in the card list so the mouse panel stays beside it. + show(has.superstrike, ["buttons"]) ? : null, show(has.dpi, ["performance"]) ? : null, show(has.polling, ["performance"]) ? : null, show(has.sensor, ["performance"]) ? : null, @@ -694,7 +696,6 @@ export function Workspace({
) : null} - {showSuperstrike ? : null} {showMagnetic ? : null} {advanced.length > 0 ? ( diff --git a/src/app/Superstrike.tsx b/src/app/Superstrike.tsx index d87e4d48..b79067d1 100644 --- a/src/app/Superstrike.tsx +++ b/src/app/Superstrike.tsx @@ -3,6 +3,7 @@ import * as control from "../device/controller"; import { t, tp } from "../i18n"; import type { InterfaceLocale } from "../interface-preferences"; import type { AnalogTuning, ControlSnapshot } from "../device/types"; +import { BunnyHop } from "./cards/PerformanceCards"; function SuperstrikeSteps({ id, @@ -179,13 +180,9 @@ export function Superstrike({ snapshot }: { snapshot: ControlSnapshot }): ReactN const showApply = !snapshot.preferences.instantFlash || snapshot.gameProfileDraft; return ( -
+ // A card in the Buttons tab's card list, so the mouse panel sits beside it. + // The id scopes this card's styles. +

HITS Tuning

@@ -240,7 +237,8 @@ export function Superstrike({ snapshot }: { snapshot: ControlSnapshot }): ReactN ) : null}
+ -
+ ); } diff --git a/src/app/cards/PerformanceCards.tsx b/src/app/cards/PerformanceCards.tsx index f6163020..1ab3fd3c 100644 --- a/src/app/cards/PerformanceCards.tsx +++ b/src/app/cards/PerformanceCards.tsx @@ -327,7 +327,7 @@ function LiftOffScale({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { ); } -function BunnyHop({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { +export function BunnyHop({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const entry = snapshot.profile.entry; if (!snapshot.profile.bunnyHopSupported || !entry) return null; const locale = snapshot.preferences.locale; From 4c6106e6f4927bd9c2e8a5fd8e7b829d03ed7ad2 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:10:40 +0200 Subject: [PATCH 04/59] HITS card: keep its own card chrome beside the mouse panel Inside the card list the HITS card lost its border and background, because the list is one merged panel whose cards are flattened into dividers, so it no longer looked like the card it was. Render it first in the right-hand column as its own card, above the merged panel, with the mouse panel still beside it. --- src/app/OverviewPage.tsx | 14 +++++++++----- src/control.css | 6 ++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index fdc13998..5c466a36 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -542,8 +542,6 @@ export function Workspace({ const powerOverview = status.ui?.powerOverview === true; const performance = [ - // First in the Buttons tab, in the card list so the mouse panel stays beside it. - show(has.superstrike, ["buttons"]) ? : null, show(has.dpi, ["performance"]) ? : null, show(has.polling, ["performance"]) ? : null, show(has.sensor, ["performance"]) ? : null, @@ -643,7 +641,7 @@ export function Workspace({ {showTeevolutionProfiles ? : null} {showNapeLayers ? : null} - {performance.length > 0 ? ( + {performance.length > 0 || showSuperstrike ? (
) : null} -
- {performance} +
+ {/* The HITS card keeps its own card chrome, first, beside the mouse panel. */} + {showSuperstrike ? : null} + {performance.length > 0 ? ( +
+ {performance} +
+ ) : null}
) : null} diff --git a/src/control.css b/src/control.css index 8e4bbcfb..39fb4f1d 100644 --- a/src/control.css +++ b/src/control.css @@ -1390,6 +1390,12 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } width: 100%; margin-top: 0; } +.performance-main { + display: grid; + gap: 1rem; + align-content: start; + min-width: 0; +} .performance-sidebar { position: sticky; top: .75rem; From 61db3749907a1cde8487eee89261dd92dc00128b Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:18:30 +0200 Subject: [PATCH 05/59] Show battery time left in the device status pill, with a rated-life fallback The battery time estimate was computed but nothing has rendered it since the 2026-09-08 UI rewrite (batteryDetail has no caller). Show it in the status pill after the battery percent, on the tabs that carry the device panel, with the full wording on hover. The history-based estimate still needs 10 minutes and a 1% change, so until then a mouse with a known rated life (PRO X 2 Superstrike 90 h, PRO X 3 Superstrike 135 h) shows how long the charge would last at that rate, labelled as rated. Discharging only; a charging time is not guessed. Adds the label in all 11 locales and tests for the rated figures. --- src/app/OverviewPage.tsx | 4 ++++ src/battery-history.test.ts | 17 ++++++++++++++++- src/battery-history.ts | 20 ++++++++++++++++++++ src/device/controller.ts | 31 +++++++++++++++++++++++++++---- src/i18n-ar.ts | 1 + src/i18n-de.ts | 1 + src/i18n-es.ts | 1 + src/i18n-fr.ts | 1 + src/i18n-ja.ts | 1 + src/i18n-ko.ts | 1 + src/i18n-pt.ts | 1 + src/i18n-ru.ts | 1 + src/i18n-vi.ts | 1 + src/i18n-zh.ts | 1 + src/i18n.ts | 1 + 15 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index deff4471..5072c401 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -336,6 +336,7 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot const showButtonMarkers = snapshot.workspaceTab === "buttons" && isNoirKsnakeStatus(status) && status.buttonMappings != null; + const batteryEstimate = control.batteryEstimateParts(status, locale); return (
@@ -410,6 +411,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot {" · "} {status.batteryPercent}% + {batteryEstimate ? ( + {" · "}{batteryEstimate.time} + ) : null} ) : null}
diff --git a/src/battery-history.test.ts b/src/battery-history.test.ts index 06c816d0..bae83c95 100644 --- a/src/battery-history.test.ts +++ b/src/battery-history.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cachedBatterySamples, estimateBatteryTime, recordBatterySample, saveBatterySample } from "./battery-history.ts"; +import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBatteryHours, recordBatterySample, saveBatterySample } from "./battery-history.ts"; class MemoryStorage implements Storage { #values = new Map(); @@ -55,3 +55,18 @@ test("cached battery samples load once from storage on cold start", () => { assert.equal(samples.length, 1); assert.equal(samples[0]?.percent, 75); }); + +test("rated battery life is known for the Superstrike mice, whatever the spacing of the name", () => { + assert.equal(ratedBatteryHours("PRO X3 SUPERSTRIKE"), 135); + assert.equal(ratedBatteryHours("PRO X 2 Superstrike"), 90); + assert.equal(ratedBatteryHours("PRO X2 SUPERSTRIKE"), 90); + assert.equal(ratedBatteryHours("Some other mouse"), null); +}); + +test("the rated-life estimate scales with the charge and uses the usual format", () => { + assert.equal(estimateFromRatedLife(100, 135), "~5.6 days"); + assert.equal(estimateFromRatedLife(51, 135), "~2.9 days"); + assert.equal(estimateFromRatedLife(10, 90), "~9.0 hr"); + assert.equal(estimateFromRatedLife(0, 135), null, "an empty battery has no time left to show"); + assert.equal(estimateFromRatedLife(50, 0), null); +}); diff --git a/src/battery-history.ts b/src/battery-history.ts index 502e6807..4c2f8f09 100644 --- a/src/battery-history.ts +++ b/src/battery-history.ts @@ -137,3 +137,23 @@ export function estimateBatteryTime( if (remainingPercent <= 0) return null; return formatEstimate(remainingPercent / (change / elapsed)); } + +/** + * Manufacturer battery life, in hours, for mice that report a percent and + * nothing else. A rough stand-in until real usage history gives a better figure. + * Keyed by the device name without spaces, upper-cased. + */ +const RATED_BATTERY_HOURS: Record = { + PROX2SUPERSTRIKE: 90, + PROX3SUPERSTRIKE: 135, +}; + +export function ratedBatteryHours(deviceName: string): number | null { + return RATED_BATTERY_HOURS[deviceName.replace(/\s+/g, "").toUpperCase()] ?? null; +} + +/** Time left if the battery drains at the rated rate, in the same format as estimateBatteryTime. */ +export function estimateFromRatedLife(percent: number, ratedHours: number): string | null { + if (!(percent > 0) || !(ratedHours > 0)) return null; + return formatEstimate((percent / 100) * ratedHours * 60 * 60 * 1000); +} diff --git a/src/device/controller.ts b/src/device/controller.ts index 1ae066c1..f6f1f0ac 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1,4 +1,4 @@ -import { cachedBatterySamples, estimateBatteryTime, recordBatterySample, type BatteryMode } from "../battery-history"; +import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBatteryHours, recordBatterySample, type BatteryMode } from "../battery-history"; import { applyBridgeNativeSettings } from "../bridge"; import { clientSupportScore, @@ -1998,12 +1998,35 @@ export function batteryDetail(status: MouseStatus, locale: InterfaceLocale = "en if (status.batteryState === "Full") return withVoltage(t(locale, "bat.full")); const mode = batteryMode(status.batteryState); if (!mode) return withVoltage(batteryStateText(locale, status.batteryState)); + const state = batteryStateText(locale, status.batteryState); + const estimate = batteryEstimateText(status, locale); + return withVoltage(estimate ? `${state} · ${estimate}` : state); +} + +/** + * How long the battery should last or take to fill, from this device's own + * history once there is enough of it. Until then a mouse with a known rated + * life shows how long the charge would last at that rate. + */ +export function batteryEstimateParts( + status: MouseStatus, + locale: InterfaceLocale = "en", +): { time: string; label: string } | null { + if (status.batteryPercent === null || status.batteryState === "Full") return null; + const mode = batteryMode(status.batteryState); + if (!mode) return null; const now = Date.now(); const samples = cachedBatterySamples(localStorage, status.name, now); const estimate = estimateBatteryTime(samples, status.batteryPercent, mode, now); - const label = mode === "charging" ? t(locale, "bat.untilFull") : t(locale, "bat.remaining"); - const state = batteryStateText(locale, status.batteryState); - return withVoltage(estimate ? `${state} · ${estimate} ${label}` : state); + if (estimate) return { time: estimate, label: mode === "charging" ? t(locale, "bat.untilFull") : t(locale, "bat.remaining") }; + const ratedHours = mode === "discharging" ? ratedBatteryHours(status.name) : null; + const rated = ratedHours ? estimateFromRatedLife(status.batteryPercent, ratedHours) : null; + return rated ? { time: rated, label: t(locale, "bat.remainingRated") } : null; +} + +export function batteryEstimateText(status: MouseStatus, locale: InterfaceLocale = "en"): string | null { + const parts = batteryEstimateParts(status, locale); + return parts ? `${parts.time} ${parts.label}` : null; } function diagnosticErrorMessage(error: unknown, fallback: string): string { diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts index bf5a16d7..d527b1d6 100644 --- a/src/i18n-ar.ts +++ b/src/i18n-ar.ts @@ -202,6 +202,7 @@ export const ar: Record = { "bat.full": "مشحون بالكامل", "bat.untilFull": "حتى الامتلاء", "bat.remaining": "متبقٍ", + "bat.remainingRated": "متبقٍ (حسب العمر المقدّر)", "bat.charging": "جارٍ الشحن", "bat.chargingSlowly": "شحن بطيء", "bat.almostFull": "شبه ممتلئ", diff --git a/src/i18n-de.ts b/src/i18n-de.ts index 8459b32c..e7076d7b 100644 --- a/src/i18n-de.ts +++ b/src/i18n-de.ts @@ -203,6 +203,7 @@ export const de: Record = { "bat.full": "Vollständig geladen", "bat.untilFull": "bis vollständig geladen", "bat.remaining": "verbleibend", + "bat.remainingRated": "verbleibend (laut Herstellerangabe)", "bat.charging": "Lädt", "bat.chargingSlowly": "Lädt langsam", "bat.almostFull": "Fast voll", diff --git a/src/i18n-es.ts b/src/i18n-es.ts index ed428c88..b27398c7 100644 --- a/src/i18n-es.ts +++ b/src/i18n-es.ts @@ -203,6 +203,7 @@ export const es: Record = { "bat.full": "Totalmente cargada", "bat.untilFull": "para completar la carga", "bat.remaining": "restante", + "bat.remainingRated": "restante (según la autonomía indicada)", "bat.charging": "Cargando", "bat.chargingSlowly": "Cargando lentamente", "bat.almostFull": "Casi llena", diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts index 4a3527a7..71713829 100644 --- a/src/i18n-fr.ts +++ b/src/i18n-fr.ts @@ -203,6 +203,7 @@ export const fr: Record = { "bat.full": "Complètement chargée", "bat.untilFull": "avant charge complète", "bat.remaining": "restant", + "bat.remainingRated": "restant (selon l'autonomie annoncée)", "bat.charging": "En charge", "bat.chargingSlowly": "Charge lente", "bat.almostFull": "Presque pleine", diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts index 17d66f45..c86d6770 100644 --- a/src/i18n-ja.ts +++ b/src/i18n-ja.ts @@ -203,6 +203,7 @@ export const ja: Record = { "bat.full": "満充電", "bat.untilFull": "満充電まで", "bat.remaining": "残り", + "bat.remainingRated": "残り(公称値)", "bat.charging": "充電中", "bat.chargingSlowly": "低速充電中", "bat.almostFull": "ほぼ満充電", diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts index 59c0535c..2e6ffdfa 100644 --- a/src/i18n-ko.ts +++ b/src/i18n-ko.ts @@ -203,6 +203,7 @@ export const ko: Record = { "bat.full": "완전 충전됨", "bat.untilFull": "완전 충전까지", "bat.remaining": "남음", + "bat.remainingRated": "남음 (공식 사용 시간 기준)", "bat.charging": "충전 중", "bat.chargingSlowly": "저속 충전 중", "bat.almostFull": "거의 충전됨", diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts index 88316988..e7610265 100644 --- a/src/i18n-pt.ts +++ b/src/i18n-pt.ts @@ -203,6 +203,7 @@ export const pt: Record = { "bat.full": "Totalmente carregada", "bat.untilFull": "até carga total", "bat.remaining": "restante", + "bat.remainingRated": "restante (pela autonomia indicada)", "bat.charging": "Carregando", "bat.chargingSlowly": "Carregando lentamente", "bat.almostFull": "Quase cheia", diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts index c241690d..2bb31135 100644 --- a/src/i18n-ru.ts +++ b/src/i18n-ru.ts @@ -202,6 +202,7 @@ export const ru: Record = { "bat.full": "Заряжена полностью", "bat.untilFull": "до полного заряда", "bat.remaining": "осталось", + "bat.remainingRated": "осталось (по заявленной автономности)", "bat.charging": "Заряжается", "bat.chargingSlowly": "Заряжается медленно", "bat.almostFull": "Почти заряжена", diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts index 647c6d13..6308bdda 100644 --- a/src/i18n-vi.ts +++ b/src/i18n-vi.ts @@ -153,6 +153,7 @@ export const vi: Record = { "bat.full": "Đã sạc đầy", "bat.untilFull": "đến khi đầy", "bat.remaining": "còn lại", + "bat.remainingRated": "còn lại (theo thời lượng pin công bố)", "bat.charging": "Đang sạc", "bat.chargingSlowly": "Đang sạc chậm", "bat.almostFull": "Gần đầy", diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts index 151373a7..fb8dc4ed 100644 --- a/src/i18n-zh.ts +++ b/src/i18n-zh.ts @@ -203,6 +203,7 @@ export const zh: Record = { "bat.full": "已充满", "bat.untilFull": "至充满", "bat.remaining": "剩余", + "bat.remainingRated": "剩余(按标称续航)", "bat.charging": "充电中", "bat.chargingSlowly": "缓慢充电中", "bat.almostFull": "接近充满", diff --git a/src/i18n.ts b/src/i18n.ts index 9c538d5f..7bba411f 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -207,6 +207,7 @@ const en = { "bat.full": "Fully charged", "bat.untilFull": "until full", "bat.remaining": "remaining", + "bat.remainingRated": "remaining (rated life)", "bat.charging": "Charging", "bat.chargingSlowly": "Charging slowly", "bat.almostFull": "Almost full", From 43eaacee4aeb3de4dd0b66382c37a123d60df842 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:33:33 +0200 Subject: [PATCH 06/59] Battery estimate: follow the polling rate on the PRO X 3 The rated 135 h is for one polling rate, but the mouse draws far more at 8000 Hz than at 1000 Hz. Use the system and signal draw per rate, as G HUB shows them for the PRO X 3 (e.g. 4 + 4 mW at 1000 Hz, 12 + 20 mW at 8000 Hz), to scale the rated figure to the rate in use. The 135 h quote is taken to be at 1000 Hz. These figures are not read from the mouse: a capture of G HUB stepping through every rate shows only the set-rate command and no power values, so G HUB has them built in. Models without a table keep the plain rated hours. --- src/battery-history.test.ts | 35 +++++++++++------ src/battery-history.ts | 76 +++++++++++++++++++++++++++++++------ src/device/controller.ts | 6 +-- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/battery-history.test.ts b/src/battery-history.test.ts index bae83c95..4ff2cd2d 100644 --- a/src/battery-history.test.ts +++ b/src/battery-history.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBatteryHours, recordBatterySample, saveBatterySample } from "./battery-history.ts"; +import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBattery, ratedFullChargeHours, recordBatterySample, saveBatterySample } from "./battery-history.ts"; class MemoryStorage implements Storage { #values = new Map(); @@ -57,16 +57,29 @@ test("cached battery samples load once from storage on cold start", () => { }); test("rated battery life is known for the Superstrike mice, whatever the spacing of the name", () => { - assert.equal(ratedBatteryHours("PRO X3 SUPERSTRIKE"), 135); - assert.equal(ratedBatteryHours("PRO X 2 Superstrike"), 90); - assert.equal(ratedBatteryHours("PRO X2 SUPERSTRIKE"), 90); - assert.equal(ratedBatteryHours("Some other mouse"), null); + assert.equal(ratedBattery("PRO X3 SUPERSTRIKE")?.hours, 135); + assert.equal(ratedBattery("PRO X 2 Superstrike")?.hours, 90); + assert.equal(ratedBattery("PRO X2 SUPERSTRIKE")?.hours, 90); + assert.equal(ratedBattery("Some other mouse"), null); }); -test("the rated-life estimate scales with the charge and uses the usual format", () => { - assert.equal(estimateFromRatedLife(100, 135), "~5.6 days"); - assert.equal(estimateFromRatedLife(51, 135), "~2.9 days"); - assert.equal(estimateFromRatedLife(10, 90), "~9.0 hr"); - assert.equal(estimateFromRatedLife(0, 135), null, "an empty battery has no time left to show"); - assert.equal(estimateFromRatedLife(50, 0), null); +test("the PRO X 3's full-charge hours follow the polling rate by its power draw", () => { + const x3 = ratedBattery("PRO X3 SUPERSTRIKE")!; + // 135 h at 1000 Hz (4 + 4 mW) is 1080 mWh; 8000 Hz draws 12 + 20 = 32 mW. + assert.equal(ratedFullChargeHours(x3, 1000), 135); + assert.equal(ratedFullChargeHours(x3, 8000), 33.75); + assert.equal(ratedFullChargeHours(x3, 125), 216, "125 Hz draws 4 + 1 = 5 mW"); + assert.equal(ratedFullChargeHours(x3, 3000), 135, "an unlisted rate falls back to the rated hours"); + assert.equal(ratedFullChargeHours(x3, null), 135); + // The PRO X 2 has no power table, so the rate changes nothing. + assert.equal(ratedFullChargeHours(ratedBattery("PRO X2 SUPERSTRIKE")!, 8000), 90); +}); + +test("the rated-life estimate scales with the charge and the rate, in the usual format", () => { + const x3 = ratedBattery("PRO X3 SUPERSTRIKE")!; + assert.equal(estimateFromRatedLife(100, x3, 1000), "~5.6 days"); + assert.equal(estimateFromRatedLife(51, x3, 1000), "~2.9 days"); + assert.equal(estimateFromRatedLife(51, x3, 8000), "~17 hr", "51% of 33.75 h"); + assert.equal(estimateFromRatedLife(10, ratedBattery("PRO X2 SUPERSTRIKE")!, 1000), "~9.0 hr"); + assert.equal(estimateFromRatedLife(0, x3, 1000), null, "an empty battery has no time left to show"); }); diff --git a/src/battery-history.ts b/src/battery-history.ts index 4c2f8f09..51be517c 100644 --- a/src/battery-history.ts +++ b/src/battery-history.ts @@ -138,22 +138,74 @@ export function estimateBatteryTime( return formatEstimate(remainingPercent / (change / elapsed)); } +/** Power draw in mW at one polling rate: the sensor and electronics, and the radio. */ +interface RatePower { + system: number; + signal: number; +} + +interface RatedBattery { + /** The manufacturer's quoted battery life. */ + hours: number; + /** The polling rate that quote is for. Only needed with powerByRateHz. */ + ratedRateHz?: number; + /** Draw per polling rate, as G HUB shows it. Lets the estimate follow the rate in use. */ + powerByRateHz?: Record; +} + /** - * Manufacturer battery life, in hours, for mice that report a percent and - * nothing else. A rough stand-in until real usage history gives a better figure. - * Keyed by the device name without spaces, upper-cased. + * Manufacturer battery life for mice that report a percent and nothing else. A + * rough stand-in until real usage history gives a better figure. Keyed by the + * device name without spaces, upper-cased. + * + * The PRO X 3 draw figures are what G HUB displays. They are not read from the + * mouse: a capture of G HUB stepping through every rate shows only the set-rate + * command and no power values, so G HUB has them built in. The 135 h quote is + * taken to be at 1000 Hz, which makes the cell about 1080 mWh (8 mW for 135 h). */ -const RATED_BATTERY_HOURS: Record = { - PROX2SUPERSTRIKE: 90, - PROX3SUPERSTRIKE: 135, +const RATED_BATTERY: Record = { + PROX2SUPERSTRIKE: { hours: 90 }, + PROX3SUPERSTRIKE: { + hours: 135, + ratedRateHz: 1000, + powerByRateHz: { + 125: { system: 4, signal: 1 }, + 250: { system: 4, signal: 1 }, + 500: { system: 4, signal: 2 }, + 1000: { system: 4, signal: 4 }, + 2000: { system: 5, signal: 9 }, + 4000: { system: 7, signal: 10 }, + 8000: { system: 12, signal: 20 }, + }, + }, }; -export function ratedBatteryHours(deviceName: string): number | null { - return RATED_BATTERY_HOURS[deviceName.replace(/\s+/g, "").toUpperCase()] ?? null; +export function ratedBattery(deviceName: string): RatedBattery | null { + return RATED_BATTERY[deviceName.replace(/\s+/g, "").toUpperCase()] ?? null; +} + +/** + * Hours a full charge lasts at the given polling rate: the rated hours scaled by + * how much more or less the mouse draws at that rate than at the rated one. + * Without a power table for the model, or the rate, it is just the rated hours. + */ +export function ratedFullChargeHours(battery: RatedBattery, pollingRateHz?: number | null): number { + const table = battery.powerByRateHz; + const at = (hz: number | null | undefined): number | null => { + const power = hz == null ? undefined : table?.[hz]; + return power ? power.system + power.signal : null; + }; + const rated = at(battery.ratedRateHz); + const current = at(pollingRateHz); + return rated && current ? (battery.hours * rated) / current : battery.hours; } -/** Time left if the battery drains at the rated rate, in the same format as estimateBatteryTime. */ -export function estimateFromRatedLife(percent: number, ratedHours: number): string | null { - if (!(percent > 0) || !(ratedHours > 0)) return null; - return formatEstimate((percent / 100) * ratedHours * 60 * 60 * 1000); +/** Time left on the rated figure, in the same format as estimateBatteryTime. */ +export function estimateFromRatedLife( + percent: number, + battery: RatedBattery, + pollingRateHz?: number | null, +): string | null { + if (!(percent > 0) || !(battery.hours > 0)) return null; + return formatEstimate((percent / 100) * ratedFullChargeHours(battery, pollingRateHz) * 60 * 60 * 1000); } diff --git a/src/device/controller.ts b/src/device/controller.ts index f6f1f0ac..9d2a6de3 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1,4 +1,4 @@ -import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBatteryHours, recordBatterySample, type BatteryMode } from "../battery-history"; +import { cachedBatterySamples, estimateBatteryTime, estimateFromRatedLife, ratedBattery, recordBatterySample, type BatteryMode } from "../battery-history"; import { applyBridgeNativeSettings } from "../bridge"; import { clientSupportScore, @@ -2019,8 +2019,8 @@ export function batteryEstimateParts( const samples = cachedBatterySamples(localStorage, status.name, now); const estimate = estimateBatteryTime(samples, status.batteryPercent, mode, now); if (estimate) return { time: estimate, label: mode === "charging" ? t(locale, "bat.untilFull") : t(locale, "bat.remaining") }; - const ratedHours = mode === "discharging" ? ratedBatteryHours(status.name) : null; - const rated = ratedHours ? estimateFromRatedLife(status.batteryPercent, ratedHours) : null; + const ratedLife = mode === "discharging" ? ratedBattery(status.name) : null; + const rated = ratedLife ? estimateFromRatedLife(status.batteryPercent, ratedLife, status.pollingRateHz) : null; return rated ? { time: rated, label: t(locale, "bat.remainingRated") } : null; } From d3c6fa9ef48586ca49019fe973406c0aa8cca73f Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:36:06 +0200 Subject: [PATCH 07/59] battery-history: the 135 h is Logitech's figure at 1000 Hz, not an assumption Comment only. The capacity (1080 mWh) follows from that quote and the 8 mW draw at 1000 Hz. --- src/battery-history.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/battery-history.ts b/src/battery-history.ts index 51be517c..a225c6c3 100644 --- a/src/battery-history.ts +++ b/src/battery-history.ts @@ -160,8 +160,8 @@ interface RatedBattery { * * The PRO X 3 draw figures are what G HUB displays. They are not read from the * mouse: a capture of G HUB stepping through every rate shows only the set-rate - * command and no power values, so G HUB has them built in. The 135 h quote is - * taken to be at 1000 Hz, which makes the cell about 1080 mWh (8 mW for 135 h). + * command and no power values, so G HUB has them built in. Logitech quotes 135 h + * at 1000 Hz, which makes the cell about 1080 mWh (8 mW for 135 h). */ const RATED_BATTERY: Record = { PROX2SUPERSTRIKE: { hours: 90 }, From 56a67ed0bb10a64c4b29b3f5d29f56d0dba443a5 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:37:43 +0200 Subject: [PATCH 08/59] Show battery time left in the Overview status pill too The Overview tab has its own status pill (connection, DPI, battery, rate, profile) separate from the device panel's. Add the time left after the battery percent there as well, with the full wording on hover. --- src/app/OverviewPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index 5072c401..c7a30c6c 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -137,6 +137,7 @@ function DeviceShowcase({ snapshot }: { if (!status) return null; const locale = snapshot.preferences.locale; const image = snapshot.deviceArtwork; + const batteryEstimate = control.batteryEstimateParts(status, locale); const [imageFailed, setImageFailed] = useState(false); const [artSize, setArtSize] = useState<{ w: number; h: number } | null>(null); @@ -208,6 +209,11 @@ function DeviceShowcase({ snapshot }: { {status.batteryPercent}% + {batteryEstimate ? ( + + {batteryEstimate.time} + + ) : null} ) : null} From a4df4c5cd520decbd7b71221f0a1614984543c57 Mon Sep 17 00:00:00 2001 From: Onyx Date: Thu, 8 Oct 2026 06:43:14 +0200 Subject: [PATCH 09/59] HITS presets: save setups in the browser and share them as a code A presets row in the HITS card: pick a saved setup to load it, Save the current values under a name, Delete one, Copy a short code for the current setup, or Import a code. A preset holds both buttons' actuation, rapid trigger level and on/off, and click haptics. Equal buttons load on the Both tab, unequal ones on Independent. With instant flash on a loaded preset is written at once, both buttons in one profile write; otherwise it waits for Apply. The code is HITS1- plus a base64url of the values and a checksum, so a mistyped or foreign code is rejected; the name is not in it. Values are checked against the mouse's own limits before loading. Presets live in this browser only, since the mouse has nowhere to store names. Not compatible with G HUB's HITS-Code, whose format is unknown. Labels are English, like the card's heading. --- src/app/Superstrike.tsx | 127 +++++++++++++++++++++++++++++++++++- src/control-devices.css | 5 ++ src/device/controller.ts | 23 +++++++ src/hits-presets.test.ts | 95 +++++++++++++++++++++++++++ src/hits-presets.ts | 137 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/hits-presets.test.ts create mode 100644 src/hits-presets.ts diff --git a/src/app/Superstrike.tsx b/src/app/Superstrike.tsx index b79067d1..a357b0ff 100644 --- a/src/app/Superstrike.tsx +++ b/src/app/Superstrike.tsx @@ -2,7 +2,18 @@ import { useEffect, useState, type ReactNode } from "react"; import * as control from "../device/controller"; import { t, tp } from "../i18n"; import type { InterfaceLocale } from "../interface-preferences"; -import type { AnalogTuning, ControlSnapshot } from "../device/types"; +import type { AnalogTuning, AnalogTuningState, ControlSnapshot } from "../device/types"; +import { + decodeHitsCode, + deleteHitsPreset, + encodeHitsCode, + loadHitsPresets, + presetFits, + saveHitsPreset, + type HitsButtonValues, + type HitsLimits, + type HitsPreset, +} from "../hits-presets"; import { BunnyHop } from "./cards/PerformanceCards"; function SuperstrikeSteps({ @@ -77,6 +88,119 @@ function PressMeter({ actuation }: { actuation: [number, number] }): ReactNode { ); } +// Saved setups for both buttons, in this browser, and a short code to share one. +function HitsPresets({ state, limits }: { state: AnalogTuningState; limits: HitsLimits }): ReactNode { + const [presets, setPresets] = useState(() => loadHitsPresets(localStorage)); + const [selected, setSelected] = useState(""); + const [note, setNote] = useState(""); + + const values = (tuning: AnalogTuning): HitsButtonValues => ({ + actuation: tuning.actuation, + rapidTrigger: tuning.rapidTrigger, + haptics: tuning.haptics, + rapidTriggerEnabled: tuning.rapidTriggerEnabled !== false, + }); + const current = (): Pick => state.mode === "both" + ? { left: values(state.both), right: values(state.both) } + : { left: values(state.left), right: values(state.right) }; + + const load = (preset: Pick, label: string): boolean => { + if (!presetFits(preset, limits)) { + setNote("That preset has values this mouse cannot do."); + return false; + } + control.loadAnalogPreset({ ...preset.left }, { ...preset.right }); + setNote(`Loaded ${label}.`); + return true; + }; + + const save = (preset: HitsPreset): void => { + const next = saveHitsPreset(localStorage, preset); + if (!next) { + setNote("Could not save that preset."); + return; + } + setPresets(next); + setSelected(preset.name.trim().slice(0, 40)); + }; + + return ( +
+ + + + + + {note ? {note} : null} +
+ ); +} + function TuningControls({ group, tuning, @@ -186,6 +310,7 @@ export function Superstrike({ snapshot }: { snapshot: ControlSnapshot }): ReactN

HITS Tuning

+
{(["both", "independent"] as const).map((mode) => ( + +
+

+ Delete “{name}”? It is removed from this browser and cannot be brought back, unless you copied its code. +

+
+ + +
+
+
+ + ); +} diff --git a/src/app/ImportHitsCodeDialog.tsx b/src/app/ImportHitsCodeDialog.tsx new file mode 100644 index 00000000..9f64cbde --- /dev/null +++ b/src/app/ImportHitsCodeDialog.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { t } from "../i18n"; +import type { InterfaceLocale } from "../interface-preferences"; +import { decodeHitsCode, presetFits, type HitsButtonValues, type HitsLimits, type HitsPreset } from "../hits-presets"; + +function describe(values: HitsButtonValues): string { + const rapid = values.rapidTriggerEnabled ? `rapid trigger ${values.rapidTrigger}` : "rapid trigger off"; + return `actuation ${values.actuation}, ${rapid}, haptics ${values.haptics}`; +} + +/** Paste a HITS code, see what it holds, and load it (and optionally save it under a name). */ +export function ImportHitsCodeDialog({ + open, + limits, + locale, + onClose, + onImport, +}: { + open: boolean; + limits: HitsLimits; + locale: InterfaceLocale; + onClose: () => void; + onImport: (preset: Pick, name: string) => void; +}): ReactNode { + const dialog = useRef(null); + const [text, setText] = useState(""); + const [name, setName] = useState(""); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + if (!open) { + setText(""); + setName(""); + } + }, [open]); + + const decoded = text.trim() ? decodeHitsCode(text) : null; + const fits = decoded !== null && presetFits(decoded, limits); + const problem = !text.trim() ? null + : !decoded ? "That is not a valid HITS code." + : !fits ? "That code has values this mouse cannot do." + : null; + + return ( + { if (event.target === dialog.current) onClose(); }} + > +
+
+
+

HITS Tuning

+

Import code

+
+ +
+
+