Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ const BUDGET_BYTES: Record<string, number> = {
".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");
Expand Down
15 changes: 14 additions & 1 deletion src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -178,7 +180,7 @@ async function statusAfterWrite(client: SupportedClient): Promise<MouseStatus> {
}

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 {
Expand Down Expand Up @@ -1483,6 +1485,7 @@ async function activateClient(client: SupportedClient): Promise<void> {
activeTeevolutionClient = null;
activeVgnClient = null;
activeViperClient = null;
activeModdoClient = null;
if (activeDevice !== client.device) onboardProfiles = null;
activeFinalmouseClient = null;
activeDevice = client.device;
Expand Down Expand Up @@ -1564,6 +1567,14 @@ async function activateClient(client: SupportedClient): Promise<void> {
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);
Expand All @@ -1589,6 +1600,7 @@ function showDisconnectedState(): void {
activeTeevolutionClient = null;
activeVgnClient = null;
activeViperClient = null;
activeModdoClient = null;
activeFinalmouseClient = null;
activeDevice = null;
onboardProfiles = null;
Expand Down Expand Up @@ -3412,6 +3424,7 @@ window.addEventListener("beforeunload", (event) => {
void activeVgnClient?.close();
void activeViperClient?.close();
void activeFinalmouseClient?.close();
void activeModdoClient?.close();
});

function isChromium(): boolean {
Expand Down
30 changes: 30 additions & 0 deletions src/devices/moddo/TESTING.md
Original file line number Diff line number Diff line change
@@ -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.
202 changes: 202 additions & 0 deletions src/devices/moddo/hid.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!this.device.opened) await this.device.open();
}

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

async readStatus(): Promise<MouseStatus> {
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<number> {
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<number> {
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<MouseStatus["liftOffDistance"]>): Promise<void> {
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<ModdoConfig> {
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<void> {
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<ModdoConfig> {
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<ModdoBattery> {
return decodeModdoBattery(this.payload(await this.device.receiveFeatureReport(MODDO_REPORT.battery)));
}

private async readFirmware(): Promise<ModdoFirmware> {
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<void> {
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));
}
}
Loading