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
1 change: 1 addition & 0 deletions package-lock.json

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

48 changes: 48 additions & 0 deletions src/ajazz/ak820.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Ajazz AK820 (wired, single-backlight variant) — protocol constants.
*
* Hardware identity (from config.xml in the official driver installer):
* Product: Ajazz AK820 有线单光版 (wired single-light edition)
* VID: 0x1A2C (China Resource Semico)
* PID: 0x9605
*
* Wire format (confirmed by Claude-previous static analysis of the installer):
* Transport: HID Feature Reports, 64-byte payload (report id 0x00 or 0x01
* depending on what the OS exposes; try both).
* Collection: vendor-specific, usage page 0xFF00 or 0xFF60 (both have been
* observed on similar China Resource Semico firmware).
*
* NOTE — this driver is currently READ-ONLY / identity-only (Phase 1).
* The exact opcode layout has not yet been captured from live traffic.
* readStatus() returns the board's name and marks settingsReady: false so
* the settings grid stays hidden. Extend once protocol capture is done.
*
* This lives next to the NJ07 / NJ08 mouse codec in `./index.ts`, which is a
* different device family on different VIDs (0xA8A4/0xA8A5). Exports here
* carry the `AK820` infix so the two families never collide.
*/

export const AJAZZ_AK820_VENDOR_ID = 0x1a2c;

/** The only PID we have confirmed. Add more as new variants are captured. */
export const AJAZZ_AK820_WIRED_PID = 0x9605;

export const AJAZZ_AK820_PRODUCT_IDS: ReadonlySet<number> = new Set([
AJAZZ_AK820_WIRED_PID,
]);

/**
* Usage pages observed on China Resource Semico HID keyboards.
* The driver tries both; accept whichever the OS exposes.
*/
export const AJAZZ_AK820_USAGE_PAGES: ReadonlySet<number> = new Set([0xff00, 0xff60]);

/** Human-readable name for a given product id. */
export function ajazzAk820ProductName(productId: number): string {
switch (productId) {
case AJAZZ_AK820_WIRED_PID:
return "Ajazz AK820 (Wired)";
default:
return `Ajazz (PID 0x${productId.toString(16).toUpperCase()})`;
}
}
116 changes: 116 additions & 0 deletions src/drivers/ajazz/ak820-hid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import test from "node:test";
import assert from "node:assert/strict";
import { AjazzAk820HidClient } from "./ak820-hid.ts";
import { AJAZZ_AK820_VENDOR_ID, AJAZZ_AK820_WIRED_PID } from "../../ajazz/ak820.ts";

// ── minimal HID stubs ──────────────────────────────────────────────────────

function report(reportId: number, byteLength = 16): HIDReportInfo {
return {
reportId,
items: [{ reportSize: 8, reportCount: byteLength }],
} as unknown as HIDReportInfo;
}

function collection(
usagePage: number,
usage: number,
options: { feature?: number[] } = {},
): HIDCollectionInfo {
return {
usagePage,
usage,
type: 1,
children: [],
featureReports: (options.feature ?? []).map((id) => report(id)),
inputReports: [],
outputReports: [],
} as unknown as HIDCollectionInfo;
}

function makeDevice(overrides: Partial<HIDDevice> = {}): HIDDevice {
return {
vendorId: AJAZZ_AK820_VENDOR_ID,
productId: AJAZZ_AK820_WIRED_PID,
productName: "Ajazz AK820",
collections: [collection(0xff00, 1, { feature: [0] })],
opened: false,
open: async () => {},
close: async () => {},
sendFeatureReport: async () => {},
receiveFeatureReport: async () => new DataView(new ArrayBuffer(64)),
...overrides,
} as unknown as HIDDevice;
}

// ── isSupported ─────────────────────────────────────────────────────────────

test("isSupported: accepts AK820 with 0xff00 vendor collection", () => {
const device = makeDevice();
assert.ok(AjazzAk820HidClient.isSupported(device));
});

test("isSupported: accepts AK820 with 0xff60 vendor collection", () => {
const device = makeDevice({
collections: [collection(0xff60, 1, { feature: [0] })],
});
assert.ok(AjazzAk820HidClient.isSupported(device));
});

