From 1c7e0c83e5b88e98c872f8c6c47f4e24424144e5 Mon Sep 17 00:00:00 2001 From: Shubhadeep Date: Thu, 13 Aug 2026 17:48:10 +0530 Subject: [PATCH 1/7] feat(attackshark): add Attack Shark R5 Ultra support via the shared compax driver --- README.md | 2 +- src/compx/codec.ts | 2 +- src/drivers/lamzu/hid.test.ts | 99 ++++++++++++++++++++++++++++++++++- src/drivers/lamzu/hid.ts | 2 +- src/drivers/mouse-types.ts | 2 +- src/drivers/vendors.ts | 4 ++ src/lamzu/index.ts | 11 +++- 7 files changed, 116 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8a49bbe..8ff925b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ checklist. | Endgame Gear wireless | `@openmouse/protocol/endgame-gear-we` | | Finalmouse | `@openmouse/protocol/finalmouse` | | Keychron | `@openmouse/protocol/keychron` | -| Lamzu | `@openmouse/protocol/lamzu` | +| Lamzu / CRDRAKO / Attack Shark | `@openmouse/protocol/lamzu` | | Logitech | `@openmouse/protocol/logitech` | | moddoMOUSE | `@openmouse/protocol/moddo` | | Ninjutso | `@openmouse/protocol/ninjutso` | diff --git a/src/compx/codec.ts b/src/compx/codec.ts index 2e04f8d..7648446 100644 --- a/src/compx/codec.ts +++ b/src/compx/codec.ts @@ -2,7 +2,7 @@ export const COMPX_REPORT_ID = 0; export const COMPX_PACKET_LENGTH = 64; export const COMPX_HEADER_LENGTH = 6; -export const COMPX_STATUS = { request: 0x00, pending: 0xa0, ok: 0xa1, unsupported: 0xa2 } as const; +export const COMPX_STATUS = { request: 0x00, pending: 0xa0, ok: 0xa1, unsupported: 0xa2, busy: 0xa3 } as const; export type CompaxLiftOffDistance = "Low" | "Medium" | "High"; export interface CompaxDpiStage { x: number; y: number } export interface CompaxRequest { diff --git a/src/drivers/lamzu/hid.test.ts b/src/drivers/lamzu/hid.test.ts index 653452a..521e89b 100644 --- a/src/drivers/lamzu/hid.test.ts +++ b/src/drivers/lamzu/hid.test.ts @@ -3,7 +3,10 @@ import test from "node:test"; import { LamzuHidClient } from "./hid.ts"; import { deviceBrand } from "../registry.ts"; -import { LAMZU_VENDOR_ID } from "@openmouse/protocol/lamzu"; +import { + ATTACKSHARK_PRODUCT_IDS, + LAMZU_VENDOR_ID, +} from "@openmouse/protocol/lamzu"; const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; globals.window ??= { setTimeout }; @@ -84,3 +87,97 @@ test("CRDRAKO KO-ONE receiver addresses the mouse as target 0x02", async () => { assert.ok(mouseRequests.length > 0); assert.ok(mouseRequests.every((packet) => packet[2] === 0x02)); }); + +function fakeR5Ultra(productId: 0x0046 | 0x0047, busyBatteryReplies = 0) { + const sent: Uint8Array[] = []; + let batterySends = 0; + const device = { + vendorId: LAMZU_VENDOR_ID, + productId, + productName: "R5 Ultra Mouse 2.4G", + opened: true, + collections: [{ + usagePage: 0xffff, + usage: 0, + type: 1, + children: [], + featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], + inputReports: [], + outputReports: [], + }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => void sent.push(new Uint8Array(data)), + receiveFeatureReport: async () => { + const request = sent[sent.length - 1]!; + const page = request[4]!; + const command = request[5]!; + const reply = new Uint8Array(64); + if (page === 0x00 && command === 0x83) { + batterySends += 1; + if (batterySends <= busyBatteryReplies) { + reply[0] = 0xa3; + reply[4] = page; + reply[5] = command; + return new DataView(reply.buffer); + } + } + const payload = page === 0x01 && command === 0x81 + ? [0x01, 0x01, 0x06, 0x40, 0x06, 0x40] + : page === 0x01 && command === 0x80 + ? [0x01, 0x80] + : page === 0x00 && command === 0x83 + ? [0x00, 0x64] + : page === 0x00 && command === 0x81 + ? [0x00, 0x00, 0x01, 0x02] + : [0x01, 0x01]; + reply[0] = 0xa1; + reply[3] = payload.length; + reply[4] = page; + reply[5] = command; + reply.set(payload, 6); + return new DataView(reply.buffer); + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as HIDDevice; + return { device, sent, batterySends: () => batterySends }; +} + +test("the Attack Shark R5 Ultra wireless decodes through the shared driver", async () => { + const { device } = fakeR5Ultra(0x0047); + const client = new LamzuHidClient(device); + const status = await client.readStatus(); + + assert.equal(status.brand, "Attack Shark"); + assert.equal(status.name, "Attack Shark R5 Ultra"); + assert.equal(status.ui?.family, "attack-shark"); + assert.equal(deviceBrand(client), "Attack Shark"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz receiver"); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.dpi, 1600); + assert.equal(status.pollingRateHz, 8000); + assert.deepEqual(status.supportedPollingRates, [500, 1000, 2000, 4000, 8000]); + assert.deepEqual(status.firmware, ["Mouse 1.2", "Dongle 1.2"]); +}); + +test("the Attack Shark R5 Ultra wired decodes as a 1 kHz wired mouse", async () => { + const { device } = fakeR5Ultra(0x0046); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.brand, "Attack Shark"); + assert.equal(status.connectionType, "Wired"); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); +}); + +test("a busy status keeps retrying instead of failing", async () => { + const { device, batterySends } = fakeR5Ultra(0x0047, 2); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.batteryPercent, 100); + assert.ok(batterySends() > 2, `expected the battery request to be re-sent, saw ${batterySends()}`); +}); + +test("the catalog offers the wired and wireless R5 Ultra", () => { + assert.deepEqual([...ATTACKSHARK_PRODUCT_IDS], [0x0046, 0x0047]); +}); diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 2e0efa0..2e1c9ce 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -426,7 +426,7 @@ export class LamzuHidClient { const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH); return reply.slice(HEADER_LENGTH, HEADER_LENGTH + length); } - if (reply[0] !== STATUS.pending && reply[0] !== STATUS.ok) { + if (reply[0] !== STATUS.pending && reply[0] !== STATUS.busy && reply[0] !== STATUS.ok) { throw new Error(this.describe(spec, `returned an unexpected status 0x${reply[0].toString(16)}`)); } await this.delay(attempt < QUICK_ATTEMPTS ? RESPONSE_DELAY_MS : WAKE_DELAY_MS); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 3e7af8f..433c5b9 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -109,7 +109,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "Lamzu" | "CRDRAKO" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index e012766..26e2f43 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -23,6 +23,7 @@ export const VENDOR_ID = { endgameGear: 0x3367, wlmouse: 0x36a7, lamzu: 0x373e, + attackshark: 0x373e, logitech: 0x046d, orbital: 0x1915, razer: 0x1532, @@ -212,6 +213,9 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.pulsar }, { vendorId: VENDOR_ID.endgameGear }, { vendorId: VENDOR_ID.wlmouse }, + // 0x373e is the shared CompX ODM vendor id behind Lamzu, CRDRAKO, and + // Attack Shark. The broad filter surfaces all of them; each driver rejects + // interfaces that lack the feature-report-0 control channel. { vendorId: VENDOR_ID.lamzu }, { vendorId: VENDOR_ID.orbital, usagePage: 0xff0a, usage: 1 }, ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, productId })), diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index 698d9fc..b5f3f4b 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -3,7 +3,7 @@ export interface LamzuProduct { model: string; wireless: boolean; pollingRates: readonly number[]; - brand?: "Lamzu" | "CRDRAKO"; + brand?: "Lamzu" | "CRDRAKO" | "Attack Shark"; uiFamily?: string; mouseTarget?: number; maxDpi?: number; @@ -14,6 +14,7 @@ const RATES_1K = [125, 250, 500, 1000] as const; const RATES_8K = [500, 1000, 2000, 4000, 8000] as const; const RATES_8K_FULL = [125, 250, 500, 1000, 2000, 4000, 8000] as const; export const CRDRAKO_PRODUCT_IDS = [0x006a, 0x006b] as const; +export const ATTACKSHARK_PRODUCT_IDS = [0x0046, 0x0047] as const; export const LAMZU_PRODUCTS: ReadonlyMap = new Map([ [0x001c, { model: "Maya X", wireless: false, pollingRates: RATES_1K }], [0x001d, { model: "Maya X", wireless: true, pollingRates: RATES_1K }], @@ -26,6 +27,14 @@ export const LAMZU_PRODUCTS: ReadonlyMap = new Map([ brand: "CRDRAKO", model: "KO-ONE", wireless: true, pollingRates: RATES_8K_FULL, mouseTarget: 0x02, uiFamily: "crdrako", }], + [0x0046, { + brand: "Attack Shark", model: "R5 Ultra", wireless: false, + pollingRates: RATES_1K, maxDpi: 42000, uiFamily: "attack-shark", + }], + [0x0047, { + brand: "Attack Shark", model: "R5 Ultra", wireless: true, + pollingRates: RATES_8K, maxDpi: 42000, uiFamily: "attack-shark", + }], ]); export const LAMZU_POLLING_RATES = [ [0x08, 125], [0x04, 250], [0x02, 500], [0x01, 1000], From 5ae4d3c8f7cfd4b6ad709b11cb9161d164bc111a Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:10:15 -0600 Subject: [PATCH 2/7] Add complete G502 lighting and button controls --- docs/logitech-testing.md | 8 +- src/drivers/logitech/hidpp.ts | 69 ++++++++++++++++- src/drivers/logitech/onboard-profiles.test.ts | 11 +++ src/drivers/logitech/onboard-profiles.ts | 75 +++++++++++++++++++ src/drivers/logitech/rgb-effects.test.ts | 12 ++- src/drivers/logitech/rgb-effects.ts | 33 ++++++++ src/drivers/mouse-types.ts | 2 + 7 files changed, 204 insertions(+), 6 deletions(-) diff --git a/docs/logitech-testing.md b/docs/logitech-testing.md index dea98ad..8cdf045 100644 --- a/docs/logitech-testing.md +++ b/docs/logitech-testing.md @@ -85,8 +85,10 @@ with seven 256-byte sectors, so none of the G402's profile offsets apply to it. working after OpenMouse writes a DPI value. 7. Reload the page and confirm the DPI written in step 4 is still reported. -RGB lighting (`0x8070`, logo and wheel zones) is deliberately not implemented — -the write packet is unverified and the panel has no Logitech lighting controls. +RGB lighting (`0x8070`) is enumerated at runtime. Each advertised zone gets its +own control (the G502 family commonly reports logo and side/DPI zones), its +current effect is read when supported, and writes use the zone's advertised +effect index. Confirm each zone changes independently and reloads correctly. Persistent polling-rate changes write the profile sector and are implemented for format 2 (LOGAN); DPI-stage changes still are not (the v1 format has no stage @@ -103,6 +105,8 @@ legacy `0x2201` DPI and `0x8060` report rate, profile format 2 (LOGAN). rate — this is the fix for "cannot click the 1 kHz polling rate option". 2. Confirm the rate actually changed in the OS (e.g. a mouse-rate tester), not just in the profile read-back, so the reload-on-write behaviour is understood. +3. Open a stored profile and change normal and G-Shift assignments. Confirm the + physical button follows each layer and that unrelated assignments survive. ## G309 LIGHTSPEED (receiver-attached, Model ID `B03C40B10000`) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index a1baee8..b14f8f2 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -84,11 +84,13 @@ import { describeProfileFormat, dpiStageCapabilitiesForOptions, encodeDpiStages, + encodeButtonAssignment, encodeProfileName, encodeReportRate, factoryProfileForFormat, validateDpiStagePlan, type DpiStagePlan, + type LogitechButtonAction, layoutForFormat, parseDirectory, parseProfilesInfo, @@ -103,7 +105,10 @@ import { } from "./onboard-profiles.ts"; import { encodeLogitechRgbEffect, + encodeLogitechColorLedEffect, + logitechColorLedLighting, logitechRgbLighting, + type LogitechColorLedZone, type LogitechRgbZone, } from "./rgb-effects.ts"; @@ -268,6 +273,7 @@ const FEATURE = { extendedReportRate: 0x8061, modeStatus: 0x8090, rgbEffects: 0x8071, + colorLedEffects: 0x8070, // Legacy features used by HERO-era mice (e.g. G502 HERO / LIGHTSPEED, // Proteus). Queried only when the extended equivalents are absent. adjustableDpi: 0x2201, @@ -374,6 +380,7 @@ export class LogitechHidppClient { private lodCapabilities: ProfileFormatCapabilities = capabilitiesForFormat(null); private supportedLods: Array> = ["Medium", "High"]; private rgbZone: LogitechRgbZone | null = null; + private colorLedZones: LogitechColorLedZone[] = []; private rgbLighting: MouseLighting | null = null; private rgbClaimed = false; private rgbOriginalMode: "Onboard" | "Host" | "Unknown" = "Unknown"; @@ -696,7 +703,11 @@ export class LogitechHidppClient { const liftOffDistance = decodeLiftOffLevel(dpiState.lod, this.lodCapabilities); const hasLiveLiftOffControl = !dpiFeature.legacy && dpiCapabilities.liftOff; const rgbFeature = await this.getFeature(FEATURE.rgbEffects); - const lighting = rgbFeature.index ? await this.readRgbLighting(rgbFeature.index) : null; + const colorLedFeature = await this.getFeature(FEATURE.colorLedEffects); + const lightingZones = rgbFeature.index + ? [await this.readRgbLighting(rgbFeature.index)].filter((zone): zone is MouseLighting => zone !== null) + : colorLedFeature.index ? await this.readColorLedLighting(colorLedFeature.index) : []; + const lighting = lightingZones[0] ?? null; const rateLimits = this.lodCapabilities.reportRates; const connectionRateCeiling = rateLimits ? (wired ? rateLimits.wiredMaxHz : rateLimits.wirelessMaxHz) @@ -755,6 +766,7 @@ export class LogitechHidppClient { ? null : decodeModeStatus(modeStatus, MODE_STATUS.lightforce), lighting: lighting ?? undefined, + lightingZones: lightingZones.length ? lightingZones : undefined, // Some profile formats have different wired and wireless ceilings. The // active transport comes from HID++ identity rather than a USB PID. pollingRateHz: connectionRateCeiling ? Math.min(pollingRateHz, connectionRateCeiling) : pollingRateHz, @@ -804,10 +816,19 @@ export class LogitechHidppClient { this.friendlyNameCache = undefined; this.wheelCapabilityCache = undefined; this.rgbZone = null; + this.colorLedZones = []; this.rgbLighting = null; } async setLighting(lighting: MouseLighting): Promise { + const colorFeature = await this.getFeature(FEATURE.colorLedEffects); + const colorZone = this.colorLedZones.find((zone) => (logitechColorLedLighting(zone)?.zone) === lighting.zone); + if (colorFeature.index && colorZone) { + const payload = encodeLogitechColorLedEffect(colorZone, lighting); + if (!payload) throw new Error("That lighting effect was not advertised for this zone."); + await this.requestLong(colorFeature.index, 0x30, payload); + return { ...lighting, writeOnly: !colorZone.readable }; + } const feature = await this.getFeature(FEATURE.rgbEffects); if (!feature.index) throw new Error("This mouse does not expose RGB Effects controls."); if (!this.rgbZone) await this.readRgbLighting(feature.index); @@ -828,6 +849,37 @@ export class LogitechHidppClient { return this.rgbLighting; } + private async readColorLedLighting(featureIndex: number): Promise { + const info = await this.request(featureIndex, 0x00); + const count = Math.min(info[3] ?? 0, 8); + const readable = ((((info[6] ?? 0) << 8) | (info[7] ?? 0)) & 1) !== 0; + this.colorLedZones = []; + const lighting: MouseLighting[] = []; + for (let index = 0; index < count; index += 1) { + const zoneReply = await this.request(featureIndex, 0x10, index, 0xff, 0x00); + const zone: LogitechColorLedZone = { + index, + location: ((zoneReply[4] ?? 0) << 8) | (zoneReply[5] ?? 0), + readable, + effects: [], + }; + const effectCount = Math.min(zoneReply[6] ?? 0, 32); + for (let effectIndex = 0; effectIndex < effectCount; effectIndex += 1) { + const effect = await this.request(featureIndex, 0x20, index, effectIndex, 0x00); + zone.effects.push({ + index: effect[4] ?? effectIndex, + id: ((effect[5] ?? 0) << 8) | (effect[6] ?? 0), + period: ((effect[9] ?? 0) << 8) | (effect[10] ?? 0), + }); + } + this.colorLedZones.push(zone); + const current = readable ? await this.request(featureIndex, 0xe0, index).catch(() => null) : null; + const mapped = logitechColorLedLighting(zone, current ? [...current.slice(4, 15)] : undefined); + if (mapped) lighting.push(mapped); + } + return lighting; + } + async setPollingRate(pollingRateHz: number): Promise { if (this.isDirectConnect) { // 0x8060's setter rejects live writes on this generation (HID++ error @@ -1219,6 +1271,7 @@ export class LogitechHidppClient { reportRateWirelessHz?: number | null; reportRateWiredHz?: number | null; name?: string | null; + buttonAssignments?: Array<{ layer: "primary" | "g-shift"; button: number; action: LogitechButtonAction }>; /** Defaults to the running profile when omitted. */ sector?: number; }): Promise { @@ -1288,6 +1341,9 @@ export class LogitechHidppClient { if (values.name !== null && values.name !== undefined) { updated = encodeProfileName(updated, formatId, values.name); } + for (const assignment of values.buttonAssignments ?? []) { + updated = encodeButtonAssignment(updated, formatId, assignment.layer, assignment.button, assignment.action); + } applyCrc(updated); // Encoding is deterministic, so an identical result means nothing changed @@ -1621,7 +1677,9 @@ export class LogitechHidppClient { const feature = await this.getFeature(FEATURE.onboardProfiles); if (!feature.index) return []; - const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo)); + const infoReply = await this.request(feature.index, PROFILE_FN.getInfo); + const info = parseProfilesInfo(infoReply); + const buttonCount = Math.min(infoReply[8] ?? 0, 16); if (!info.profileFormatId) return []; const active = await this.request(feature.index, PROFILE_FN.getCurrentProfile); @@ -1632,7 +1690,12 @@ export class LogitechHidppClient { const profiles: OnboardProfile[] = []; for (const entry of directory) { const bytes = await this.readProfileSector(feature.index, entry.sector, sectorSize); - profiles.push(decodeOnboardProfile(bytes, info.profileFormatId, entry, entry.sector === currentSector)); + const profile = decodeOnboardProfile(bytes, info.profileFormatId, entry, entry.sector === currentSector); + if (buttonCount > 0) { + profile.buttonAssignments = profile.buttonAssignments.slice(0, buttonCount); + profile.gShiftAssignments = profile.gShiftAssignments.slice(0, buttonCount); + } + profiles.push(profile); } return profiles; } diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index f0a6a31..4c3ddd6 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -17,6 +17,7 @@ import { describeProfileFormat, dpiStageCapabilitiesForOptions, encodeDpiStages, + encodeButtonAssignment, encodeProfileName, encodeReportRate, factoryProfileForFormat, @@ -1062,3 +1063,13 @@ test("a corrupted byte invalidates the CRC", () => { tampered[0x04] ^= 0xff; assert.equal(decodeOnboardProfile(tampered, 7, { sector: 3, enabled: true }, true).crcValid, false); }); + +test("G502 normal and G-Shift button assignments round-trip without touching other records", () => { + const normal = encodeButtonAssignment(G502_SECTORS[0], 2, "primary", 5, "DPI Shift"); + const shifted = encodeButtonAssignment(normal, 2, "g-shift", 5, "Cycle profiles"); + const decoded = decodeOnboardProfile(shifted, 2, { sector: 1, enabled: true }, false); + assert.equal(decoded.buttonAssignments[5]?.action, "DPI Shift"); + assert.equal(decoded.gShiftAssignments[5]?.action, "Cycle profiles"); + assert.deepEqual([...shifted.slice(0x20, 0x20 + 5 * 4)], [...G502_SECTORS[0].slice(0x20, 0x20 + 5 * 4)]); + assert.equal(decoded.crcValid, true); +}); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index ec00fae..cf356c2 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -331,11 +331,81 @@ export interface OnboardProfile { * three confirmed on hardware; 0xff is unwritten flash and decodes as null. */ bunnyHoppingMs: number | null; + buttonAssignments: OnboardButtonAssignment[]; + gShiftAssignments: OnboardButtonAssignment[]; crcValid: boolean; /** Raw sector, kept so captures can diff before/after a vendor-app change. */ raw: Uint8Array; } +export type LogitechButtonAction = + | "Disabled" | "Left click" | "Right click" | "Middle click" | "Back" | "Forward" + | "Tilt left" | "Tilt right" | "Next DPI" | "Previous DPI" | "Cycle DPI" + | "Default DPI" | "DPI Shift" | "Next profile" | "Previous profile" + | "Cycle profiles" | "G-Shift" | "Battery indicator"; + +export interface OnboardButtonAssignment { + button: number; + action: LogitechButtonAction | "Custom"; + raw: readonly number[]; +} + +export const LOGITECH_BUTTON_ACTIONS: readonly LogitechButtonAction[] = [ + "Disabled", "Left click", "Right click", "Middle click", "Back", "Forward", + "Tilt left", "Tilt right", "Next DPI", "Previous DPI", "Cycle DPI", "Default DPI", + "DPI Shift", "Next profile", "Previous profile", "Cycle profiles", "G-Shift", "Battery indicator", +]; + +const ACTION_RECORDS: Readonly> = { + Disabled: [0xff, 0xff, 0xff, 0xff], + "Left click": [0x80, 0x01, 0x00, 0x01], + "Right click": [0x80, 0x01, 0x00, 0x02], + "Middle click": [0x80, 0x01, 0x00, 0x04], + Back: [0x80, 0x01, 0x00, 0x08], + Forward: [0x80, 0x01, 0x00, 0x10], + "Tilt left": [0x90, 0x01, 0x00, 0x00], + "Tilt right": [0x90, 0x02, 0x00, 0x00], + "Next DPI": [0x90, 0x03, 0x00, 0x00], + "Previous DPI": [0x90, 0x04, 0x00, 0x00], + "Cycle DPI": [0x90, 0x05, 0x00, 0x00], + "Default DPI": [0x90, 0x06, 0x00, 0x00], + "DPI Shift": [0x90, 0x07, 0x00, 0x00], + "Next profile": [0x90, 0x08, 0x00, 0x00], + "Previous profile": [0x90, 0x09, 0x00, 0x00], + "Cycle profiles": [0x90, 0x0a, 0x00, 0x00], + "G-Shift": [0x90, 0x0b, 0x00, 0x00], + "Battery indicator": [0x90, 0x0c, 0x00, 0x00], +}; + +function decodeButtonAssignments(bytes: Uint8Array, component: ComponentSpec): OnboardButtonAssignment[] { + const result: OnboardButtonAssignment[] = []; + for (let button = 0; button < component.size / 4; button += 1) { + const raw = [...bytes.slice(component.offset + button * 4, component.offset + button * 4 + 4)]; + if (raw.length < 4) break; + const action = (Object.entries(ACTION_RECORDS) as Array<[LogitechButtonAction, readonly number[]]>) + .find(([, record]) => record.every((value, index) => value === raw[index]))?.[0] ?? "Custom"; + result.push({ button, action, raw }); + } + return result; +} + +export function encodeButtonAssignment( + sector: Uint8Array, + profileFormatId: number, + layer: "primary" | "g-shift", + button: number, + action: LogitechButtonAction, +): Uint8Array { + const name = layer === "primary" ? "button_functions" : "g_shift_function"; + const component = componentsForFormat(profileFormatId).find((candidate) => candidate.name === name); + if (!component || !Number.isInteger(button) || button < 0 || button >= component.size / 4) { + throw new Error("That button is outside this profile format's assignment table."); + } + const result = sector.slice(); + result.set(ACTION_RECORDS[action], component.offset + button * 4); + return applyCrc(result); +} + /** Report-rate bytes index this table, matching 0x8061's ordering. */ const REPORT_RATE_HZ = [125, 250, 500, 1000, 2000, 4000, 8000] as const; @@ -986,6 +1056,9 @@ export function decodeOnboardProfile( ? { stages: [], defaultIndex: null } : legacyLayout ? decodeLegacyDpi(bytes, layout.dpi) : decodeDpi(bytes, layout.dpi); const angleSnappingByte = bytes[layout.angleSnapping]; + const components = componentsForFormat(profileFormatId); + const buttons = components.find((component) => component.name === "button_functions")!; + const gShift = components.find((component) => component.name === "g_shift_function")!; return { sector: entry.sector, @@ -1012,6 +1085,8 @@ export function decodeOnboardProfile( ? null : readUint16LE(bytes, layout.powerOffTimeout), bunnyHoppingMs: decodeBunnyHoppingMs(bytes, layout.bunnyHopping), + buttonAssignments: decodeButtonAssignments(bytes, buttons), + gShiftAssignments: decodeButtonAssignments(bytes, gShift), crcValid: bytes.length > 2 && profileCrc(bytes) === storedCrc(bytes), raw: bytes, }; diff --git a/src/drivers/logitech/rgb-effects.test.ts b/src/drivers/logitech/rgb-effects.test.ts index 0027733..606e87f 100644 --- a/src/drivers/logitech/rgb-effects.test.ts +++ b/src/drivers/logitech/rgb-effects.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { encodeLogitechRgbEffect, logitechRgbLighting, type LogitechRgbZone } from "./rgb-effects.ts"; +import { encodeLogitechColorLedEffect, encodeLogitechRgbEffect, logitechColorLedLighting, logitechRgbLighting, type LogitechColorLedZone, type LogitechRgbZone } from "./rgb-effects.ts"; const zone: LogitechRgbZone = { index: 0, @@ -28,3 +28,13 @@ test("encodes the Solaar 0x8071 SetEffectByIndex payload", () => { color: "#123456", }), [0, 1, 0x12, 0x34, 0x56, 0x02, 0, 0, 0, 0, 0, 0, 1]); }); + +test("decodes and writes an independent G502 0x8070 logo zone", () => { + const colorZone: LogitechColorLedZone = { ...zone, location: 2, readable: true }; + const lighting = logitechColorLedLighting(colorZone, [1, 0x12, 0x34, 0x56, 0x02, 0, 0, 0, 0, 0, 0])!; + assert.equal(lighting.zone, "Logo"); + assert.equal(lighting.mode, "Static"); + assert.equal(lighting.color, "#123456"); + assert.equal(lighting.writeOnly, false); + assert.deepEqual(encodeLogitechColorLedEffect(colorZone, lighting), [0, 1, 0x12, 0x34, 0x56, 0, 0, 0, 0, 0, 0, 0]); +}); diff --git a/src/drivers/logitech/rgb-effects.ts b/src/drivers/logitech/rgb-effects.ts index 0f637f3..6d79a0b 100644 --- a/src/drivers/logitech/rgb-effects.ts +++ b/src/drivers/logitech/rgb-effects.ts @@ -12,6 +12,39 @@ export interface LogitechRgbZone { effects: LogitechRgbEffect[]; } +export interface LogitechColorLedZone extends LogitechRgbZone { + readable: boolean; +} + +const ZONE_NAMES: Readonly> = { + 1: "Primary", 2: "Logo", 3: "Left side", 4: "Right side", 5: "Combined", + 6: "Primary 1", 7: "Primary 2", 8: "Primary 3", 9: "Primary 4", 10: "Primary 5", 11: "Primary 6", +}; + +export function logitechColorLedLighting(zone: LogitechColorLedZone, raw?: readonly number[]): MouseLighting | null { + const lighting = logitechRgbLighting(zone); + if (!lighting) return null; + const effectId = raw?.[0]; + const mode = effectId === undefined ? null : logitechRgbMode(effectId); + const color = raw && (mode === "Static" || mode === "Breathing single") + ? `#${raw.slice(1, 4).map((value) => value.toString(16).padStart(2, "0")).join("")}` + : lighting.color; + const speed = raw && mode === "Cycling" ? ((raw[6] ?? 0) << 8) | (raw[7] ?? 0) + : raw && mode === "Breathing single" ? ((raw[4] ?? 0) << 8) | (raw[5] ?? 0) + : lighting.speed; + return { ...lighting, zone: ZONE_NAMES[zone.location] ?? `Zone ${zone.index + 1}`, mode, color, speed, writeOnly: !zone.readable }; +} + +export function encodeLogitechColorLedEffect(zone: LogitechColorLedZone, lighting: MouseLighting): number[] | null { + const encoded = encodeLogitechRgbEffect(zone, lighting); + if (!encoded) return null; + const result = [encoded[0], encoded[1], ...encoded.slice(2, 12)]; + // 0x8070's byte 4 is the ramp/form field. Zero selects the device default; + // 0x8071 uses value 2 here for its fixed-effect variant. + if (lighting.mode === "Static") result[5] = 0; + return result; +} + const EFFECT_MODES: Readonly> = { 0x00: "Off", 0x01: "Static", diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 3e7af8f..988b6bd 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -249,5 +249,7 @@ export interface MouseStatus { lightforceSwitchMode?: "Hybrid" | "Optical" | null; /** Razer lighting zones. */ lighting?: MouseLighting; + /** Independently addressable lighting zones. `lighting` remains the first zone for compatibility. */ + lightingZones?: MouseLighting[]; firmware: string[]; } From bb14e6ac22c9da7c475f743e11193be2ddc07c43 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:27:21 -0600 Subject: [PATCH 3/7] Add safe Logitech keyboard and media bindings --- src/drivers/logitech/hidpp.ts | 5 +++-- src/drivers/logitech/onboard-profiles.test.ts | 8 ++++++++ src/drivers/logitech/onboard-profiles.ts | 17 +++++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index b14f8f2..1c115d0 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -91,6 +91,7 @@ import { validateDpiStagePlan, type DpiStagePlan, type LogitechButtonAction, + type LogitechButtonBinding, layoutForFormat, parseDirectory, parseProfilesInfo, @@ -1271,7 +1272,7 @@ export class LogitechHidppClient { reportRateWirelessHz?: number | null; reportRateWiredHz?: number | null; name?: string | null; - buttonAssignments?: Array<{ layer: "primary" | "g-shift"; button: number; action: LogitechButtonAction }>; + buttonAssignments?: Array<{ layer: "primary" | "g-shift"; button: number; binding: LogitechButtonAction | LogitechButtonBinding }>; /** Defaults to the running profile when omitted. */ sector?: number; }): Promise { @@ -1342,7 +1343,7 @@ export class LogitechHidppClient { updated = encodeProfileName(updated, formatId, values.name); } for (const assignment of values.buttonAssignments ?? []) { - updated = encodeButtonAssignment(updated, formatId, assignment.layer, assignment.button, assignment.action); + updated = encodeButtonAssignment(updated, formatId, assignment.layer, assignment.button, assignment.binding); } applyCrc(updated); diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index 4c3ddd6..1865d87 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -1073,3 +1073,11 @@ test("G502 normal and G-Shift button assignments round-trip without touching oth assert.deepEqual([...shifted.slice(0x20, 0x20 + 5 * 4)], [...G502_SECTORS[0].slice(0x20, 0x20 + 5 * 4)]); assert.equal(decoded.crcValid, true); }); + +test("G502 keyboard shortcuts and consumer keys use direct four-byte HID bindings", () => { + const keyboard = encodeButtonAssignment(G502_SECTORS[0], 2, "primary", 5, { kind: "keyboard", modifiers: 0x03, key: 0x0e }); + assert.deepEqual([...keyboard.slice(0x20 + 5 * 4, 0x20 + 6 * 4)], [0x80, 0x02, 0x03, 0x0e]); + const media = encodeButtonAssignment(keyboard, 2, "g-shift", 5, { kind: "consumer", usage: 0x00cd }); + assert.deepEqual([...media.slice(0x60 + 5 * 4, 0x60 + 6 * 4)], [0x80, 0x03, 0x00, 0xcd]); + assert.equal(profileCrc(media), storedCrc(media)); +}); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index cf356c2..4660eb0 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -350,6 +350,11 @@ export interface OnboardButtonAssignment { raw: readonly number[]; } +export type LogitechButtonBinding = + | { kind: "action"; action: LogitechButtonAction } + | { kind: "keyboard"; key: number; modifiers: number } + | { kind: "consumer"; usage: number }; + export const LOGITECH_BUTTON_ACTIONS: readonly LogitechButtonAction[] = [ "Disabled", "Left click", "Right click", "Middle click", "Back", "Forward", "Tilt left", "Tilt right", "Next DPI", "Previous DPI", "Cycle DPI", "Default DPI", @@ -394,15 +399,23 @@ export function encodeButtonAssignment( profileFormatId: number, layer: "primary" | "g-shift", button: number, - action: LogitechButtonAction, + binding: LogitechButtonAction | LogitechButtonBinding, ): Uint8Array { const name = layer === "primary" ? "button_functions" : "g_shift_function"; const component = componentsForFormat(profileFormatId).find((candidate) => candidate.name === name); if (!component || !Number.isInteger(button) || button < 0 || button >= component.size / 4) { throw new Error("That button is outside this profile format's assignment table."); } + const normalized: LogitechButtonBinding = typeof binding === "string" + ? { kind: "action", action: binding } + : binding; + const record = normalized.kind === "action" + ? ACTION_RECORDS[normalized.action] + : normalized.kind === "keyboard" + ? [0x80, 0x02, normalized.modifiers & 0xff, normalized.key & 0xff] + : [0x80, 0x03, (normalized.usage >> 8) & 0xff, normalized.usage & 0xff]; const result = sector.slice(); - result.set(ACTION_RECORDS[action], component.offset + button * 4); + result.set(record, component.offset + button * 4); return applyCrc(result); } From fd3c3efca4c478532069ac588e91dead2f56118e Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:41:17 -0600 Subject: [PATCH 4/7] Add G502 X Plus per-LED lighting --- src/drivers/logitech/hidpp.ts | 51 ++++++++++++++++++++++++++++++++++- src/drivers/mouse-types.ts | 6 ++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index 1c115d0..eed8c8b 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -274,6 +274,7 @@ const FEATURE = { extendedReportRate: 0x8061, modeStatus: 0x8090, rgbEffects: 0x8071, + perKeyLightingV2: 0x8081, colorLedEffects: 0x8070, // Legacy features used by HERO-era mice (e.g. G502 HERO / LIGHTSPEED, // Proteus). Queried only when the extended equivalents are absent. @@ -705,9 +706,12 @@ export class LogitechHidppClient { const hasLiveLiftOffControl = !dpiFeature.legacy && dpiCapabilities.liftOff; const rgbFeature = await this.getFeature(FEATURE.rgbEffects); const colorLedFeature = await this.getFeature(FEATURE.colorLedEffects); - const lightingZones = rgbFeature.index + const effectZones = rgbFeature.index ? [await this.readRgbLighting(rgbFeature.index)].filter((zone): zone is MouseLighting => zone !== null) : colorLedFeature.index ? await this.readColorLedLighting(colorLedFeature.index) : []; + const perLedFeature = await this.getFeature(FEATURE.perKeyLightingV2); + const perLedZones = perLedFeature.index ? await this.readPerLedLighting(perLedFeature.index, name) : []; + const lightingZones = [...effectZones, ...perLedZones]; const lighting = lightingZones[0] ?? null; const rateLimits = this.lodCapabilities.reportRates; const connectionRateCeiling = rateLimits @@ -822,6 +826,20 @@ export class LogitechHidppClient { } async setLighting(lighting: MouseLighting): Promise { + if (lighting.hardwareZoneId !== undefined) { + const feature = await this.getFeature(FEATURE.perKeyLightingV2); + if (!feature.index) throw new Error("This mouse does not expose per-LED lighting controls."); + const color = Number.parseInt((lighting.color ?? "#000000").slice(1), 16); + const enabledColor = lighting.mode === "Off" ? 0 : color; + await this.requestLong(feature.index, 0x10, [ + lighting.hardwareZoneId, + (enabledColor >> 16) & 0xff, + (enabledColor >> 8) & 0xff, + enabledColor & 0xff, + ]); + await this.request(feature.index, 0x70, 0x00); + return { ...lighting, mode: lighting.mode === "Off" ? "Off" : "Static", writeOnly: true }; + } const colorFeature = await this.getFeature(FEATURE.colorLedEffects); const colorZone = this.colorLedZones.find((zone) => (logitechColorLedLighting(zone)?.zone) === lighting.zone); if (colorFeature.index && colorZone) { @@ -2668,6 +2686,37 @@ export class LogitechHidppClient { return this.rgbLighting; } + private async readPerLedLighting(featureIndex: number, deviceName: string): Promise { + // 0x8081 exposes a 256-bit zone bitmap across three pages, but deliberately + // provides no color readback. Keep it scoped to the known mouse layout; + // keyboards sharing this feature need a keyboard-shaped editor. + if (!deviceName.toUpperCase().includes("G502 X")) return []; + const bitmap: number[] = []; + for (let page = 0; page < 3; page += 1) { + const reply = await this.request(featureIndex, 0x00, 0x00, 0x00, page); + bitmap.push(...reply.slice(5)); + } + const ids: number[] = []; + for (let id = 1; id < Math.min(255, bitmap.length * 8); id += 1) { + if (((bitmap[id >> 3] ?? 0) & (1 << (id & 7))) !== 0) ids.push(id); + } + return ids.map((id) => ({ + zone: `LED ${id}`, + group: "Lightstrip", + hardwareZoneId: id, + modes: ["Off", "Static"], + mode: "Static", + color: "#7c5cff", + color2: null, + colorModes: ["Static"], + dualColorModes: [], + reactiveModes: [], + speeds: [], + speed: null, + writeOnly: true, + })); + } + private async readDpiConfiguration(featureIndex: number): Promise { const reply = await this.request(featureIndex, 0x50); const x = ((reply[4] ?? 0) << 8) | (reply[5] ?? 0); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 988b6bd..e7d253b 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -95,6 +95,10 @@ export interface MouseLighting { brightnessLevels?: readonly number[]; /** True when the mouse cannot report the effect back (Razer effect writes). */ writeOnly?: boolean; + /** HID++ per-key/per-LED zone id when this is a directly painted RGB cell. */ + hardwareZoneId?: number; + /** Lets the UI group individually painted cells into one physical surface. */ + group?: string; } export type MouseLightingMode = @@ -109,7 +113,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "Lamzu" | "CRDRAKO" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; From cb7071ee9a908fee4401a8f336d269c45575d6e9 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:43:48 -0600 Subject: [PATCH 5/7] Prepare G502 X effect engine for LED painting --- src/drivers/logitech/hidpp.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index eed8c8b..b260f2d 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -829,6 +829,22 @@ export class LogitechHidppClient { if (lighting.hardwareZoneId !== undefined) { const feature = await this.getFeature(FEATURE.perKeyLightingV2); if (!feature.index) throw new Error("This mouse does not expose per-LED lighting controls."); + const effects = await this.getFeature(FEATURE.rgbEffects); + if (effects.index && !this.rgbClaimed) { + const profiles = await this.getFeature(FEATURE.onboardProfiles); + if (profiles.index) await this.setOnboardMode("Host"); + await this.request(effects.index, 0x50, 0x01, 0x03, 0x04); + if (!this.rgbZone) await this.readRgbLighting(effects.index); + // Disable the autonomous effect engine before painting individual + // cells. This is the G502 X PLUS prep sequence tested by Solaar. + await this.requestLong(effects.index, 0x10, [ + 0xff, + this.rgbZone?.effects.length ?? 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x01, + ]); + this.rgbClaimed = true; + } const color = Number.parseInt((lighting.color ?? "#000000").slice(1), 16); const enabledColor = lighting.mode === "Off" ? 0 : color; await this.requestLong(feature.index, 0x10, [ From 32104798388caf28eda6de6ff69c5aa5a9e3788e Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:02:24 -0600 Subject: [PATCH 6/7] Add onboard keyboard macro writes --- src/drivers/logitech/hidpp.ts | 63 +++++++++++++++++-- src/drivers/logitech/onboard-profiles.test.ts | 26 ++++++++ src/drivers/logitech/onboard-profiles.ts | 57 +++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index b260f2d..050d74e 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -85,6 +85,8 @@ import { dpiStageCapabilitiesForOptions, encodeDpiStages, encodeButtonAssignment, + encodeMacroButtonAssignment, + encodeMacroSector, encodeProfileName, encodeReportRate, factoryProfileForFormat, @@ -92,6 +94,7 @@ import { type DpiStagePlan, type LogitechButtonAction, type LogitechButtonBinding, + type LogitechMacroStep, layoutForFormat, parseDirectory, parseProfilesInfo, @@ -1307,11 +1310,56 @@ export class LogitechHidppClient { reportRateWiredHz?: number | null; name?: string | null; buttonAssignments?: Array<{ layer: "primary" | "g-shift"; button: number; binding: LogitechButtonAction | LogitechButtonBinding }>; + buttonMacros?: Array<{ layer: "primary" | "g-shift"; button: number; steps: LogitechMacroStep[] }>; /** Defaults to the running profile when omitted. */ sector?: number; }): Promise { const { featureIndex, sector, sectorSize, formatId, profile } = await this.openActiveProfile(values.sector); let updated: Uint8Array = profile.slice(); + const macroBackups: Array<{ sector: number; bytes: Uint8Array }> = []; + + for (const macro of values.buttonMacros ?? []) { + const info = parseProfilesInfo(await this.request(featureIndex, PROFILE_FN.getInfo)); + if (info.macroFormatId === 0) throw new Error("This mouse does not advertise onboard macro storage."); + const directoryBytes = await this.readProfileSector(featureIndex, 0, Math.min(sectorSize, 64)); + const profileSectors = new Set(parseDirectory(directoryBytes).map((entry) => entry.sector)); + const referenced = new Set(); + for (const profileSector of profileSectors) { + const raw = await this.readProfileSector(featureIndex, profileSector, sectorSize); + const decoded = decodeOnboardProfile(raw, formatId, { sector: profileSector, enabled: true }, false); + for (const assignment of [...decoded.buttonAssignments, ...decoded.gShiftAssignments]) { + if (assignment.raw[0] === 0x00 && assignment.raw[2] > 0) referenced.add(assignment.raw[2]); + } + } + const currentAssignments = macro.layer === "primary" + ? decodeOnboardProfile(updated, formatId, { sector, enabled: true }, false).buttonAssignments + : decodeOnboardProfile(updated, formatId, { sector, enabled: true }, false).gShiftAssignments; + const currentMacro = currentAssignments[macro.button]?.raw; + let macroSector = currentMacro?.[0] === 0x00 ? currentMacro[2] : undefined; + if (!macroSector) { + for (let candidate = info.profileCount + 1; candidate < info.sectorCount; candidate += 1) { + if (profileSectors.has(candidate) || referenced.has(candidate)) continue; + const bytes = await this.readProfileSector(featureIndex, candidate, sectorSize); + if (bytes.every((byte) => byte === 0xff) || bytes.every((byte, index) => byte === 0xff || (index === bytes.length - 1 && byte === 0x00))) { + macroSector = candidate; + break; + } + } + } + if (!macroSector) throw new Error("No free onboard macro sector is available."); + const backup = await this.readProfileSector(featureIndex, macroSector, sectorSize); + const encoded = encodeMacroSector(sectorSize, macro.steps); + try { + await this.writeProfileSector(featureIndex, macroSector, encoded); + const confirmed = await this.readProfileSector(featureIndex, macroSector, sectorSize); + if (!confirmed.every((byte, index) => byte === encoded[index])) throw new Error("The mouse did not store the macro as written."); + macroBackups.push({ sector: macroSector, bytes: backup }); + updated = encodeMacroButtonAssignment(updated, formatId, macro.layer, macro.button, macroSector); + } catch (error) { + await this.writeProfileSector(featureIndex, macroSector, backup).catch(() => undefined); + throw error; + } + } if (values.bunnyHoppingMs !== null && values.bunnyHoppingMs !== undefined) { const invalid = validateBunnyHoppingMs(values.bunnyHoppingMs); @@ -1385,10 +1433,17 @@ export class LogitechHidppClient { // and the write cycle can be skipped entirely. if (updated.every((byte, index) => byte === profile[index])) return; - await this.writeProfileSector(featureIndex, sector, updated); - const confirmed = await this.readProfileSector(featureIndex, sector, sectorSize); - if (!confirmed.every((byte, index) => byte === updated[index])) { - throw new Error("The mouse did not store the profile as written."); + try { + await this.writeProfileSector(featureIndex, sector, updated); + const confirmed = await this.readProfileSector(featureIndex, sector, sectorSize); + if (!confirmed.every((byte, index) => byte === updated[index])) { + throw new Error("The mouse did not store the profile as written."); + } + } catch (error) { + for (const backup of macroBackups) { + await this.writeProfileSector(featureIndex, backup.sector, backup.bytes).catch(() => undefined); + } + throw error; } } diff --git a/src/drivers/logitech/onboard-profiles.test.ts b/src/drivers/logitech/onboard-profiles.test.ts index 1865d87..f061578 100644 --- a/src/drivers/logitech/onboard-profiles.test.ts +++ b/src/drivers/logitech/onboard-profiles.test.ts @@ -18,6 +18,8 @@ import { dpiStageCapabilitiesForOptions, encodeDpiStages, encodeButtonAssignment, + encodeMacroButtonAssignment, + encodeMacroSector, encodeProfileName, encodeReportRate, factoryProfileForFormat, @@ -263,12 +265,28 @@ test("parses getOnboardProfilesInfo", () => { assert.deepEqual(parseProfilesInfo(INFO_REPLY), { memoryModelId: 1, profileFormatId: 7, + macroFormatId: 1, profileCount: 5, + buttonCount: 5, sectorCount: 16, sectorSize: 255, }); }); +test("encodes an onboard keyboard macro and links it to a button", () => { + const macro = encodeMacroSector(36, [ + { key: 0x0e, modifiers: 0x03, delayMs: 0 }, + { key: 0x2c, modifiers: 0, delayMs: 250 }, + ]); + assert.deepEqual([...macro.slice(0, 24)], [ + 0x43, 0x01, 0, 0x43, 0x02, 0, 0x43, 0, 0x0e, 0x44, 0, 0x0e, + 0x44, 0x02, 0, 0x44, 0x01, 0, 0x40, 0, 250, 0x43, 0, 0x2c, + ]); + const linked = encodeMacroButtonAssignment(G502_SECTORS[0], 2, "primary", 5, 9); + assert.deepEqual([...linked.slice(0x20 + 20, 0x20 + 24)], [0, 5, 9, 0]); + assert.equal(profileCrc(linked), storedCrc(linked)); +}); + test("parses the profile directory and its enabled flags", () => { const entries = parseDirectory(DIRECTORY); assert.deepEqual(entries, [ @@ -455,14 +473,18 @@ test("parses the captured G502 format-2 geometry and directory", () => { assert.deepEqual(parseProfilesInfo(G502_INFO_REPLY), { memoryModelId: 1, profileFormatId: 2, + macroFormatId: 1, profileCount: 3, + buttonCount: 11, sectorCount: 16, sectorSize: 256, }); assert.deepEqual(parseProfilesInfo(G502_HERO_INFO_REPLY), { memoryModelId: 1, profileFormatId: 2, + macroFormatId: 1, profileCount: 5, + buttonCount: 11, sectorCount: 16, sectorSize: 256, }); @@ -555,7 +577,9 @@ test("parses the captured G502 LIGHTSPEED format-3 geometry and directory", () = assert.deepEqual(parseProfilesInfo(G502_LIGHTSPEED_INFO_REPLY), { memoryModelId: 1, profileFormatId: 3, + macroFormatId: 1, profileCount: 5, + buttonCount: 11, sectorCount: 16, sectorSize: 255, }); @@ -624,7 +648,9 @@ test("parses the captured G102 LIGHTSYNC format-4 geometry and directory", () => assert.deepEqual(parseProfilesInfo(G102_LIGHTSYNC_INFO_REPLY), { memoryModelId: 1, profileFormatId: 4, + macroFormatId: 1, profileCount: 1, + buttonCount: 6, sectorCount: 16, sectorSize: 255, }); diff --git a/src/drivers/logitech/onboard-profiles.ts b/src/drivers/logitech/onboard-profiles.ts index 4660eb0..1a20612 100644 --- a/src/drivers/logitech/onboard-profiles.ts +++ b/src/drivers/logitech/onboard-profiles.ts @@ -296,7 +296,9 @@ export function decodeLiftOffLevel( export interface OnboardProfilesInfo { memoryModelId: number; profileFormatId: number; + macroFormatId: number; profileCount: number; + buttonCount: number; sectorCount: number; sectorSize: number; } @@ -355,6 +357,59 @@ export type LogitechButtonBinding = | { kind: "keyboard"; key: number; modifiers: number } | { kind: "consumer"; usage: number }; +export interface LogitechMacroStep { + key: number; + modifiers: number; + /** Pause before this chord, in milliseconds. */ + delayMs: number; +} + +/** Encodes the HID++ macro format used by G502 onboard-memory sectors. */ +export function encodeMacroSector(sectorSize: number, steps: readonly LogitechMacroStep[]): Uint8Array { + if (!Number.isInteger(sectorSize) || sectorSize < 6 || !steps.length) { + throw new Error("The macro sector geometry or sequence is invalid."); + } + const records: number[][] = []; + for (const [index, step] of steps.entries()) { + if (!Number.isInteger(step.key) || step.key < 1 || step.key > 0xff + || !Number.isInteger(step.modifiers) || step.modifiers < 0 || step.modifiers > 0xff + || !Number.isInteger(step.delayMs) || step.delayMs < 0 || step.delayMs > 0xffff) { + throw new Error("The macro contains an invalid key, modifier, or delay."); + } + if (index > 0 && step.delayMs > 0) records.push([0x40, step.delayMs >> 8, step.delayMs & 0xff]); + const modifiers = Array.from({ length: 8 }, (_, bit) => 1 << bit) + .filter((bit) => (step.modifiers & bit) !== 0); + for (const modifier of modifiers) records.push([0x43, modifier, 0x00]); + records.push([0x43, 0x00, step.key], [0x44, 0x00, step.key]); + for (const modifier of modifiers.reverse()) records.push([0x44, modifier, 0x00]); + } + records.push([0xff, 0xff, 0xff]); + if (records.length * 3 > sectorSize) throw new Error("That sequence is too long for one onboard macro sector."); + const result = new Uint8Array(sectorSize).fill(0xff); + records.forEach((record, index) => result.set(record, index * 3)); + return result; +} + +export function encodeMacroButtonAssignment( + profile: Uint8Array, + profileFormatId: number, + layer: "primary" | "g-shift", + button: number, + macroSector: number, +): Uint8Array { + if (!Number.isInteger(macroSector) || macroSector < 1 || macroSector > 0xff) { + throw new Error("The macro sector cannot be represented by this profile format."); + } + const name = layer === "primary" ? "button_functions" : "g_shift_function"; + const component = componentsForFormat(profileFormatId).find((candidate) => candidate.name === name); + if (!component || !Number.isInteger(button) || button < 0 || button >= component.size / 4) { + throw new Error("That button is outside this profile format's assignment table."); + } + const result = profile.slice(); + result.set([0x00, button & 0xff, macroSector, 0x00], component.offset + button * 4); + return applyCrc(result); +} + export const LOGITECH_BUTTON_ACTIONS: readonly LogitechButtonAction[] = [ "Disabled", "Left click", "Right click", "Middle click", "Back", "Forward", "Tilt left", "Tilt right", "Next DPI", "Previous DPI", "Cycle DPI", "Default DPI", @@ -531,7 +586,9 @@ export function parseProfilesInfo(reply: Uint8Array): OnboardProfilesInfo { return { memoryModelId: reply[3] ?? 0, profileFormatId: reply[4] ?? 0, + macroFormatId: reply[5] ?? 0, profileCount: reply[6] ?? 0, + buttonCount: reply[8] ?? 0, sectorCount: reply[9] ?? 0, sectorSize: ((reply[10] ?? 0) << 8) | (reply[11] ?? 0), }; From 2c3a1a62761cf6560cf05aa78d354a3d13137d81 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:04:27 -0600 Subject: [PATCH 7/7] Restore profile after failed macro link --- src/drivers/logitech/hidpp.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index 050d74e..9a4197c 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -1440,6 +1440,9 @@ export class LogitechHidppClient { throw new Error("The mouse did not store the profile as written."); } } catch (error) { + // The profile sector is the pointer to the macro. Restore it first so an + // interrupted commit cannot leave a corrupt or half-linked assignment. + await this.writeProfileSector(featureIndex, sector, profile).catch(() => undefined); for (const backup of macroBackups) { await this.writeProfileSector(featureIndex, backup.sector, backup.bytes).catch(() => undefined); }