diff --git a/package-lock.json b/package-lock.json
index 7ab1367d..0d53e21a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "2.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
- "@openmouse/protocol": "0.29.0",
+ "@openmouse/protocol": "0.30.0",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
@@ -481,9 +481,9 @@
}
},
"node_modules/@openmouse/protocol": {
- "version": "0.29.0",
- "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.29.0.tgz",
- "integrity": "sha512-0YcCdBZigRZlfJgVWxe9iGUQChilgUX/9J3lwHACRJ9goGQVw16IRW7DnSQ5R93NaMxVTMaOCIiloCGDbGR1Kw==",
+ "version": "0.30.0",
+ "resolved": "https://registry.npmjs.org/@openmouse/protocol/-/protocol-0.30.0.tgz",
+ "integrity": "sha512-gIRoMJF3y0qzXPut8sRTvg/4pmOP0EQG2DwtD4aHZL5iPT76IzDC0fI45902yFVTDRfpzrOo4VCCfP+XXMd2cA==",
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index 9d30af44..b70d791b 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@openmouse/protocol": "0.29.0",
+ "@openmouse/protocol": "0.30.0",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
diff --git a/public/devices/asus-rog-harpe-ace-aim-lab.png b/public/devices/asus-rog-harpe-ace-aim-lab.png
new file mode 100644
index 00000000..634f6c49
Binary files /dev/null and b/public/devices/asus-rog-harpe-ace-aim-lab.png differ
diff --git a/src/app/cards/DpiCard.tsx b/src/app/cards/DpiCard.tsx
index 31ebaf32..c3a9b9cb 100644
--- a/src/app/cards/DpiCard.tsx
+++ b/src/app/cards/DpiCard.tsx
@@ -16,6 +16,21 @@ const DEFAULT_LOD = 2;
type StageRow = { enabled: boolean; value: string; lod: number };
+function isUniform(stages: readonly number[] | undefined): boolean {
+ return stages !== undefined && stages.length > 1 && stages.every((value) => value === stages[0]);
+}
+
+/** Single on a fixed-count stage mouse puts one DPI in every stage, so the DPI button has nothing to cycle. */
+function singleFillsStageTable(snapshot: ControlSnapshot): boolean {
+ const status = snapshot.status;
+ const logitechSlots = snapshot.profile.slotsAvailable && snapshot.dpiSlotPlan !== null;
+ return !logitechSlots
+ && (status?.dpiStages?.length ?? 0) > 0
+ && status?.ui?.dpiStageEditor !== undefined
+ && status.ui.dpiStageEditor.countEditable !== true
+ && snapshot.capabilities?.dpiStagesWritable === true;
+}
+
/**
* One DPI editor for every device flavor. It always shows up to four rows,
* each with a tickbox (include the stage in the DPI cycle), a typed value, and
@@ -98,6 +113,32 @@ function DpiStageEditor({
}, []);
const fixedStageCount = mode === "stage" && stageEditor?.countEditable !== true;
+ const fillsStageTable = singleFillsStageTable(snapshot);
+ const singleFillsStages = mode === "generic" && fillsStageTable;
+
+ const stagesDifferFrom = (dpi: number): boolean => (status?.dpiStages ?? []).some((value) => value !== dpi);
+
+ const applySingleDpi = (dpi: number): void => {
+ if (!singleFillsStages) {
+ control.applyDpiValue(dpi);
+ return;
+ }
+ // Keep the last real table so the Stages view can put it back.
+ const deviceStages = snapshot.deviceStatus?.dpiStages;
+ if (deviceStages && !isUniform(deviceStages)) control.rememberDpiStagesBeforeSingle(deviceStages);
+ (status?.dpiStages ?? []).forEach((value, stage) => {
+ if (value !== dpi) control.applyDpiStageValue(stage, dpi);
+ });
+ };
+
+ const restoreStagesBeforeSingle = (): void => {
+ const backup = control.dpiStagesBeforeSingle();
+ const stages = status?.dpiStages;
+ if (!backup || !stages || backup.length !== stages.length || !isUniform(stages)) return;
+ backup.forEach((dpi, stage) => {
+ if (stages[stage] !== dpi) control.applyDpiStageValue(stage, dpi);
+ });
+ };
// A table the driver cannot write is inert: no value edits, no active-stage
// switch. Kept distinct from `locked` (settings-pending) because the note
// and the disabled reasons are different.
@@ -126,6 +167,8 @@ function DpiStageEditor({
if (mode === lastModeRef.current) return;
lastModeRef.current = mode;
setRows(initRows());
+ if (singleFillsStages && status?.dpi && stagesDifferFrom(status.dpi)) applySingleDpi(status.dpi);
+ if (mode === "stage" && fillsStageTable) restoreStagesBeforeSingle();
// initRows is recreated each render on purpose so it always reads fresh snapshot data.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
@@ -171,8 +214,8 @@ function DpiStageEditor({
const active = next.find((row) => row.enabled);
if (!active) return;
const snap = closestDpiOption(options, parseRow(active.value) ?? status?.dpi ?? 0);
- if (snap !== null && snap !== status?.dpi) {
- control.applyDpiValue(snap);
+ if (snap !== null && (singleFillsStages ? stagesDifferFrom(snap) : snap !== status?.dpi)) {
+ applySingleDpi(snap);
const next2 = next.map((row) => (row.enabled ? { ...row, value: String(snap) } : row));
setRows(next2);
rowsRef.current = next2;
@@ -195,7 +238,7 @@ function DpiStageEditor({
const selected = next.map((row, i) => (i === index ? { ...row, enabled: true } : { ...row, enabled: false }));
setRows(selected);
rowsRef.current = selected;
- control.applyDpiValue(snap);
+ applySingleDpi(snap);
touchedRef.current = Date.now();
return;
}
@@ -280,7 +323,9 @@ function DpiStageEditor({
? tp(locale, "dpi.editorFixedNote", { total: status?.dpiStages?.length ?? 0 })
: mode === "stage"
? t(locale, "dpi.editorCountNote")
- : t(locale, "dpi.editorGenericNote");
+ : singleFillsStages
+ ? tp(locale, "dpi.singleOverwritesNote", { total: status?.dpiStages?.length ?? 0 })
+ : t(locale, "dpi.editorGenericNote");
const enabledCount = rows.filter((row) => row.enabled).length;
@@ -358,7 +403,7 @@ function DpiStageEditor({
);
})}
- {note}
+ {note}
);
}
@@ -447,7 +492,10 @@ export function SlotLiftOffPanel({ snapshot }: { snapshot: ControlSnapshot }): R
}
export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
- const [editorView, setEditorView] = useState<"stage" | "single">("stage");
+ // A table with one value in every stage is what Single saved, so reopen in that view.
+ const [editorView, setEditorView] = useState<"stage" | "single">(() => (
+ singleFillsStageTable(snapshot) && isUniform(snapshot.deviceStatus?.dpiStages) ? "single" : "stage"
+ ));
const status = snapshot.status;
const deviceStatus = snapshot.deviceStatus;
const locale = snapshot.preferences.locale;
diff --git a/src/control.css b/src/control.css
index b97b7ab4..7b39e47b 100644
--- a/src/control.css
+++ b/src/control.css
@@ -892,6 +892,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
#polling-card .title-row h2, #host-lod-row .title-row h2 { font-size: 1.15rem; }
.setting-note { display: block; margin-top: 1rem; color: var(--text-soft); font-size: .72rem; line-height: 1.5; }
.setting-note-stable { min-height: 3em; }
+.setting-note-warning { color: var(--warning); }
.setting-action { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-top: .7rem; color: var(--text-soft); font-size: .7rem; font-weight: 500; }
.setting-action #dpi-pending { color: var(--text-strong); font-weight: 650; }
.setting-action button { padding: .4rem .65rem; border: 1px solid var(--text); border-radius: 6px; background: var(--text); color: var(--ink); font-size: .65rem; font-weight: 700; }
diff --git a/src/device/controller.ts b/src/device/controller.ts
index f8938399..f29f10af 100644
--- a/src/device/controller.ts
+++ b/src/device/controller.ts
@@ -2452,6 +2452,30 @@ function rememberActiveDevice(device: HIDDevice): void {
}
}
+const SINGLE_DPI_BACKUP_STORAGE_KEY = "openmouse.single-dpi-backup";
+
+function singleDpiBackups(): Record {
+ try {
+ return JSON.parse(localStorage.getItem(SINGLE_DPI_BACKUP_STORAGE_KEY) ?? "{}") as Record;
+ } catch {
+ return {};
+ }
+}
+
+/** The stage table the active mouse had before the Single DPI view gave every stage one value. */
+export function dpiStagesBeforeSingle(): number[] | null {
+ return activeDevice ? singleDpiBackups()[deviceStorageKey(activeDevice)] ?? null : null;
+}
+
+export function rememberDpiStagesBeforeSingle(stages: readonly number[]): void {
+ if (!activeDevice) return;
+ try {
+ const backups = { ...singleDpiBackups(), [deviceStorageKey(activeDevice)]: [...stages] };
+ localStorage.setItem(SINGLE_DPI_BACKUP_STORAGE_KEY, JSON.stringify(backups));
+ } catch {
+ }
+}
+
async function waitForControllerIdle(): Promise {
while (settingInProgress || refreshInProgress || activationInProgress) {
await new Promise((resolve) => window.setTimeout(resolve, 25));
diff --git a/src/i18n-ar.ts b/src/i18n-ar.ts
index b7b282ac..d369a573 100644
--- a/src/i18n-ar.ts
+++ b/src/i18n-ar.ts
@@ -322,6 +322,7 @@ export const ar: Record = {
"dpi.editorFixedNote": "صف واحد لكل مرحلة DPI، {total} ثابتة. عدّل القيمة أو المنزلق لتغييرها. انقر رقمًا لجعله المرحلة النشطة.",
"dpi.stagesReadOnly": "تأتي مراحل DPI هذه من الماوس للقراءة فقط، ولا يمكن تعديلها هنا. غيّرها في برنامج الشركة المصنّعة.",
"dpi.editorGenericNote": "صف واحد لكل DPI مسبق. حدّد صفًا (أو انقر رقمه) لجعله DPI الحالي؛ تبقى الصفوف غير المحددة كاختيارات لاحقًا.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "المحور X",
"dpi.yAxis": "المحور Y",
"dpi.viewStages": "مراحل",
diff --git a/src/i18n-de.ts b/src/i18n-de.ts
index 6fd5fa1a..a6747bca 100644
--- a/src/i18n-de.ts
+++ b/src/i18n-de.ts
@@ -323,6 +323,7 @@ export const de: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "X-Achse",
"dpi.yAxis": "Y-Achse",
"dpi.viewStages": "Stufen",
diff --git a/src/i18n-es.ts b/src/i18n-es.ts
index 141439ad..d4a72dc9 100644
--- a/src/i18n-es.ts
+++ b/src/i18n-es.ts
@@ -323,6 +323,7 @@ export const es: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "Eje X",
"dpi.yAxis": "Eje Y",
"dpi.viewStages": "Etapas",
diff --git a/src/i18n-fr.ts b/src/i18n-fr.ts
index ac150f22..5229cdd5 100644
--- a/src/i18n-fr.ts
+++ b/src/i18n-fr.ts
@@ -323,6 +323,7 @@ export const fr: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "Axe X",
"dpi.yAxis": "Axe Y",
"dpi.viewStages": "Étapes",
diff --git a/src/i18n-ja.ts b/src/i18n-ja.ts
index ff4d4478..ad2475d3 100644
--- a/src/i18n-ja.ts
+++ b/src/i18n-ja.ts
@@ -323,6 +323,7 @@ export const ja: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "X軸",
"dpi.yAxis": "Y軸",
"dpi.viewStages": "ステージ",
diff --git a/src/i18n-ko.ts b/src/i18n-ko.ts
index 9f8f4a6f..c1b9a338 100644
--- a/src/i18n-ko.ts
+++ b/src/i18n-ko.ts
@@ -323,6 +323,7 @@ export const ko: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "X축",
"dpi.yAxis": "Y축",
"dpi.viewStages": "단계",
diff --git a/src/i18n-pt.ts b/src/i18n-pt.ts
index 709db666..a2126152 100644
--- a/src/i18n-pt.ts
+++ b/src/i18n-pt.ts
@@ -323,6 +323,7 @@ export const pt: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "Eixo X",
"dpi.yAxis": "Eixo Y",
"dpi.viewStages": "Estágios",
diff --git a/src/i18n-ru.ts b/src/i18n-ru.ts
index 4a1641f6..8290d827 100644
--- a/src/i18n-ru.ts
+++ b/src/i18n-ru.ts
@@ -322,6 +322,7 @@ export const ru: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "Ось X",
"dpi.yAxis": "Ось Y",
"dpi.viewStages": "Ступени",
diff --git a/src/i18n-vi.ts b/src/i18n-vi.ts
index c7b9ace4..6a828c2b 100644
--- a/src/i18n-vi.ts
+++ b/src/i18n-vi.ts
@@ -271,6 +271,7 @@ export const vi: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "Trục X",
"dpi.yAxis": "Trục Y",
"dpi.viewStages": "Stage",
diff --git a/src/i18n-zh.ts b/src/i18n-zh.ts
index b5089f89..38e4c3b9 100644
--- a/src/i18n-zh.ts
+++ b/src/i18n-zh.ts
@@ -323,6 +323,7 @@ export const zh: Record = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "X 轴",
"dpi.yAxis": "Y 轴",
"dpi.viewStages": "阶段",
diff --git a/src/i18n.ts b/src/i18n.ts
index 4070f775..54963bf8 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -327,6 +327,7 @@ const en = {
"dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.",
"dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.",
"dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.",
+ "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.",
"dpi.xAxis": "X axis",
"dpi.yAxis": "Y axis",
"dpi.viewStages": "Stages",
diff --git a/src/ui/device-images.test.ts b/src/ui/device-images.test.ts
index 3607d8d3..64ad9957 100644
--- a/src/ui/device-images.test.ts
+++ b/src/ui/device-images.test.ts
@@ -107,6 +107,11 @@ test("Delux M800 Mini and M800 family resolve by name", () => {
assert.equal(deviceImage(null, "Delux M800 Pro"), "/devices/delux-m800-mini.png");
});
+test("ROG Harpe Ace Aim Lab Edition resolves by name on every connection", () => {
+ assert.equal(deviceImage(null, "ROG Harpe Ace Aim Lab Edition"), "/devices/asus-rog-harpe-ace-aim-lab.png");
+ assert.equal(deviceImage(null, "ROG Omni receiver"), CDN + "unknown-device.png");
+});
+
test("Attack Shark R2 resolves by name (PID 0x402D is shared with the M5 Pro)", () => {
// TEMPORARY: served from the repo override (see LOCAL_OVERRIDES in
// device-images.ts / public/devices/README.md) until a maintainer uploads
diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts
index 01431f92..b29245a2 100644
--- a/src/ui/device-images.ts
+++ b/src/ui/device-images.ts
@@ -68,6 +68,8 @@ function resolveDeviceImageFilename(_device: HIDDevice | null | undefined, displ
// don't match.
if (/\battack\s*shark\s*x3\b/i.test(displayName)) return "attackshark-x3.png";
if (/\bm[23]k\b/i.test(displayName)) return "zaunkoenig-m3k.png";
+ // Name, not PID: over the ROG Omni receiver the device is 0x1ACE.
+ if (/\bharpe\s*ace\s*aim\s*lab\b/i.test(displayName)) return "asus-rog-harpe-ace-aim-lab.png";
if (/\bmx\s*master\s*3s\b/i.test(displayName)) return "logitech-mx-master-3s.png";
if (/\bterra\s*pro\b/i.test(displayName)) return "teevolution-terra-pro.png";
if (/\bm-001\b/i.test(displayName)) return "wallhack-m-001.png";
@@ -205,6 +207,7 @@ const LOCAL_OVERRIDES: Readonly> = {
"attackshark-r2.png": "/devices/attackshark-r2.png",
"delux-m800-mini.png": "/devices/delux-m800-mini.png",
"ipi-float-88.png": "/devices/ipi-float-88.png",
+ "asus-rog-harpe-ace-aim-lab.png": "/devices/asus-rog-harpe-ace-aim-lab.png",
};
export function deviceImage(device: HIDDevice | null | undefined, displayName = ""): string {