test("isSupported: rejects wrong vendor id", () => {
const device = makeDevice({ vendorId: 0x046d });
assert.equal(AjazzAk820HidClient.isSupported(device), false);
});

test("isSupported: rejects unknown product id on correct VID", () => {
const device = makeDevice({ productId: 0x1234 });
assert.equal(AjazzAk820HidClient.isSupported(device), false);
});

test("isSupported: rejects device with no vendor-specific collection", () => {
const device = makeDevice({
collections: [collection(0x0001, 2)], // Generic Desktop Mouse — not vendor
});
assert.equal(AjazzAk820HidClient.isSupported(device), false);
});

test("isSupported: accepts vendor collection nested inside a parent", () => {
const parent = {
...collection(0x0001, 6),
children: [collection(0xff00, 1, { feature: [0] })],
} as unknown as HIDCollectionInfo;
const device = makeDevice({ collections: [parent] });
assert.ok(AjazzAk820HidClient.isSupported(device));
});

// ── readStatus ──────────────────────────────────────────────────────────────

test("readStatus: returns correct brand and name", async () => {
const device = makeDevice();
const client = new AjazzAk820HidClient(device);
const status = await client.readStatus();
assert.equal(status.brand, "AJAZZ");
assert.equal(status.name, "Ajazz AK820 (Wired)");
});

test("readStatus: sets settingsReady false (phase 1 — no protocol yet)", async () => {
const device = makeDevice();
const client = new AjazzAk820HidClient(device);
const status = await client.readStatus();
assert.equal(status.ui?.settingsReady, false);
});

test("readStatus: connectionType is Wired", async () => {
const device = makeDevice();
const client = new AjazzAk820HidClient(device);
const status = await client.readStatus();
assert.equal(status.connectionType, "Wired");
});

// ── getDpiOptions ────────────────────────────────────────────────────────────

test("getDpiOptions: returns empty array (keyboard, no mouse DPI)", () => {
const device = makeDevice();
const client = new AjazzAk820HidClient(device);
assert.deepEqual(client.getDpiOptions(), []);
});
110 changes: 110 additions & 0 deletions src/drivers/ajazz/ak820-hid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Ajazz AK820 (wired, single-backlight) — Phase 1 driver.
*
* This is a read-only identity driver: it recognises the keyboard on connect
* and reports its name, but makes no protocol reads and changes nothing.
* The settings grid is hidden (`settingsReady: false`) because the AK820's
* key-remap / backlight protocol has not yet been captured.
*
* Architecture note — why a keyboard lives in OpenMouse:
* OpenMouse's shared `MouseStatus` type is mouse-centric, but non-mouse
* devices are explicitly supported by returning a status with
* `ui.settingsReady: false`, which hides the settings grid. The Wallhack
* K-001 keyboard driver (`drivers/wallhack/keyboard-hid.ts`) is the
* canonical prior art followed here.
*
* Extending this driver:
* 1. Capture live HID traffic with Wireshark / USBPcap or macOS Packet Logger
* while the official driver changes a backlight colour or remaps a key.
* 2. Identify the feature-report opcode and payload layout.
* 3. Add an `exchange()` helper (sendFeatureReport + receiveFeatureReport)
* and decode the response in `readStatus()`.
* 4. Set `settingsReady: true` and add setter methods.
* 5. Update `vendors.ts` and `registry.ts` with a usage filter once the
* exact usage page + report id are confirmed from captured traffic.
*/

import type { MouseStatus } from "../mouse-types.ts";
import {
AJAZZ_AK820_PRODUCT_IDS,
AJAZZ_AK820_USAGE_PAGES,
AJAZZ_AK820_VENDOR_ID,
ajazzAk820ProductName,
} from "../../ajazz/ak820.ts";

