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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ checklist.
| Rapoo VT9 Pro | `@openmouse/protocol/rapoo` |
| Razer V4 | `@openmouse/protocol/razer-v4` |
| Ryunix | `@openmouse/protocol/ryunix` |
| Amkette XS series | `@openmouse/protocol/amkette` |
| SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` |
| Teevolution | `@openmouse/protocol/teevolution` |
| VGN | `@openmouse/protocol/vgn` |
Expand Down
273 changes: 273 additions & 0 deletions docs/amkette-xs-flow-testing.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@
"types": "./dist/ryunix/index.d.ts",
"import": "./dist/ryunix/index.js"
},
"./amkette": {
"types": "./dist/amkette/index.d.ts",
"import": "./dist/amkette/index.js"
},
"./rapoo": {
"types": "./dist/rapoo/index.d.ts",
"import": "./dist/rapoo/index.js"
Expand Down
1 change: 1 addition & 0 deletions src/amkette/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./xs.js";
52 changes: 52 additions & 0 deletions src/amkette/xs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
AMKETTE_XS_BLUETOOTH_PRODUCT_ID,
AMKETTE_XS_PRODUCT_IDS,
AMKETTE_XS_USAGE,
amketteBluetoothSlot,
amketteIsReceiverName,
amketteXsModel,
} from "./xs.ts";

test("the XS IDs add Bluetooth to the two shared product IDs, on usage 0x00", () => {
assert.equal(AMKETTE_XS_BLUETOOTH_PRODUCT_ID, 0x026d);
assert.deepEqual([...AMKETTE_XS_PRODUCT_IDS].sort(), [0x026d, 0x026e, 0x026f]);
assert.equal(AMKETTE_XS_USAGE, 0x00);
});

test("Amkette models are found only by the strings the firmware reports", () => {
assert.equal(amketteXsModel("XS Speed")?.name, "XS Flow 2 Speed");
assert.equal(amketteXsModel("XS Speed")?.lightingScheme, 1);
assert.equal(amketteXsModel("XS speed 2")?.name, "XS Flow 2 Speed", "the Bluetooth LE name");
assert.equal(amketteXsModel("XS Flow+")?.name, "XS Flow Plus");
assert.equal(amketteXsModel("XS Natural+")?.lightingScheme, 4);
// Retail names are not reported by the firmware, so they are not matched.
assert.equal(amketteXsModel("XS Flow 2 Speed"), null);
assert.equal(amketteXsModel("XS Natural Plus"), null);
assert.equal(amketteXsModel(""), null);
assert.equal(amketteXsModel(null), null);
assert.equal(amketteXsModel("Kyu Pro MX1"), null);
});

test("receiver strings are generic, model names are not", () => {
assert.equal(amketteIsReceiverName("2.4G Wireless Receiver"), true);
assert.equal(amketteIsReceiverName("USB Dongle"), true);
assert.equal(amketteIsReceiverName("XS Speed"), false);
assert.equal(amketteIsReceiverName(""), false);
assert.equal(amketteIsReceiverName(null), false);
});

test("the Bluetooth name carries the slot of the three the mouse holds", () => {
for (const slot of [1, 2, 3]) {
assert.equal(amketteXsModel(`XS speed ${slot}`)?.name, "XS Flow 2 Speed");
assert.equal(amketteBluetoothSlot(`XS speed ${slot}`), slot);
}
assert.equal(amketteBluetoothSlot("XS speed 4"), null);
assert.equal(amketteBluetoothSlot("XS speed"), null, "the USB label has no slot");
assert.equal(amketteBluetoothSlot("XS speed 22"), null);
assert.equal(amketteXsModel("XS speed 4"), null);
assert.equal(amketteBluetoothSlot("Kyu Pro MX1"), null);
assert.equal(amketteBluetoothSlot(null), null);
});
103 changes: 103 additions & 0 deletions src/amkette/xs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Amkette XS series on the 0x04F3 OEM firmware it shares with the Ryunix Kyu Pro
* MX1. The wire protocol, telemetry and command encoders are shared and live in
* `@openmouse/protocol/ryunix`; this module holds only what identifies the
* Amkette models: their IDs, collection usage, product strings and Bluetooth
* names. See `docs/amkette-xs-flow-testing.md` for the evidence.
*/

import { RYUNIX_WIRED_PRODUCT_ID, RYUNIX_WIRELESS_PRODUCT_ID } from "../ryunix/kyu-pro-mx1.ts";

/**
* Bluetooth LE product ID of the Amkette XS Flow 2 Speed. Amkette's
* configurator treats any ID other than 0x026E and 0x026F as Bluetooth.
*/
export const AMKETTE_XS_BLUETOOTH_PRODUCT_ID = 0x026d;
/**
* The Amkette XS firmware publishes the same configuration channel on usage
* 0x00 of page 0x0A, on all three paths, where the Kyu Pro MX1 contribution
* saw usage 0xC7. The usage keeps the two drivers apart.
*/
export const AMKETTE_XS_USAGE = 0x00;
export const AMKETTE_XS_PRODUCT_IDS: ReadonlySet<number> = new Set([
AMKETTE_XS_BLUETOOTH_PRODUCT_ID,
RYUNIX_WIRED_PRODUCT_ID,
RYUNIX_WIRELESS_PRODUCT_ID,
]);

export interface AmketteXsModel {
/** Retail name shown to the user. */
name: string;
/** The USB product string the model reports, as Amkette's configurator labels it. */
productStrings: readonly string[];
/**
* Base of the Bluetooth LE name; the mouse keeps three Bluetooth slots and
* appends the active slot, e.g. "XS speed 2". Null where not known.
*/
bluetoothLabel: string | null;
/** Lighting scheme in the vendor app (`RYUNIX_LIGHTING_SCHEMES` in `@openmouse/protocol/ryunix`). */
lightingScheme: number;
/** True once an owned unit has been seen reporting `productStrings`. */
verified: boolean;
}

/**
* Amkette XS models on this firmware. Each configurator names its device with a
* short label; an owned XS Flow 2 Speed reports that label ("XS Speed") as its
* USB product string on the cable. Over Bluetooth LE it advertises "XS speed" plus
* the active slot (the mouse holds three; "XS speed 2" was seen on slot 2, the
* other slots are as the owner described them). The other two models' labels are
* taken from their configurators and have not been seen on hardware.
*/
export const AMKETTE_XS_MODELS: readonly AmketteXsModel[] = [
{ name: "XS Flow 2 Speed", productStrings: ["XS Speed"], bluetoothLabel: "XS speed", lightingScheme: 1, verified: true },
{ name: "XS Flow Plus", productStrings: ["XS Flow+"], bluetoothLabel: null, lightingScheme: 3, verified: false },
{ name: "XS Natural Plus", productStrings: ["XS Natural+"], bluetoothLabel: null, lightingScheme: 4, verified: false },
];

function compactName(name: string): string {
return name.trim().toLowerCase().replace(/[\s_-]+/g, "");
}

/**
* The Amkette model a product string names: a USB label or a Bluetooth slot
* name. Retail names are not matched, since the firmware has not been seen to
* report them.
*/
export function amketteXsModel(productName: string | null | undefined): AmketteXsModel | null {
const compact = compactName(productName ?? "");
if (compact === "") return null;
return AMKETTE_XS_MODELS.find((model) =>
model.productStrings.some((label) => compact === compactName(label))
|| amketteBluetoothSlot(productName, model) !== null) ?? null;
}

/**
* Name shown when the firmware reports no model name. The driver is chosen by
* the usage-0x00 collection, which has only been seen on an Amkette mouse, so an
* unnamed device is shown as Amkette's XS series rather than a specific model.
*/
export const AMKETTE_XS_FALLBACK_NAME = "XS series";

/**
* True for the generic strings the 2.4 GHz receiver reports ("2.4G Wireless
* Receiver"), which name the dongle rather than the mouse.
*/
export function amketteIsReceiverName(name: string | null | undefined): boolean {
return /receiver|dongle/i.test(name ?? "");
}

/** The Bluetooth slot (1-3) a name like "XS speed 2" advertises for a model, or null. */
export function amketteBluetoothSlot(
productName: string | null | undefined,
model?: AmketteXsModel,
): number | null {
const compact = compactName(productName ?? "");
for (const candidate of model ? [model] : AMKETTE_XS_MODELS) {
if (candidate.bluetoothLabel === null) continue;
const base = compactName(candidate.bluetoothLabel);
const slot = /^[123]$/.exec(compact.slice(base.length));
if (compact.startsWith(base) && slot) return Number(slot[0]);
}
return null;
}
Loading
Loading