diff --git a/public/devices/README.md b/public/devices/README.md
index 2f4035d..248f09e 100644
--- a/public/devices/README.md
+++ b/public/devices/README.md
@@ -38,6 +38,11 @@ transparent canvases; the original G502 backdrop was extracted from its source
render. Confirm redistribution terms before including them in a public release
package.
+`logitech-g703.png` was supplied from Logitech G's official G703 HERO product
+gallery (`resource.logitechg.com` DAM `g703-mouse-top-angle-gallery-1.png`)
+and normalized to the same 700×700 transparent canvas as the G502 set. Confirm
+redistribution terms before including it in a public release package.
+
`logitech-mx-master-4.png` is a line-art trace made for this repository, not a
vendor render. Only geometry derives from the source: the outer silhouette and
the shell seams — button split, scroll-wheel housing, thumb rest, thumb wheel,
diff --git a/public/devices/logitech-g703.png b/public/devices/logitech-g703.png
new file mode 100644
index 0000000..b2bb0b8
Binary files /dev/null and b/public/devices/logitech-g703.png differ
diff --git a/src/app/cards/LightingCard.tsx b/src/app/cards/LightingCard.tsx
index 5ef334f..5363af7 100644
--- a/src/app/cards/LightingCard.tsx
+++ b/src/app/cards/LightingCard.tsx
@@ -18,7 +18,10 @@ export function LightingCard({
zones?: MouseLighting[];
zoneIndex?: number;
}): ReactNode {
- const [selectedZone, setSelectedZone] = useState(zones && zones.length > 1 ? 1 : zoneIndex);
+ const hasLightstrip = Boolean(zones?.some((zone) => zone.hardwareZoneId != null));
+ const [selectedZone, setSelectedZone] = useState(
+ hasLightstrip && zones && zones.length > 1 ? 1 : zoneIndex,
+ );
const activeZoneIndex = zones ? Math.min(selectedZone, zones.length - 1) : zoneIndex;
const lighting = zones?.[activeZoneIndex] ?? suppliedLighting ?? snapshot.status?.lighting;
if (!lighting) return null;
@@ -57,7 +60,11 @@ export function LightingCard({
RGB parts
{lighting.group ? `${lighting.group} · ${lighting.zone}` : lighting.zone}
-
+
{zones.map((zone, index) => (
({ x: dpi, y: dpi, lod: 0 }));
+ onboardProfiles = [1, 2, 3].map((index) => ({
+ sector: index,
+ enabled: true,
+ isCurrent: index === 3,
+ name: `Profile ${index}`,
+ dpiStages: index === 3 ? currentStages : currentStages.slice(0, 2),
+ defaultDpiIndex: 0,
+ reportRateWireless: 1000,
+ reportRateWired: 1000,
+ angleSnapping: false,
+ powerSaveTimeoutSeconds: 60,
+ powerOffTimeoutSeconds: 300,
+ bunnyHoppingMs: null,
+ buttonAssignments: actions.map((action, button) => ({ button, action, raw: [0x80, 0, 0, 0] })),
+ gShiftAssignments: actions.map((action, button) => ({ button, action, raw: [0x80, 0, 0, 0] })),
+ crcValid: true,
+ raw: new Uint8Array(255),
+ } as OnboardProfile));
+ dpiOptions = [50, 100, 200, 400, 450, 800, 1600, 3200, 6400, 12000];
+ active = previewClient();
+}
+
async function showFixturePreview(name: PreviewMode): Promise {
const { PREVIEW_FIXTURES } = await import("../preview-fixtures");
await loadPreviewEntries();
@@ -3198,6 +3223,7 @@ async function showFixturePreview(name: PreviewMode): Promise {
return;
}
dpiOptions = [100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 32000];
+ if (name === "g703") showG703PreviewProfiles();
// Populate brand capabilities so preview cards that gate on capabilities still render.
capabilities = readCapabilities();
applyStatus(fixture.status);
diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts
index 369b79d..395200d 100644
--- a/src/preview-fixtures.ts
+++ b/src/preview-fixtures.ts
@@ -620,6 +620,58 @@ const LOGITECH_LEGACY: MouseStatus = {
firmware: ["MPM 12.01"],
};
+const LOGITECH_G703_ZONE = (zone: string, color: string): MouseLighting => ({
+ zone,
+ modes: ["Off", "Static", "Cycling", "Wave", "Breathing single"],
+ mode: "Static",
+ color,
+ color2: null,
+ colorModes: ["Static", "Breathing single"],
+ dualColorModes: [],
+ reactiveModes: ["Cycling", "Wave", "Breathing single"],
+ speeds: [1000, 2000, 3000, 5000, 10000],
+ speed: 5000,
+ writeOnly: true,
+});
+
+/** Wired G703 capture: legacy DPI, format-3 HEAT, Primary/Logo write-only RGB. */
+const LOGITECH_G703: MouseStatus = {
+ brand: "Logitech",
+ name: "G703",
+ ui: {
+ family: "logitech-hidpp",
+ lodRequiresSurface: true,
+ hideUnsupportedPollingRates: true,
+ defaultDisplayName: "G703",
+ },
+ batteryPercent: 78,
+ batteryState: "Charging",
+ dpi: 450,
+ pollingRateHz: 1000,
+ supportedPollingRates: [125, 250, 500, 1000],
+ activeProfile: 3,
+ deviceMode: "Onboard",
+ liftOffDistance: null,
+ supportedLiftOffDistances: [],
+ connectionType: "Wired",
+ connectionDetail: "Wired USB",
+ transportIds: { USB: "C087" },
+ onboardProfileFormat: {
+ id: 3,
+ name: "HEAT",
+ base: "v1",
+ supported: true,
+ verified: true,
+ writable: true,
+ },
+ lighting: LOGITECH_G703_ZONE("Primary", "#7c5cff"),
+ lightingZones: [
+ LOGITECH_G703_ZONE("Primary", "#7c5cff"),
+ LOGITECH_G703_ZONE("Logo", "#35d59a"),
+ ],
+ firmware: ["MPM 14.02", "BOT 64.00", "RQI 04.00"],
+};
+
/** Logi Bolt productivity mouse: DPI + battery, no report-rate or LOD feature. */
const LOGITECH_MX_MASTER_3S: MouseStatus = {
brand: "Logitech",
@@ -666,11 +718,12 @@ export const PREVIEW_FIXTURES: Record = {
"razer-viper-mini": { label: "Razer Viper Mini", status: RAZER_VIPER_MINI },
"razer-cobra": { label: "Razer Cobra", status: RAZER_COBRA },
"razer-viper-v4": { label: "Razer Viper V4 Pro", status: RAZER_VIPER_V4 },
- teevolution: { label: "Teevolution Terra Pro", status: TEEVOLUTION },
+ "terra-pro": { label: "Teevolution Terra Pro", status: TEEVOLUTION },
vgn: { label: "VGN F2 Master Plus", status: VGN },
finalmouse: { label: "Finalmouse UltralightX", status: FINALMOUSE },
ninjutso: { label: "Ninjutso Sora V3", status: NINJUTSO },
- keychron: { label: "Keychron Nape Pro", status: KEYCHRON },
+ "nape-pro": { label: "Keychron Nape Pro", status: KEYCHRON },
"mx-master-3s": { label: "Logitech MX Master 3S", status: LOGITECH_MX_MASTER_3S },
+ g703: { label: "Logitech G703", status: LOGITECH_G703 },
"logitech-legacy": { label: "Logitech G402 (legacy DPI)", status: LOGITECH_LEGACY },
};
diff --git a/src/preview-modes.ts b/src/preview-modes.ts
index e0231bc..3a7b6bb 100644
--- a/src/preview-modes.ts
+++ b/src/preview-modes.ts
@@ -25,12 +25,13 @@ export const PREVIEW_KEYS = [
"razer-viper-mini",
"razer-cobra",
"razer-viper-v4",
- "teevolution",
"vgn",
"finalmouse",
"ninjutso",
- "keychron",
+ "nape-pro",
"mx-master-3s",
+ "g703",
+ "terra-pro",
"logitech-legacy",
] as const;
@@ -66,12 +67,13 @@ export function parsePreviewMode(value: string | null): PreviewMode | null {
case "razer-viper-mini": return "razer-viper-mini";
case "razer-cobra": return "razer-cobra";
case "razer-viper-v4": return "razer-viper-v4";
- case "teevolution": return "teevolution";
+ case "terra-pro": return "terra-pro";
case "vgn": return "vgn";
case "finalmouse": return "finalmouse";
case "ninjutso": return "ninjutso";
- case "keychron": return "keychron";
+ case "nape-pro": return "nape-pro";
case "mx-master-3s": return "mx-master-3s";
+ case "g703": return "g703";
case "logitech-legacy": return "logitech-legacy";
default: return null;
}
diff --git a/src/supported-mice.ts b/src/supported-mice.ts
index d53dfa6..1962926 100644
--- a/src/supported-mice.ts
+++ b/src/supported-mice.ts
@@ -92,6 +92,8 @@ export const MICE: Mouse[] = [
{ brand: "Logitech", model: "G403 HERO (wired)", status: "supported", req: 1,
pids: [0xc08f],
note: "PID 0xc08f — HID++ direct, DPI feature 0x2202" },
+ { brand: "Logitech", model: "G703 (wired)", status: "supported", req: 1,
+ note: "PID 0xc087 — HID++ direct, legacy DPI 0x2201, onboard format 3 (HEAT) writable" },
{ brand: "Logitech", model: "G Pro X Superlight", status: "supported", req: 4,
pids: [0xc094],
note: "PID 0xc094 — HID++ direct" },
diff --git a/src/ui/device-images.test.ts b/src/ui/device-images.test.ts
index 6b4b88d..37fc0f0 100644
--- a/src/ui/device-images.test.ts
+++ b/src/ui/device-images.test.ts
@@ -11,6 +11,13 @@ test("G502 family USB interfaces use their matching normalized artwork", () => {
assert.equal(deviceImage(hid(0xc099)), "/devices/logitech-g502-x.png");
});
+test("G703 wired PIDs and Lightspeed name fallback use the G703 render", () => {
+ assert.equal(deviceImage(hid(0xc087)), "/devices/logitech-g703.png");
+ assert.equal(deviceImage(hid(0xc090)), "/devices/logitech-g703.png");
+ assert.equal(deviceImage(hid(0xc539), "G703 HERO"), "/devices/logitech-g703.png");
+ assert.equal(deviceImage(null, "G703 Wired/Wireless Gaming Mouse"), "/devices/logitech-g703.png");
+});
+
test("G502 X receiver artwork follows the paired mouse name", () => {
assert.equal(deviceImage(hid(0xc547), "G502 X PLUS"), "/devices/logitech-g502-x-plus.png");
assert.equal(deviceImage(hid(0xc547), "G502 X"), "/devices/logitech-g502-x.png");
@@ -30,6 +37,7 @@ test("fixture previews resolve product art without a HID device", () => {
assert.equal(deviceImage(null, "Cobra"), "/devices/razer-cobra.webp");
assert.equal(deviceImage(null, "Terra Pro"), "/devices/teevolution-terra-pro.png");
assert.equal(deviceImage(null, "MX Master 3S"), "/devices/logitech-mx-master-3s.png");
+ assert.equal(deviceImage(null, "G703"), "/devices/logitech-g703.png");
});
test("Pulsar 4K receiver artwork follows the reported mouse name", () => {
diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts
index ecc3a11..82726c2 100644
--- a/src/ui/device-images.ts
+++ b/src/ui/device-images.ts
@@ -14,6 +14,9 @@ const DEVICE_IMAGES: ReadonlyMap = new Map([
["046d:c095", "/devices/logitech-g502-x-plus.png"],
["046d:c099", "/devices/logitech-g502-x.png"],
["046d:c0a8", "/devices/logitech-pro-x2-superstrike.png"],
+ // Original G703 (0xc087) and G703 HERO wired (0xc090) share the same shell.
+ ["046d:c087", "/devices/logitech-g703.png"],
+ ["046d:c090", "/devices/logitech-g703.png"],
// M3K and M2K use the supplied M3K product artwork.
["0483:a462", "/devices/zaunkoenig-m3k.png"],
["0483:a3cf", "/devices/zaunkoenig-m3k.png"],
@@ -78,6 +81,7 @@ export function deviceImage(device: HIDDevice | null | undefined, displayName =
if (/g502\s*x\s*plus/i.test(displayName)) return "/devices/logitech-g502-x-plus.png";
if (/g502\s*x/i.test(displayName)) return "/devices/logitech-g502-x.png";
if (/\bg502\b/i.test(displayName)) return "/devices/logitech-g502.png";
+ if (/\bg703\b/i.test(displayName)) return "/devices/logitech-g703.png";
if (/mx\s*master\s*4/i.test(displayName)) return "/devices/logitech-mx-master-4.png";
if (/superstrike/i.test(displayName)) return "/devices/logitech-pro-x2-superstrike.png";
if (/superlight/i.test(displayName)) return "/devices/logitech-pro-x-superlight-2c.png";