export class AjazzAk820HidClient {
readonly device: HIDDevice;

constructor(device: HIDDevice) {
this.device = device;
}

/**
* Match: correct VID + known PID + at least one vendor-specific collection
* on a usage page we recognise. The usage-page check keeps us from
* accidentally claiming a standard HID keyboard interface on the same device.
*/
static isSupported(device: HIDDevice): boolean {
if (device.vendorId !== AJAZZ_AK820_VENDOR_ID) return false;
if (!AJAZZ_AK820_PRODUCT_IDS.has(device.productId)) return false;
return AjazzAk820HidClient.hasVendorCollection(device.collections);
}

private static hasVendorCollection(
collections: readonly HIDCollectionInfo[],
): boolean {
for (const col of collections) {
if (AJAZZ_AK820_USAGE_PAGES.has(col.usagePage)) return true;
if (AjazzAk820HidClient.hasVendorCollection(col.children)) return true;
}
return false;
}

/** No DPI options — this is a keyboard. */
getDpiOptions(): number[] {
return [];
}

async open(): Promise<void> {
if (!this.device.opened) await this.device.open();
}

async close(): Promise<void> {
if (this.device.opened) await this.device.close();
}

/**
* Phase 1: identity only. No feature-report exchange is attempted because
* the exact opcode layout is not yet known. Once captured traffic reveals
* the command structure, replace this with a real read.
*/
async readStatus(): Promise<MouseStatus> {
await this.open();
const name = ajazzAk820ProductName(this.device.productId);
return {
brand: "AJAZZ",
name,
ui: {
family: "ajazz-ak820",
// Hide the settings grid until protocol capture is complete and
// real reads / writes are implemented.
settingsReady: false,
defaultDisplayName: name,
statusNote:
"Settings not yet available — protocol capture needed. " +
"See src/drivers/ajazz/ak820-hid.ts for how to extend this driver.",
},
batteryPercent: null,
batteryState: "Unknown",
// Placeholder mouse fields required by the shared status shape.
// The grid is hidden, so these are never displayed to the user.
dpi: 0,
pollingRateHz: 0,
activeProfile: null,
connectionType: "Wired",
connectionDetail: "USB",
liftOffDistance: null,
firmware: [name],
};
}
}
4 changes: 3 additions & 1 deletion src/drivers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ import { BytechHidClient } from "./bytech/hid.ts";
import { RapooHidClient } from "./rapoo/hid.ts";
import { CoolerMasterHidClient } from "./coolermaster/hid.ts";
import { AjazzHidClient } from "./ajazz/hid.ts";
import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts";

export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient;
export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient;
export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient;

export interface DeviceDriver {
brand: string;
Expand Down Expand Up @@ -194,6 +195,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [
// can be the whole matcher.
{ brand: "Rapoo", supports: (device) => RapooHidClient.isSupported(device), create: (device) => new RapooHidClient(device), score: () => 7 },
{ brand: "Cooler Master", supports: (device) => CoolerMasterHidClient.isSupported(device), create: (device) => new CoolerMasterHidClient(device), score: () => 7 },
{ brand: "AJAZZ", supports: (device) => AjazzAk820HidClient.isSupported(device), create: (device) => new AjazzAk820HidClient(device), score: () => 7 },
];

function driverFor(device: HIDDevice): DeviceDriver | undefined {
Expand Down
4 changes: 4 additions & 0 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,11 @@ import {
COOLERMASTER_USAGE_PAGE,
COOLERMASTER_VENDOR_ID,
} from "@openmouse/protocol/coolermaster";
import { AJAZZ_AK820_WIRED_PID } from "../ajazz/ak820.ts";

export const VENDOR_ID = {
coolermaster: COOLERMASTER_VENDOR_ID,
ajazz: 0x1a2c,
vaxee: VAXEE_VENDOR_ID,
asus: ASUS_VENDOR_ID,
ryunix: RYUNIX_VENDOR_ID,
Expand Down Expand Up @@ -921,4 +923,6 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [
...RYUNIX_HID_FILTERS,
...RAPOO_HID_FILTERS,
{ vendorId: VENDOR_ID.coolermaster, usagePage: COOLERMASTER_USAGE_PAGE, usage: COOLERMASTER_USAGE },
{ vendorId: VENDOR_ID.ajazz, productId: AJAZZ_AK820_WIRED_PID, usagePage: 0xff00 },
{ vendorId: VENDOR_ID.ajazz, productId: AJAZZ_AK820_WIRED_PID, usagePage: 0xff60 },
];
Loading