diff --git a/package-lock.json b/package-lock.json index 9c7169d..5df5818 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@openmouse/protocol", "version": "0.1.0", + "license": "AGPL-3.0-or-later", "devDependencies": { "semantic-release": "25.0.9", "tsx": "^4.23.11", diff --git a/src/ajazz/ak820.ts b/src/ajazz/ak820.ts new file mode 100644 index 0000000..62ed045 --- /dev/null +++ b/src/ajazz/ak820.ts @@ -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 = 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 = 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()})`; + } +} diff --git a/src/drivers/ajazz/ak820-hid.test.ts b/src/drivers/ajazz/ak820-hid.test.ts new file mode 100644 index 0000000..bc346ef --- /dev/null +++ b/src/drivers/ajazz/ak820-hid.test.ts @@ -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 { + 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(), []); +}); diff --git a/src/drivers/ajazz/ak820-hid.ts b/src/drivers/ajazz/ak820-hid.ts new file mode 100644 index 0000000..ad5ba06 --- /dev/null +++ b/src/drivers/ajazz/ak820-hid.ts @@ -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 { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + 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 { + 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], + }; + } +} diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index bb0674f..2950874 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -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; @@ -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 { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 86e64fb..13ffe78 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -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, @@ -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 }, ];