Skip to content
Open
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
5 changes: 5 additions & 0 deletions public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file added public/devices/logitech-g703.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions src/app/cards/LightingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,7 +60,11 @@ export function LightingCard({
<span>RGB parts</span>
<strong>{lighting.group ? `${lighting.group} · ${lighting.zone}` : lighting.zone}</strong>
</div>
<div className="lighting-strip" role="list" aria-label="G502 X Plus lightstrip LEDs">
<div
className={`lighting-strip${hasLightstrip ? " is-lightstrip" : ""}`}
role="list"
aria-label={hasLightstrip ? "Lightstrip LEDs" : "RGB zones"}
>
{zones.map((zone, index) => (
<button
key={`${zone.zone}-${index}`}
Expand Down
7 changes: 4 additions & 3 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -707,12 +707,13 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
.lighting-zone-copy { display:grid;gap:.25rem }
.lighting-zone-copy span { color:var(--faint);font-family:var(--font-mono);font-size:.55rem;letter-spacing:.09em;text-transform:uppercase }
.lighting-zone-copy strong { color:var(--text-hover);font-size:.82rem;font-weight:550 }
.lighting-strip { display:grid;grid-template-columns:1.15fr repeat(8,1fr);gap:.3rem;align-items:center }
.lighting-strip { display:grid;grid-template-columns:repeat(auto-fit,minmax(2.6rem,4rem));gap:.3rem;align-items:center }
.lighting-strip.is-lightstrip { grid-template-columns:1.15fr repeat(8,1fr) }
.lighting-strip button { position:relative;min-width:0;height:2.2rem;padding:0;border:1px solid color-mix(in srgb,var(--zone-color) 45%,var(--border));border-radius:7px;background:color-mix(in srgb,var(--zone-color) 74%,#090b0d);color:#fff;font-family:var(--font-mono);font-size:.52rem;box-shadow:inset 0 0 14px color-mix(in srgb,var(--zone-color) 50%,transparent);cursor:pointer }
.lighting-strip button:first-child { margin-right:.2rem;background:var(--surface-control);box-shadow:none;color:var(--muted) }
.lighting-strip.is-lightstrip button:first-child { margin-right:.2rem;background:var(--surface-control);box-shadow:none;color:var(--muted) }
.lighting-strip button:hover,.lighting-strip button.is-selected { border-color:var(--ui-accent);outline:1px solid color-mix(in srgb,var(--ui-accent) 35%,transparent);outline-offset:1px }
.lighting-strip button.is-selected::after { position:absolute;right:25%;bottom:-.48rem;left:25%;height:2px;border-radius:2px;background:var(--ui-accent);content:"" }
@media (max-width:760px) { .lighting-zone-picker { grid-template-columns:1fr }.lighting-strip { overflow-x:auto;grid-template-columns:3.5rem repeat(8,minmax(2.2rem,1fr)) } }
@media (max-width:760px) { .lighting-zone-picker { grid-template-columns:1fr }.lighting-strip { overflow-x:auto }.lighting-strip.is-lightstrip { grid-template-columns:3.5rem repeat(8,minmax(2.2rem,1fr)) } }

.dpi-header-actions { display: flex; align-items: center; gap: .45rem; }
.dpi-header-actions button { padding: .38rem .6rem; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--surface-input); color: var(--dim); font-size: .62rem; font-weight: 700; white-space: nowrap; }
Expand Down
26 changes: 26 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3186,6 +3186,31 @@ function showSuperstrikePreview(): void {
setReadStatus("Current: 800 DPI · 4,000 Hz");
}

function showG703PreviewProfiles(): void {
const actions = ["Left click", "Right click", "Middle click", "Back", "Forward", "DPI Shift", "Next DPI"] as const;
const currentStages = [450, 800, 1600, 12000].map((dpi) => ({ 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<void> {
const { PREVIEW_FIXTURES } = await import("../preview-fixtures");
await loadPreviewEntries();
Expand All @@ -3198,6 +3223,7 @@ async function showFixturePreview(name: PreviewMode): Promise<void> {
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);
Expand Down
57 changes: 55 additions & 2 deletions src/preview-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -666,11 +718,12 @@ export const PREVIEW_FIXTURES: Record<FixturePreviewMode, PreviewFixture> = {
"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 },
};
10 changes: 6 additions & 4 deletions src/preview-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/supported-mice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
8 changes: 8 additions & 0 deletions src/ui/device-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/ui/device-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const DEVICE_IMAGES: ReadonlyMap<string, string> = 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"],
Expand Down Expand Up @@ -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";
Expand Down