From 672c061ab10de08a93ce182ac90393dffdb5a6a0 Mon Sep 17 00:00:00 2001 From: jus-kim Date: Sat, 8 Aug 2026 11:49:10 -0400 Subject: [PATCH] Port the moddoMOUSE into the new src/devices structure --- build/check-bundle-size.ts | 7 +- src/control.ts | 15 ++- src/devices/moddo/TESTING.md | 30 +++++ src/devices/moddo/hid.ts | 202 +++++++++++++++++++++++++++++ src/devices/moddo/protocol.test.ts | 117 +++++++++++++++++ src/devices/moddo/protocol.ts | 178 +++++++++++++++++++++++++ src/devices/mouse-types.ts | 2 +- src/devices/registry.test.ts | 2 +- src/devices/registry.ts | 4 +- src/devices/vendors.ts | 10 ++ 10 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 src/devices/moddo/TESTING.md create mode 100644 src/devices/moddo/hid.ts create mode 100644 src/devices/moddo/protocol.test.ts create mode 100644 src/devices/moddo/protocol.ts diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 80f80a0c..93811154 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -7,9 +7,10 @@ const BUDGET_BYTES: Record = { ".css": 46_000, // Raised from 280 kB for the production Logitech onboard-profile codec, // guarded flash editor, verification exporter, upstream Finalmouse driver, - // the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power plus - // asymmetric lift-off protocol and controls. Preview fixtures remain dev-only. - ".js": 325_000, + // the dedicated Viper Mini protocol driver, Viper V3 sleep/low-power plus + // asymmetric lift-off protocol and controls, and the moddoMOUSE wired/ + // wireless config driver. Preview fixtures remain dev-only. + ".js": 330_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/control.ts b/src/control.ts index a3053887..d7fcd2ad 100644 --- a/src/control.ts +++ b/src/control.ts @@ -87,6 +87,7 @@ import { RazerHidClient } from "./devices/razer/hid"; import { RazerViperMiniHidClient } from "./devices/razer/viper-mini-hid"; import { RazerViperV4ProHidClient } from "./devices/razer/viper-v4-pro-hid"; import { FinalmouseHidClient } from "./devices/finalmouse/hid"; +import { ModdoHidClient } from "./devices/moddo/hid"; import { TeevolutionHidClient } from "./devices/teevolution/hid"; import { VgnF2HidClient } from "./devices/vgn/hid"; import { SUPPORTED_HID_FILTERS } from "./devices/vendors"; @@ -120,6 +121,7 @@ let activeRazerClient: RazerHidClient | RazerViperMiniHidClient | null = null; let activeTeevolutionClient: TeevolutionHidClient | null = null; let activeVgnClient: VgnF2HidClient | null = null; let activeViperClient: RazerViperV4ProHidClient | null = null; +let activeModdoClient: ModdoHidClient | null = null; /** Cached onboard profiles; a full read is far too slow for the refresh loop. */ let onboardProfiles: OnboardProfile[] | null = null; let onboardProfilesLoading = false; @@ -178,7 +180,7 @@ async function statusAfterWrite(client: SupportedClient): Promise { } function activeSettingsClient(): SupportedClient | null { - return activeClient ?? activePulsarClient ?? activeEggClient ?? activeEggWeClient ?? activeFinalmouseClient ?? activeDmClient ?? activeOrbitalClient ?? activeRazerClient ?? activeViperClient ?? activeTeevolutionClient ?? activeVgnClient; + return activeClient ?? activePulsarClient ?? activeEggClient ?? activeEggWeClient ?? activeFinalmouseClient ?? activeDmClient ?? activeOrbitalClient ?? activeRazerClient ?? activeViperClient ?? activeTeevolutionClient ?? activeVgnClient ?? activeModdoClient; } function hasActiveClient(): boolean { @@ -1483,6 +1485,7 @@ async function activateClient(client: SupportedClient): Promise { activeTeevolutionClient = null; activeVgnClient = null; activeViperClient = null; + activeModdoClient = null; if (activeDevice !== client.device) onboardProfiles = null; activeFinalmouseClient = null; activeDevice = client.device; @@ -1564,6 +1567,14 @@ async function activateClient(client: SupportedClient): Promise { dpiOptions = client.getDpiOptions(); configureDpiControl(status.dpi); showStatus(status); + } else if (client instanceof ModdoHidClient) { + activeModdoClient = client; + await client.open(); + const status = await client.readStatus(); + deviceStatuses.set(client.device, status); + dpiOptions = client.getDpiOptions(); + configureDpiControl(status.dpi); + showStatus(status); } else { activePulsarClient = client; await showPulsarExplorer(client); @@ -1589,6 +1600,7 @@ function showDisconnectedState(): void { activeTeevolutionClient = null; activeVgnClient = null; activeViperClient = null; + activeModdoClient = null; activeFinalmouseClient = null; activeDevice = null; onboardProfiles = null; @@ -3412,6 +3424,7 @@ window.addEventListener("beforeunload", (event) => { void activeVgnClient?.close(); void activeViperClient?.close(); void activeFinalmouseClient?.close(); + void activeModdoClient?.close(); }); function isChromium(): boolean { diff --git a/src/devices/moddo/TESTING.md b/src/devices/moddo/TESTING.md new file mode 100644 index 00000000..c503b1ed --- /dev/null +++ b/src/devices/moddo/TESTING.md @@ -0,0 +1,30 @@ +# moddoMOUSE hardware test checklist + +Test in Chrome or Edge over HTTPS. Select only the vendor configuration +collection (`usagePage 0xff`, `usage 0x01`; older firmware answers on +`usage 0x02`) when the browser lists multiple HID interfaces. + +Supported identifiers: + +- VID `2fe3` — moddo.io +- `2fe3:0001` — 2.4 GHz dongle (wireless) +- `2fe3:0002` — wired + +The driver only reads and writes documented settings (DPI, report rate, and +lift-off distance) from the packed config feature report `0x02`; it never flashes +firmware, pairs the receiver, or remaps buttons. Report layout follows the +moddoHUB-Web reference (`js/main.js`, `js/battery.js`, `js/firmware.js`). + +1. Connect the device and confirm the wired/wireless state, battery (wireless + only), firmware version(s), DPI, and polling rate are correct. +2. Change one setting at a time: DPI (50–26,000 in 50-DPI steps, per axis), + polling rate (125 / 250 / 500 / 1000 Hz), and lift-off distance + (1 mm = Medium, 2 mm = High — Low is not offered). +3. Reload after each write and confirm the value persisted on the mouse. Writes + are fire-and-forget, so the driver settles and re-reads before reporting; a + value that will not stick surfaces an explicit error rather than a silent + revert. +4. Confirm a sleeping wireless mouse shows "settings not ready" rather than + 0 DPI / 0 Hz, then wakes and reads correctly. +5. Record the device identifier, firmware version, and any failing setting in + the issue or pull request. diff --git a/src/devices/moddo/hid.ts b/src/devices/moddo/hid.ts new file mode 100644 index 00000000..be72cf8e --- /dev/null +++ b/src/devices/moddo/hid.ts @@ -0,0 +1,202 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + decodeModdoBattery, + decodeModdoConfig, + decodeModdoFirmware, + decodeModdoLift, + encodeModdoConfig, + encodeModdoLift, + isValidModdoDpi, + moddoDpiOptions, + MODDO_DPI_MAX, + MODDO_DPI_MIN, + MODDO_DPI_STEP, + MODDO_REPORT, + MODDO_SUPPORTED_POLLING_RATES, + MODDO_USAGE_PAGE_VENDOR, + MODDO_USAGE_VENDOR_CONFIG, + MODDO_USAGE_VENDOR_CONFIG_LEGACY, + MODDO_VENDOR_ID, + type ModdoBattery, + type ModdoConfig, + type ModdoFirmware, +} from "./protocol.ts"; + +// The firmware applies config writes fire-and-forget, so a read-back can briefly +// still show the old value (especially over the 2.4 GHz dongle). Settle and retry +// before deciding a write failed. +const WRITE_SETTLE_MS = 60; +const WRITE_CONFIRM_ATTEMPTS = 5; + +/** + * moddoMOUSE (moddo.io) vendor HID control. + * + * Transport: vendor usage page 0xFF, usage 0x01 (or legacy 0x02) under VID 0x2FE3. + * All packing/parsing lives in ./protocol; this class is the WebHID I/O around it. + */ +export class ModdoHidClient { + readonly device: HIDDevice; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== MODDO_VENDOR_ID) return false; + const hasVendorConfig = (collections: readonly HIDCollectionInfo[]): boolean => + collections.some((collection) => + (collection.usagePage === MODDO_USAGE_PAGE_VENDOR + && (collection.usage === MODDO_USAGE_VENDOR_CONFIG || collection.usage === MODDO_USAGE_VENDOR_CONFIG_LEGACY)) + || hasVendorConfig(collection.children)); + return hasVendorConfig(device.collections); + } + + get supportedPollingRates(): number[] { + return [...MODDO_SUPPORTED_POLLING_RATES]; + } + + getDpiOptions(): number[] { + return moddoDpiOptions(); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + async readStatus(): Promise { + await this.open(); + // readConfig rejects a zeroed/asleep report, so a null here means "not ready" + // and the grid stays hidden instead of showing 0 DPI / 0 Hz. + const config = await this.readConfig().catch(() => null); + const battery = await this.readBattery().catch( + (): ModdoBattery => ({ percent: null, state: "Unknown" }), + ); + const firmware = await this.readFirmware().catch(() => null); + const wireless = firmware?.dongleConnected ?? false; + + return { + brand: "moddoMOUSE", + name: "moddoMOUSE", + ui: { + family: "moddo", + settingsReady: config !== null, + hideLodLow: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + defaultDisplayName: "moddoMOUSE", + }, + batteryPercent: battery.percent, + batteryState: battery.state, + dpi: config?.dpiX ?? 1600, + dpiY: config?.dpiY ?? config?.dpiX ?? 1600, + pollingRateHz: config?.polling ?? 1000, + supportedPollingRates: this.supportedPollingRates, + activeProfile: null, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless ? "2.4 GHz receiver" : "USB", + liftOffDistance: config ? decodeModdoLift(config.lift) : null, + firmware: this.firmwareLines(firmware), + }; + } + + async setDpi(dpi: number, dpiY: number = dpi): Promise { + for (const value of [dpi, dpiY]) { + if (!isValidModdoDpi(value)) { + throw new Error( + `moddoMOUSE DPI must be ${MODDO_DPI_MIN}–${MODDO_DPI_MAX.toLocaleString()} in ${MODDO_DPI_STEP} DPI steps.`, + ); + } + } + await this.open(); + const config = await this.readConfig(); + await this.writeConfig({ ...config, dpiX: dpi, dpiY }); + const confirmed = await this.confirmConfig((c) => c.dpiX === dpi && c.dpiY === dpiY); + if (confirmed.dpiX !== dpi || confirmed.dpiY !== dpiY) { + throw new Error(`The mouse kept ${confirmed.dpiX.toLocaleString()} DPI instead of ${dpi.toLocaleString()}.`); + } + return confirmed.dpiX; + } + + async setPollingRate(rate: number): Promise { + if (!(MODDO_SUPPORTED_POLLING_RATES as readonly number[]).includes(rate)) { + throw new Error("moddoMOUSE supports 125, 250, 500, or 1000 Hz report rate."); + } + await this.open(); + const config = await this.readConfig(); + await this.writeConfig({ ...config, polling: rate }); + const confirmed = await this.confirmConfig((c) => c.polling === rate); + if (confirmed.polling !== rate) { + throw new Error( + `The mouse kept ${confirmed.polling.toLocaleString()} Hz instead of ${rate.toLocaleString()} Hz.`, + ); + } + return confirmed.polling; + } + + async setLiftOffDistance(value: NonNullable): Promise { + if (value === "Low") { + throw new Error("The moddoMOUSE supports 1 mm (Medium) or 2 mm (High) lift-off distance."); + } + const encoded = encodeModdoLift(value); + await this.open(); + const config = await this.readConfig(); + await this.writeConfig({ ...config, lift: encoded }); + const confirmed = await this.confirmConfig((c) => c.lift === encoded); + if (confirmed.lift !== encoded) { + throw new Error( + `The mouse kept ${decodeModdoLift(confirmed.lift) ?? "an unknown"} lift-off distance instead of ${value}.`, + ); + } + } + + // --------------------------------------------------------------------------- + // Report I/O + // --------------------------------------------------------------------------- + + private async readConfig(): Promise { + const config = decodeModdoConfig(this.payload(await this.device.receiveFeatureReport(MODDO_REPORT.config))); + if (!config) throw new Error("moddoMOUSE settings are not ready yet — wake the mouse and try again."); + return config; + } + + private async writeConfig(config: ModdoConfig): Promise { + await this.device.sendFeatureReport(MODDO_REPORT.config, encodeModdoConfig(config)); + } + + /** Re-read the config until it matches, tolerating fire-and-forget write latency. */ + private async confirmConfig(matches: (config: ModdoConfig) => boolean): Promise { + let config = await this.readConfig(); + for (let attempt = 1; attempt < WRITE_CONFIRM_ATTEMPTS && !matches(config); attempt += 1) { + await this.delay(WRITE_SETTLE_MS); + config = await this.readConfig(); + } + return config; + } + + private async readBattery(): Promise { + return decodeModdoBattery(this.payload(await this.device.receiveFeatureReport(MODDO_REPORT.battery))); + } + + private async readFirmware(): Promise { + return decodeModdoFirmware(this.payload(await this.device.receiveFeatureReport(MODDO_REPORT.firmware))); + } + + private firmwareLines(firmware: ModdoFirmware | null): string[] { + const lines = [firmware?.mouse ? `Mouse v${firmware.mouse}` : "Mouse firmware unavailable"]; + if (firmware?.dongle) lines.push(`Dongle v${firmware.dongle}`); + return lines; + } + + private delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + } + + /** WebHID feature reports carry the report id as byte 0; return the bytes past it. */ + private payload(view: DataView): Uint8Array { + return new Uint8Array(view.buffer, view.byteOffset + 1, Math.max(0, view.byteLength - 1)); + } +} diff --git a/src/devices/moddo/protocol.test.ts b/src/devices/moddo/protocol.test.ts new file mode 100644 index 00000000..37bfec11 --- /dev/null +++ b/src/devices/moddo/protocol.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decodeModdoBattery, + decodeModdoConfig, + decodeModdoFirmware, + decodeModdoLift, + encodeModdoConfig, + encodeModdoLift, + isValidModdoDpi, + moddoDpiOptions, + MODDO_CONFIG_REPORT_SIZE, + type ModdoConfig, +} from "./protocol.ts"; + +test("config round-trips through the packed 11-byte report", () => { + // Arrange + const config: ModdoConfig = { + polling: 1000, + lift: 1, + dpiX: 1600, + dpiY: 800, + invertSwap: 0, + angle: -5, + deepSleep: 30, + }; + + // Act + const encoded = encodeModdoConfig(config); + + // Assert + assert.equal(encoded.length, MODDO_CONFIG_REPORT_SIZE); + assert.deepEqual([...encoded], [0xe8, 0x03, 0x01, 0x40, 0x06, 0x20, 0x03, 0x00, 0xfb, 0xff, 0x1e]); + assert.deepEqual(decodeModdoConfig(encoded), config); +}); + +test("decode preserves the invert/swap, angle, and deep-sleep bytes for read-modify-write", () => { + // Arrange — a config whose reserved fields are all non-zero. + const config: ModdoConfig = { + polling: 500, + lift: 2, + dpiX: 26000, + dpiY: 26000, + invertSwap: 0x03, + angle: 180, + deepSleep: 60, + }; + + // Act / Assert + assert.deepEqual(decodeModdoConfig(encodeModdoConfig(config)), config); +}); + +test("decodeModdoConfig rejects truncated or not-ready reports", () => { + // Arrange + const zeroed = new Uint8Array(MODDO_CONFIG_REPORT_SIZE); + const truncated = new Uint8Array([0xe8, 0x03, 0x01, 0x40, 0x06]); + const badDpi = encodeModdoConfig({ + polling: 1000, lift: 1, dpiX: 0, dpiY: 0, invertSwap: 0, angle: 0, deepSleep: 0, + }); + + // Act / Assert — a sleeping mouse answers all-zero; the driver must not show 0 DPI / 0 Hz. + assert.equal(decodeModdoConfig(zeroed), null); + assert.equal(decodeModdoConfig(truncated), null); + assert.equal(decodeModdoConfig(badDpi), null); +}); + +test("battery decodes charge state and hides the column when no cell is present", () => { + // Arrange — [remaining, _, status, chargerFlags] + const charging = new Uint8Array([90, 0, 0x44, 0x01]); + const discharging = new Uint8Array([75, 0, 0x45, 0x01]); + const full = new Uint8Array([100, 0, 0x46, 0x01]); + const noCell = new Uint8Array([90, 0, 0x44, 0x00]); + const overRange = new Uint8Array([200, 0, 0x44, 0x01]); + + // Act / Assert + assert.deepEqual(decodeModdoBattery(charging), { percent: 90, state: "Charging" }); + assert.deepEqual(decodeModdoBattery(discharging), { percent: 75, state: "Discharging" }); + assert.deepEqual(decodeModdoBattery(full), { percent: 100, state: "Full" }); + assert.deepEqual(decodeModdoBattery(noCell), { percent: null, state: "Unknown" }); + assert.deepEqual(decodeModdoBattery(overRange), { percent: null, state: "Charging" }); + assert.deepEqual(decodeModdoBattery(new Uint8Array([90, 0, 0x44])), { percent: null, state: "Unknown" }); +}); + +test("firmware distinguishes the wireless dongle path from a wired mouse", () => { + // Arrange — [dongle major.minor.patch.build, mouse major.minor.patch.build] + const wireless = new Uint8Array([1, 2, 3, 4, 2, 0, 1, 0]); + const wired = new Uint8Array([0, 0, 0, 0, 1, 4, 2, 0]); + + // Act / Assert + assert.deepEqual(decodeModdoFirmware(wireless), { mouse: "2.0.1", dongle: "1.2.3", dongleConnected: true }); + assert.deepEqual(decodeModdoFirmware(wired), { mouse: "1.4.2", dongle: null, dongleConnected: false }); + assert.deepEqual(decodeModdoFirmware(new Uint8Array(7)), { mouse: null, dongle: null, dongleConnected: false }); +}); + +test("lift-off codec round-trips the two supported heights", () => { + // Act / Assert + assert.equal(decodeModdoLift(1), "Medium"); + assert.equal(decodeModdoLift(2), "High"); + assert.equal(decodeModdoLift(0), null); + assert.equal(decodeModdoLift(3), null); + for (const value of ["Medium", "High"] as const) { + assert.equal(decodeModdoLift(encodeModdoLift(value)), value); + } +}); + +test("DPI validation follows the 50-step range and options span it", () => { + // Act / Assert + for (const dpi of [50, 1600, 26000]) assert.equal(isValidModdoDpi(dpi), true); + for (const dpi of [49, 75, 0, 26050, 1600.5]) assert.equal(isValidModdoDpi(dpi), false); + + const options = moddoDpiOptions(); + assert.equal(options[0], 50); + assert.equal(options[1], 100); + assert.equal(options.at(-1), 26000); + assert.equal(options.length, 520); +}); diff --git a/src/devices/moddo/protocol.ts b/src/devices/moddo/protocol.ts new file mode 100644 index 00000000..2bdfc823 --- /dev/null +++ b/src/devices/moddo/protocol.ts @@ -0,0 +1,178 @@ +/** + * moddoMOUSE (moddo.io) vendor HID protocol — pure encode/decode helpers. + * + * Settings live in one packed feature report (id 0x02) — no CBOR: + * [ pollingHz u16le | lift u8 | dpiX u16le | dpiY u16le | invert/swap u8 | angle i16le | deepSleep u8 ] + * Battery is feature report 0x04; firmware version + connection path (wireless + * dongle vs. wired) come from feature report 0x03. + * + * These functions operate on the report *payload*, i.e. the bytes after the + * WebHID report-id byte; hid.ts strips that byte before calling in. Only + * documented settings are touched — no firmware flashing, wireless pairing, or + * button remapping. Report layout mirrors moddoHUB-Web (js/main.js, + * js/battery.js, js/firmware.js). + */ + +export const MODDO_VENDOR_ID = 0x2fe3; +export const MODDO_USAGE_PAGE_VENDOR = 0xff; +export const MODDO_USAGE_VENDOR_CONFIG = 0x01; +export const MODDO_USAGE_VENDOR_CONFIG_LEGACY = 0x02; + +export const MODDO_REPORT = { + config: 0x02, + firmware: 0x03, + battery: 0x04, +} as const; + +/** Packed config-report field offsets (into the payload, after the report-id byte). */ +const CONFIG = { + polling: 0, // u16 LE — report rate in Hz + lift: 2, // u8 — 1 = 1 mm, 2 = 2 mm + dpiX: 3, // u16 LE + dpiY: 5, // u16 LE + invertSwap: 7, // u8 — preserved across writes + angle: 8, // i16 LE — preserved across writes + deepSleep: 10, // u8 — preserved across writes +} as const; + +export const MODDO_CONFIG_REPORT_SIZE = 11; + +export const MODDO_SUPPORTED_POLLING_RATES = [125, 250, 500, 1000] as const; +export const MODDO_DPI_MIN = 50; +export const MODDO_DPI_MAX = 26000; +export const MODDO_DPI_STEP = 50; + +// Battery status codes and charger flags (js/battery.js). +export const MODDO_BATTERY_STATUS = { + charging: 0x44, + discharging: 0x45, + fullyCharged: 0x46, + fullyDischarged: 0x47, +} as const; +export const MODDO_CHARGER_BATTERY_PRESENT_BIT = 1 << 0; + +/** The moddoMOUSE reports lift-off at 1 mm ("Medium") or 2 mm ("High") only. */ +export type ModdoLiftOffDistance = "Medium" | "High"; +export type ModdoBatteryState = "Charging" | "Full" | "Discharging" | "Unknown"; + +export interface ModdoConfig { + polling: number; + lift: number; + dpiX: number; + dpiY: number; + invertSwap: number; + angle: number; + deepSleep: number; +} + +export interface ModdoBattery { + percent: number | null; + state: ModdoBatteryState; +} + +export interface ModdoFirmware { + mouse: string | null; + dongle: string | null; + dongleConnected: boolean; +} + +export function isValidModdoDpi(dpi: number): boolean { + return Number.isInteger(dpi) && dpi >= MODDO_DPI_MIN && dpi <= MODDO_DPI_MAX && dpi % MODDO_DPI_STEP === 0; +} + +export function moddoDpiOptions(): number[] { + const values: number[] = []; + for (let dpi = MODDO_DPI_MIN; dpi <= MODDO_DPI_MAX; dpi += MODDO_DPI_STEP) values.push(dpi); + return values; +} + +export function decodeModdoLift(raw: number): ModdoLiftOffDistance | null { + if (raw === 1) return "Medium"; + if (raw === 2) return "High"; + return null; +} + +export function encodeModdoLift(value: ModdoLiftOffDistance): number { + return value === "Medium" ? 1 : 2; +} + +/** + * Decode the packed config payload, or null when the mouse is not ready: a + * truncated report, or the zeroed report a sleeping/off wireless mouse answers + * with. Returning null (rather than 0 DPI / 0 Hz) keeps the UI grid hidden and + * stops a read-modify-write from persisting zeros. + */ +export function decodeModdoConfig(payload: Uint8Array): ModdoConfig | null { + // Need polling + lift + both DPI axes present (through offset 6). + if (payload.length < 7) return null; + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const u8 = (offset: number): number => (offset < payload.length ? view.getUint8(offset) : 0); + const i16 = (offset: number): number => (offset + 2 <= payload.length ? view.getInt16(offset, true) : 0); + const config: ModdoConfig = { + polling: view.getUint16(CONFIG.polling, true), + lift: view.getUint8(CONFIG.lift), + dpiX: view.getUint16(CONFIG.dpiX, true), + dpiY: view.getUint16(CONFIG.dpiY, true), + invertSwap: u8(CONFIG.invertSwap), + angle: i16(CONFIG.angle), + deepSleep: u8(CONFIG.deepSleep), + }; + if (config.polling <= 0 || config.dpiX < MODDO_DPI_MIN || config.dpiX > MODDO_DPI_MAX) return null; + return config; +} + +/** Serialize the full 11-byte config report; callers validate before calling. */ +export function encodeModdoConfig(config: ModdoConfig): Uint8Array { + const buffer = new Uint8Array(MODDO_CONFIG_REPORT_SIZE); + const view = new DataView(buffer.buffer); + view.setUint16(CONFIG.polling, config.polling, true); + view.setUint8(CONFIG.lift, config.lift); + view.setUint16(CONFIG.dpiX, config.dpiX, true); + view.setUint16(CONFIG.dpiY, config.dpiY, true); + view.setUint8(CONFIG.invertSwap, config.invertSwap); + view.setInt16(CONFIG.angle, config.angle, true); + view.setUint8(CONFIG.deepSleep, config.deepSleep); + return buffer; +} + +export function decodeModdoBattery(payload: Uint8Array): ModdoBattery { + if (payload.length < 4) return { percent: null, state: "Unknown" }; + const remaining = payload[0]!; + const status = payload[2]!; + const chargerStatus = payload[3]!; + if ((chargerStatus & MODDO_CHARGER_BATTERY_PRESENT_BIT) === 0) { + // No cell present (e.g. a wired mouse) — surface an empty battery column. + return { percent: null, state: "Unknown" }; + } + const percent = remaining > 100 ? null : remaining; + const state: ModdoBatteryState = status === MODDO_BATTERY_STATUS.charging + ? "Charging" + : status === MODDO_BATTERY_STATUS.fullyCharged + ? "Full" + : status === MODDO_BATTERY_STATUS.discharging || status === MODDO_BATTERY_STATUS.fullyDischarged + ? "Discharging" + : "Unknown"; + return { percent, state }; +} + +export function decodeModdoFirmware(payload: Uint8Array): ModdoFirmware { + // First 8 bytes: dongle (rx) then mouse (tx) major.minor.patch.build. + if (payload.length < 8) return { mouse: null, dongle: null, dongleConnected: false }; + const version = (offset: number): { text: string; present: boolean } => { + const major = payload[offset]!; + const minor = payload[offset + 1]!; + const patch = payload[offset + 2]!; + const build = payload[offset + 3]!; + const present = !(major === 0 && minor === 0 && patch === 0 && build === 0); + return { text: `${major}.${minor}.${patch}`, present }; + }; + const dongle = version(0); + const mouse = version(4); + return { + mouse: mouse.present ? mouse.text : null, + dongle: dongle.present ? dongle.text : null, + // A non-zero dongle version means the mouse answered over the 2.4 GHz + // receiver; a wired mouse leaves those bytes zero. + dongleConnected: dongle.present, + }; +} diff --git a/src/devices/mouse-types.ts b/src/devices/mouse-types.ts index 97ccfb3a..f64de8ad 100644 --- a/src/devices/mouse-types.ts +++ b/src/devices/mouse-types.ts @@ -41,7 +41,7 @@ export interface MouseUiHints { } export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "Lamzu" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "Lamzu" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "moddoMOUSE"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/devices/registry.test.ts b/src/devices/registry.test.ts index 42bb41ab..53eb1508 100644 --- a/src/devices/registry.test.ts +++ b/src/devices/registry.test.ts @@ -10,7 +10,7 @@ import { SUPPORTED_HID_FILTERS, VENDOR_ID } from "./vendors.ts"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x0f, 0x10, 0x11, 0x20, 0xa1]; -const USAGE_PAGES = [0x01, 0x0c, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff43]; +const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff43]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/devices/registry.ts b/src/devices/registry.ts index 921d9934..f4b8e71b 100644 --- a/src/devices/registry.ts +++ b/src/devices/registry.ts @@ -4,6 +4,7 @@ import { eggWeCreate, eggWeIsSupported, eggWeSupportScore, isEggWeClient, type E import { FinalmouseHidClient } from "./finalmouse/hid.ts"; import { LamzuHidClient } from "./lamzu/hid.ts"; import { LogitechHidppClient } from "./logitech/hidpp.ts"; +import { ModdoHidClient } from "./moddo/hid.ts"; import { OrbitalHidClient } from "./orbital/hid.ts"; import { PulsarHidClient } from "./pulsar/pulsar-hid.ts"; import { PulsarProHidClient } from "./pulsar/pulsar-pro-hid.ts"; @@ -15,7 +16,7 @@ import { VgnF2HidClient } from "./vgn/hid.ts"; import { WLMouseHidClient } from "./wlmouse/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | ModdoHidClient; export interface DeviceDriver { brand: string; @@ -35,6 +36,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Logitech", supports: (device) => LogitechHidppClient.isSupported(device), create: (device) => new LogitechHidppClient(device), score: () => 6 }, { brand: "WLMouse", supports: (device) => WLMouseHidClient.isSupported(device), create: (device) => new WLMouseHidClient(device), score: () => 5 }, { brand: "Lamzu", supports: (device) => LamzuHidClient.isSupported(device), create: (device) => new LamzuHidClient(device), score: () => 5 }, + { brand: "moddoMOUSE", supports: (device) => ModdoHidClient.isSupported(device), create: (device) => new ModdoHidClient(device), score: () => 5 }, { brand: "Orbital", supports: (device) => OrbitalHidClient.isSupported(device), create: (device) => new OrbitalHidClient(device), score: () => 6 }, { brand: "Razer", supports: (device) => RazerHidClient.isSupported(device), create: (device) => new RazerHidClient(device), score: () => 6 }, { brand: "Razer", supports: (device) => RazerViperMiniHidClient.isSupported(device), create: (device) => new RazerViperMiniHidClient(device), score: () => 6 }, diff --git a/src/devices/vendors.ts b/src/devices/vendors.ts index 18b4e4fd..ea6d812d 100644 --- a/src/devices/vendors.ts +++ b/src/devices/vendors.ts @@ -13,8 +13,17 @@ export const VENDOR_ID = { vgn: 0x3554, atk: 0x373b, finalmouse: 0x361d, + moddo: 0x2fe3, } as const; +// moddoMOUSE exposes its vendor config interface on usage page 0xff, usage 0x01 +// (older firmware answers on usage 0x02). Offer both so the picker lists the +// control interface; the driver rejects anything without the config report. +export const MODDO_HID_FILTERS: HIDDeviceFilter[] = [ + { vendorId: VENDOR_ID.moddo, usagePage: 0xff, usage: 0x01 }, + { vendorId: VENDOR_ID.moddo, usagePage: 0xff, usage: 0x02 }, +]; + // Viper V2/V3 Pro expose their control channel as a Generic Desktop Mouse // collection. Limit this broad collection filter to known PIDs so it cannot // also surface unrelated Razer keyboards or the Viper V4 Pro's ordinary @@ -127,5 +136,6 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...RAZER_VIPER_V4_CONTROL_FILTERS, ...RAZER_DEATHADDER_ESSENTIAL_FILTERS, ...EGG_WE_HID_FILTERS, + ...MODDO_HID_FILTERS, ...LOGITECH_RECEIVER_FILTERS, ];