From e96c8f006627b6838e704bae1c1ada402de0a6a8 Mon Sep 17 00:00:00 2001 From: OpenMouse Bot Date: Fri, 30 Oct 2026 22:16:19 -0600 Subject: [PATCH] feat(arbiter): 10-byte user mode for the Polar 65+ so SuperTap works The Polar 65+ answers GetUserMode with ten bytes (eight-byte header [layoutIndex, reserved1, reserved2, mode, checkFlag, flag, keyIndex0, keyIndex1] plus two zero pad bytes) and expects ten-byte SetUserMode writes, matching the current app2 vendor bundle. The driver only spoke the original six-byte format, so decode returned null and SuperTap could never enable. Adds a per-model userModeBytes flag (default 6, 10 for 0xA009) used by decode, encode and the read-back comparison, plus tests built on the exact bytes captured from the board. --- docs/arbiter-support.md | 29 +++++++++++++++++-------- src/arbiter/index.ts | 41 ++++++++++++++++++++++++++++-------- src/arbiter/protocol.test.ts | 11 ++++++++++ src/drivers/arbiter/hid.ts | 10 +++++---- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/docs/arbiter-support.md b/docs/arbiter-support.md index f192294..5d0f84b 100644 --- a/docs/arbiter-support.md +++ b/docs/arbiter-support.md @@ -161,7 +161,12 @@ main layer with these keys replaced (matrix key, then the FN code): - `LShift` gives code 249, which the vendor labels **SuperTap**. `LAlt`, `RAlt` and `LWin` hold codes 242/242/243; 242 is labelled **Fn**. - `Esc` (224), `Enter` (148), `RShift` (202) and the arrows (204, 205, 206, - 207) hold reserved codes the vendor gives no label. + 207) hold reserved codes the vendor gives no label. A Polar 65+ owner + decoded them from the keyboard's onboard shortcuts: `Enter` changes RGB + mode, `RShift` switches between the 7 preset colors, `↑`/`↓` adjust + brightness, `←`/`→` adjust effect speed, `Esc` (hold 5 s) factory-resets, + and the `Windows` key (243) toggles game mode. They drive keyboard + functions locally, not settings OpenMouse writes. - **75 PRO (8 keys):** `F9`-`F12` give previous, play/pause, next and `Print`; `` ` `` gives `Insert`; `LShift` gives SuperTap (249); `RAlt` gives Fn (242); `Home` gives `End`. @@ -190,8 +195,8 @@ not say. | `GetKeyMappingPerKeys` | 29 | short | defined on the PRO classes but never called by the bundle; not implemented | | | `GetAllProfile` | 35 | long | `index\|total<<4` (total 10) | requested packet by packet until `index == total - 1`; payload not decoded | | `SetBoardProfile` | 38 | short | one byte, the profile index | data change flag | -| `GetUserMode` | 53 | short | none (`dataSize` 6) | 6 bytes, see SuperTap | -| `SetUserMode` | 52 | short | 6 bytes, see SuperTap | data change flag | +| `GetUserMode` | 53 | short | none (`dataSize` 6) | 6 bytes (original) or 10 bytes (current generation), see SuperTap | +| `SetUserMode` | 52 | short | 6 bytes (original) or 10 bytes (current generation), see SuperTap | data change flag | Keys in the travel and mapping tables follow the layout array `keyMaxtrix` (68 entries for the 65s, listed in the bundle as `_P`/`J4`), not HID usages. @@ -263,14 +268,20 @@ held, only the later press counts. This comes from Arbiter's announcement and press reviews, not from the firmware, so the exact resolution rule (for example what happens on release) is unconfirmed. -**What the wire carries.** `SetUserMode`/`GetUserMode` carry six bytes: +**What the wire carries.** The original generation carries six bytes: `[layoutIndex, mode, checkFlag, flag & 0x7F | alternative-bit, keyIndex0, -keyIndex1]`. The vendor app writes `mode` 1, `flag` 0 and `alternative` 0, takes -`layoutIndex` from the current layer, reads `checkFlag > 0` as enabled and +keyIndex1]`. The current generation (Polar 65+, app2 vendor app) carries an +eight-byte header `[layoutIndex, reserved1, reserved2, mode, checkFlag, +flag & 0x7F | alternative-bit, keyIndex0, keyIndex1]` plus two zero pad +bytes, i.e. ten on the wire; its get-reply is accepted with eight or more +bytes. Both generations write `mode` 1, `flag` 0 and `alternative` 0, take +`layoutIndex` from the current layer, read `checkFlag > 0` as enabled and `keyIndex0/1` as the two keys of the pair. In its UI you switch SuperTap on, click one of two key tiles and then a key on the keyboard drawing. Turning the switch saves the profile before sending the user mode. Only models with -`superTapSupport` expose it. +`superTapSupport` expose it. The driver sends each model its own size +(`userModeBytes` in the catalog); the Polar 65+ needs ten, everything else +stays on six until a capture proves otherwise. **`keyIndex` is a wire index, not a matrix position.** The vendor app finds each key's position in the main layer's layout array and sends that index; the `s2` @@ -339,8 +350,8 @@ Behavior worth knowing: matrix defaults are all `0`. It only carries a nonzero value if the keyboard itself reports one. The driver therefore writes `0` for a rebound key and leaves other keys' values untouched. -- What the FN layer's reserved codes (224, 148, 202, 204-207, 243) do, and how - the keyboard activates the layer; only the factory defaults are known. +- How the keyboard activates the FN layer; only the factory defaults are known + (a Polar 65+ owner has decoded what most reserved codes do — see above). - Which of the `s2` travel read and write sizes is right (see [Table sizes](#table-sizes)). - Wireless, Bluetooth and dongle transports. The bundle has per-transport diff --git a/src/arbiter/index.ts b/src/arbiter/index.ts index b8f7e49..9e042ea 100644 --- a/src/arbiter/index.ts +++ b/src/arbiter/index.ts @@ -82,6 +82,14 @@ export interface ArbiterProduct { layers: number; profiles: number; superTap: boolean; + /** + * User-mode wire size. The original generation carries six bytes + * `[layoutIndex, mode, checkFlag, flag, keyIndex0, keyIndex1]`; the + * current generation (Polar 65+, app2 vendor app) carries an eight-byte + * header `[layoutIndex, reserved1, reserved2, mode, checkFlag, flag, + * keyIndex0, keyIndex1]` plus two zero pad bytes, i.e. ten on the wire. + */ + userModeBytes: 6 | 10; /** False until the model is exercised through this package on hardware. */ verified: boolean; } @@ -89,8 +97,9 @@ export interface ArbiterProduct { const product = ( name: string, vendorId: number, productId: number, usagePage: number, protocol: ArbiterProtocol, travelKeys: number, bindingKeys: number, layers: number, profiles: number, superTap: boolean, + userModeBytes: 6 | 10 = 6, ): ArbiterProduct => ({ - name, vendorId, productId, usagePage, protocol, travelKeys, bindingKeys, layers, profiles, superTap, verified: false, + name, vendorId, productId, usagePage, protocol, travelKeys, bindingKeys, layers, profiles, superTap, userModeBytes, verified: false, }); /** @@ -121,7 +130,7 @@ export const ARBITER_PRODUCTS: readonly ArbiterProduct[] = [ // travel writes total 3 (29+29+10 = 68), key-mapping writes total 5 // (29x4+20 = 136 = 2 layers x 68), SetBoardProfile values 0-2, and // Get/SetUserMode traffic, matching the i2 (65 PRO) class. - product("Polar 65+", ARBITER_VENDOR_ID_ALT, 0xa009, 0xff02, "i2", 68, 68, 2, 3, true), + product("Polar 65+", ARBITER_VENDOR_ID_ALT, 0xa009, 0xff02, "i2", 68, 68, 2, 3, true, 10), product("Polar 75 PRO", ARBITER_VENDOR_ID_ALT, 0xa001, 0xff02, "u2", 82, 81, 2, 3, true), ]; @@ -448,25 +457,39 @@ export interface ArbiterUserMode { keyIndex: readonly [number, number]; } -export function decodeArbiterUserMode(reply: ArbiterReply): ArbiterUserMode | null { - if (reply.command !== ARBITER_COMMAND.getUserMode || reply.data.length !== ARBITER_USER_MODE_SIZE) return null; +export function decodeArbiterUserMode(reply: ArbiterReply, userModeBytes: 6 | 10 = 6): ArbiterUserMode | null { + if (reply.command !== ARBITER_COMMAND.getUserMode) return null; const data = reply.data; + if (userModeBytes === 10) { + // Current generation: the board answers ten bytes; the first eight are + // the header, the last two are zero padding. + if (data.length < 8) return null; + return { + layoutIndex: data[0]!, mode: data[3]!, checkFlag: data[4]!, + flag: data[5]! & 0x7f, alternative: data[5]! >> 7, keyIndex: [data[6]!, data[7]!], + }; + } + if (data.length !== ARBITER_USER_MODE_SIZE) return null; return { layoutIndex: data[0]!, mode: data[1]!, checkFlag: data[2]!, flag: data[3]! & 0x7f, alternative: data[3]! >> 7, keyIndex: [data[4]!, data[5]!], }; } -export function encodeArbiterUserModeWrite(mode: ArbiterUserMode): Uint8Array { - const bytes = [mode.layoutIndex, mode.mode, mode.checkFlag, mode.keyIndex[0], mode.keyIndex[1]]; +export function encodeArbiterUserModeWrite(mode: ArbiterUserMode, userModeBytes: 6 | 10 = 6): Uint8Array { + const bytes = userModeBytes === 10 + ? [mode.layoutIndex, 0, 0, mode.mode, mode.checkFlag, mode.keyIndex[0], mode.keyIndex[1]] + : [mode.layoutIndex, mode.mode, mode.checkFlag, mode.keyIndex[0], mode.keyIndex[1]]; for (const value of bytes) { if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError("Arbiter user-mode values are whole numbers from 0 to 255."); } if (!Number.isInteger(mode.flag) || mode.flag < 0 || mode.flag > 0x7f) throw new RangeError("Arbiter user-mode flag is 0-127."); if (mode.alternative !== 0 && mode.alternative !== 1) throw new RangeError("Arbiter user-mode alternative is 0 or 1."); - return encodeArbiterRequest(ARBITER_COMMAND.setUserMode, ARBITER_PACKET.short, [ - mode.layoutIndex, mode.mode, mode.checkFlag, mode.flag | (mode.alternative << 7), mode.keyIndex[0], mode.keyIndex[1], - ]); + const flag = mode.flag | (mode.alternative << 7); + const body = userModeBytes === 10 + ? [mode.layoutIndex, 0, 0, mode.mode, mode.checkFlag, flag, mode.keyIndex[0], mode.keyIndex[1], 0, 0] + : [mode.layoutIndex, mode.mode, mode.checkFlag, flag, mode.keyIndex[0], mode.keyIndex[1]]; + return encodeArbiterRequest(ARBITER_COMMAND.setUserMode, ARBITER_PACKET.short, body); } /** Firmware reply: byte 0 is the low and byte 7 the high version byte, shown as `V{high}{low}`. */ diff --git a/src/arbiter/protocol.test.ts b/src/arbiter/protocol.test.ts index ad7c561..2ece100 100644 --- a/src/arbiter/protocol.test.ts +++ b/src/arbiter/protocol.test.ts @@ -147,6 +147,15 @@ test("user mode splits the flag from the alternative bit", () => { assert.equal(decodeArbiterUserMode(decodeArbiterReply(reply(53, [1, 2]))!), null); assert.throws(() => encodeArbiterUserModeWrite({ ...decoded!, flag: 128 }), RangeError); assert.throws(() => encodeArbiterUserModeWrite({ ...decoded!, alternative: 2 as never }), RangeError); + // Current generation (Polar 65+, app2 vendor app): eight header bytes plus + // two zero pad bytes, as captured on real hardware. + const decoded10 = decodeArbiterUserMode(decodeArbiterReply(reply(53, [0, 0, 0, 1, 1, 0, 0x1f, 0x21, 0, 0]))!, 10); + assert.deepEqual(decoded10, { layoutIndex: 0, mode: 1, checkFlag: 1, flag: 0, alternative: 0, keyIndex: [0x1f, 0x21] }); + const frame10 = encodeArbiterUserModeWrite(decoded10!, 10); + assert.deepEqual([...frame10.slice(0, 5)], [0, 52, 0, 10, 0]); + assert.deepEqual([...frame10.slice(4, 14)], [0, 0, 0, 1, 1, 0, 0x1f, 0x21, 0, 0]); + assert.equal(decodeArbiterUserMode(decodeArbiterReply(reply(53, [0, 0, 0, 1, 1, 0]))!, 10), null); + assert.equal(decodeArbiterUserMode(decodeArbiterReply(reply(53, [0, 0, 0, 1, 1, 0, 0x1f, 0x21, 0, 0]))!), null); }); test("write acknowledgements read the flag and tolerate a short body", () => { @@ -165,6 +174,8 @@ test("the catalog matches the vendor bundle and is entirely unverified", () => { // 0xA009 lives on 0xFF02 only: that is the page the vendor app claims, // and the board's 0xFF01 collection answers nothing. assert.equal(arbiterProduct(0x371c, 0xa009, 0xff02)?.protocol, "i2"); + assert.equal(arbiterProduct(0x371c, 0xa009, 0xff02)?.userModeBytes, 10); + assert.deepEqual(ARBITER_PRODUCTS.map((entry) => entry.userModeBytes), [6, 6, 6, 6, 6, 6, 6, 10, 6]); assert.equal(arbiterProduct(0x371c, 0xa009, 0xff01), undefined); assert.deepEqual(ARBITER_PRODUCTS.map((entry) => [entry.name, entry.travelKeys, entry.bindingKeys, arbiterTravelPackets(entry), arbiterKeyMappingPackets(entry)]), [ ["Arbiter Polar 65", 68, 68, 3, 3], ["Arbiter Polar 65", 68, 68, 3, 3], ["Polar 65", 68, 68, 3, 3], ["Nimbus 65", 68, 68, 3, 3], diff --git a/src/drivers/arbiter/hid.ts b/src/drivers/arbiter/hid.ts index 291672f..ec647b8 100644 --- a/src/drivers/arbiter/hid.ts +++ b/src/drivers/arbiter/hid.ts @@ -336,7 +336,7 @@ export class ArbiterKeyboardHidClient { private async readUserModeValue(): Promise { const reply = await this.exchange(ARBITER_COMMAND.getUserMode, encodeArbiterShortRead(ARBITER_COMMAND.getUserMode)); - const mode = decodeArbiterUserMode(reply); + const mode = decodeArbiterUserMode(reply, this.requireProduct().userModeBytes); if (!mode) throw new Error("The Arbiter keyboard returned an unreadable user mode."); return mode; } @@ -345,14 +345,16 @@ export class ArbiterKeyboardHidClient { setUserMode(mode: ArbiterUserMode): Promise { return this.serialized(async () => { this.requireSuperTap(); - const frame = encodeArbiterUserModeWrite(mode); + const userModeBytes = this.requireProduct().userModeBytes; + const frame = encodeArbiterUserModeWrite(mode, userModeBytes); const ack = await this.exchange(ARBITER_COMMAND.setUserMode, frame); if (decodeArbiterWriteAck(ack, ARBITER_COMMAND.setUserMode) === null) { throw new Error("The Arbiter keyboard did not acknowledge the user mode."); } const confirmed = await this.readUserModeValue(); - const echoed = encodeArbiterUserModeWrite(confirmed); - if (frame.slice(4, 10).some((byte, i) => byte !== echoed[4 + i])) { + const echoed = encodeArbiterUserModeWrite(confirmed, userModeBytes); + const size = frame[3]!; + if (frame.slice(4, 4 + size).some((byte, i) => byte !== echoed[4 + i])) { throw new Error("The Arbiter keyboard did not retain the user mode."); } return confirmed;