@@ -41,6 +51,30 @@ export function LightingCard({ + {zones && zones.length > 1 ? ( + + + RGB parts + {lighting.group ? `${lighting.group} · ${lighting.zone}` : lighting.zone} + + + {zones.map((zone, index) => ( + setSelectedZone(index)} + >{zone.hardwareZoneId ?? "FX"} + ))} + + + ) : null} + ({ value: candidate, label: candidate }))} value={mode} disabled={disabled} - onChange={(next) => control.applyLighting({ mode: next as typeof mode })} + onChange={(next) => control.applyLighting({ mode: next as typeof mode }, activeZoneIndex)} /> {brightnessLevels.length > 0 ? ( @@ -60,7 +94,7 @@ export function LightingCard({ options={brightnessLevels.map((level) => ({ value: level, label: `${level}%` }))} value={lighting.brightness} disabled={disabled} - onChange={(brightness) => control.applyLighting({ brightness })} + onChange={(brightness) => control.applyLighting({ brightness }, activeZoneIndex)} /> ) : null} @@ -77,7 +111,7 @@ export function LightingCard({ value={lighting.color ?? "#00ff00"} disabled={disabled} aria-label="Lighting colour" - onChange={(event) => control.applyLighting({ color: event.currentTarget.value })} + onChange={(event) => control.applyLighting({ color: event.currentTarget.value }, activeZoneIndex)} /> {usesColor2 ? ( @@ -89,7 +123,7 @@ export function LightingCard({ value={lighting.color2 ?? "#ff0000"} disabled={disabled} aria-label="Second lighting colour" - onChange={(event) => control.applyLighting({ color2: event.currentTarget.value })} + onChange={(event) => control.applyLighting({ color2: event.currentTarget.value }, activeZoneIndex)} /> ) : null} @@ -103,7 +137,7 @@ export function LightingCard({ value={lighting.color ?? "#00ff00"} disabled={disabled} ariaLabel="Lighting colour" - onChange={(color) => control.applyLighting({ color })} + onChange={(color) => control.applyLighting({ color }, activeZoneIndex)} /> {usesColor2 ? ( @@ -114,7 +148,7 @@ export function LightingCard({ value={lighting.color2 ?? "#ff0000"} disabled={disabled} ariaLabel="Second lighting colour" - onChange={(color2) => control.applyLighting({ color2 })} + onChange={(color2) => control.applyLighting({ color2 }, activeZoneIndex)} /> ) : null} @@ -137,7 +171,7 @@ export function LightingCard({ key={`speed-${lighting.speed}`} disabled={disabled} aria-label="Effect speed" - onChange={(event) => control.applyLighting({ speed: Number(event.currentTarget.value) })} + onChange={(event) => control.applyLighting({ speed: Number(event.currentTarget.value) }, activeZoneIndex)} /> ) : ( ({ value: speed, label: String(speed) }))} value={lighting.speed} disabled={disabled} - onChange={(speed) => control.applyLighting({ speed })} + onChange={(speed) => control.applyLighting({ speed }, activeZoneIndex)} /> )} diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts index 4184071c..fcc0b53f 100644 --- a/src/app/cards/availability.ts +++ b/src/app/cards/availability.ts @@ -93,8 +93,8 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability { sensor: sensor && ready, lightforce: Boolean(status.lightforceSwitchMode), superstrike: traits.logitech && status.analogButtonTuning?.buttons.length === 2, - lighting: Boolean(status.lighting), - lightingAdvanced: host && Boolean(status.lighting), + lighting: Boolean(status.lighting || status.lightingZones?.length), + lightingAdvanced: host && Boolean(status.lighting || status.lightingZones?.length), profiles: traits.logitech && status.deviceMode !== undefined && status.deviceMode !== "Unknown", logitechDetails: traits.logitech, diff --git a/src/check-scan.ts b/src/check-scan.ts index d3794f61..89acba29 100644 --- a/src/check-scan.ts +++ b/src/check-scan.ts @@ -21,7 +21,7 @@ export const BRANDS: Record = { 0x3710: "Pulsar", 0x3367: "Endgame Gear", 0x36A7: "WLMouse", - 0x373E: "Lamzu", + 0x373E: "Lamzu / CRDRAKO / Attack Shark", 0x3554: "Teevolution / VGN", 0x373B: "ATK", 0x361D: "Finalmouse", diff --git a/src/control-profiles.css b/src/control-profiles.css index 3b123a97..a2a8f3f6 100644 --- a/src/control-profiles.css +++ b/src/control-profiles.css @@ -94,6 +94,49 @@ .control-shell .settings-grid.has-dpi-slots { grid-template-rows:minmax(176px,auto) } .control-shell .settings-grid.has-dpi-slots .dpi-card { grid-row:auto } +.profile-button-editor { margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border-rule) } +.profile-button-heading { display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;margin-bottom:.8rem } +.profile-button-heading p { margin:0 0 .3rem;color:var(--faint);font-family:var(--font-mono);font-size:.55rem;letter-spacing:.09em } +.profile-button-heading h2 { margin:0;color:var(--text-hover);font-size:1rem;font-weight:550 } +.profile-button-heading small { display:block;margin-top:.25rem;color:var(--text-faint);font-size:.62rem } +.assignment-layer-tabs { display:flex;padding:.2rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-control) } +.assignment-layer-tabs button { min-width:5rem;padding:.38rem .55rem;border:0;border-radius:5px;background:transparent;color:var(--muted);font-size:.62rem;cursor:pointer } +.assignment-layer-tabs button[aria-selected="true"] { background:var(--surface-raised);color:var(--ui-accent);box-shadow:0 1px 5px rgb(0 0 0 / 25%) } +.assignment-grid { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem } +.assignment-card { display:grid;grid-template-columns:2rem minmax(5rem,.55fr) minmax(9rem,1fr);align-items:center;gap:.55rem;min-width:0;padding:.55rem .6rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-raised) } +.assignment-button-number { display:grid;width:1.8rem;height:1.8rem;place-items:center;border:1px solid var(--border);border-radius:6px;background:var(--surface-control);color:var(--ui-accent);font-family:var(--font-mono);font-size:.55rem } +.assignment-button-name { overflow:hidden;color:var(--text-hover);font-size:.65rem;text-overflow:ellipsis;white-space:nowrap } +.assignment-select-wrap { position:relative;min-width:0 } +.assignment-select-wrap select { width:100%;box-sizing:border-box;padding:.42rem 1.8rem .42rem .55rem;border:1px solid var(--border);border-radius:6px;appearance:none;background:var(--surface-control);color:var(--text-input);color-scheme:dark;font-size:.64rem;cursor:pointer } +.assignment-select-wrap select:hover:not(:disabled),.assignment-select-wrap select:focus-visible { border-color:var(--ui-accent);outline:0 } +.assignment-select-wrap i { position:absolute;top:50%;right:.65rem;width:.32rem;height:.32rem;border-right:1px solid var(--muted);border-bottom:1px solid var(--muted);pointer-events:none;transform:translateY(-65%) rotate(45deg) } +@media (max-width:900px) { .assignment-grid { grid-template-columns:1fr } } +@media (max-width:560px) { .profile-button-heading { align-items:stretch;flex-direction:column }.assignment-card { grid-template-columns:2rem 1fr }.assignment-select-wrap { grid-column:1 / -1 } } +.shortcut-dialog-backdrop { position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:1rem;background:rgb(0 0 0 / 68%);backdrop-filter:blur(4px) } +.shortcut-dialog { width:min(25rem,100%);box-sizing:border-box;padding:1.2rem;border:1px solid var(--border-hover);border-radius:11px;background:var(--surface-menu);box-shadow:0 20px 60px rgb(0 0 0 / 60%);outline:0 } +.shortcut-dialog:focus-visible { border-color:var(--ui-accent) } +.shortcut-dialog-kicker { color:var(--ui-accent);font-family:var(--font-mono);font-size:.54rem;letter-spacing:.1em } +.shortcut-dialog h3 { margin:.35rem 0 .3rem;color:var(--text-hover);font-size:1.05rem;font-weight:550 } +.shortcut-dialog p { margin:0;color:var(--text-faint);font-size:.66rem;line-height:1.5 } +.shortcut-record-button { display:flex;align-items:center;justify-content:center;gap:.45rem;width:100%;margin-top:1rem;padding:.55rem;border:1px solid var(--border-hover);border-radius:7px;background:var(--surface-control);color:var(--text-hover);font-size:.67rem;font-weight:650;cursor:pointer } +.shortcut-record-button i { width:.55rem;height:.55rem;border-radius:50%;background:#e2686f;box-shadow:0 0 0 3px rgb(226 104 111 / 12%) } +.shortcut-record-button.is-recording { border-color:#e2686f;color:#f08b91 } +.shortcut-record-button.is-recording i { border-radius:2px;animation:shortcut-pulse 1s ease-in-out infinite } +@keyframes shortcut-pulse { 50% { opacity:.45 } } +.shortcut-capture { display:grid;min-height:3.25rem;margin:1rem 0 .45rem;place-items:center;border:1px dashed var(--border-hover);border-radius:8px;background:var(--surface-control);color:var(--muted);font-family:var(--font-mono);font-size:.78rem } +.shortcut-capture.has-value { border-style:solid;border-color:var(--ui-accent);color:var(--text-hover);box-shadow:inset 0 0 18px color-mix(in srgb,var(--ui-accent) 8%,transparent) } +.shortcut-capture ol { display:flex;width:100%;box-sizing:border-box;align-items:center;gap:.4rem;overflow-x:auto;margin:0;padding:.7rem;list-style:none } +.shortcut-capture li { display:flex;flex:0 0 auto;align-items:center;gap:.35rem } +.shortcut-capture li + li::before { color:var(--ghost);content:"→" } +.shortcut-capture kbd { padding:.35rem .48rem;border:1px solid var(--border-hover);border-radius:5px;background:var(--surface-raised);color:var(--text-hover);font:600 .63rem var(--font-mono);box-shadow:0 2px 0 var(--border) } +.shortcut-capture li small { color:var(--ghost);font-size:.48rem } +.shortcut-error { display:block;color:#e2686f;font-size:.62rem } +.shortcut-macro-note { display:block;margin-top:.45rem;color:var(--text-faint);font-size:.59rem;line-height:1.4 } +.shortcut-dialog-actions { display:flex;justify-content:flex-end;gap:.45rem;margin-top:1rem } +.shortcut-dialog-actions button { padding:.45rem .7rem;border:1px solid var(--border);border-radius:6px;background:var(--surface-control);color:var(--muted);font-size:.63rem;cursor:pointer } +.shortcut-dialog-actions button.is-primary { border-color:var(--ui-accent);background:var(--ui-accent);color:var(--ui-accent-ink);font-weight:650 } +.shortcut-dialog-actions button:disabled { opacity:.4;cursor:default } + /* `.segmented` and friends set display, which outranks the user-agent `[hidden] { display: none }` rule — so hiding those rows by setting the hidden property silently did nothing. Make the attribute win everywhere. */ diff --git a/src/control.css b/src/control.css index 63943358..a6265db0 100644 --- a/src/control.css +++ b/src/control.css @@ -688,6 +688,16 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .lighting-color-picker.is-disabled { opacity: .45; pointer-events: none; } .lighting-speed-row { margin-top: 1rem; } .lighting-speed-row .segmented { grid-template-columns: repeat(4, 1fr); max-width: 15rem; } +.lighting-zone-picker { display:grid;grid-template-columns:minmax(9rem,.35fr) minmax(18rem,1fr);align-items:end;gap:1rem;margin:0 0 1rem;padding:.8rem;border:1px solid var(--border);border-radius:9px;background:var(--surface-raised) } +.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 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 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)) } } .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; } diff --git a/src/device/controller.ts b/src/device/controller.ts index 1c777325..08ada64f 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -72,6 +72,9 @@ import { validateBunnyHoppingMs, type DpiStageCapabilities, type DpiStagePlan, + type LogitechButtonAction, + type LogitechButtonBinding, + type LogitechMacroStep, } from "@openmouse/protocol/drivers/logitech/onboard-profiles"; import { setCaptureContext } from "../capture-context"; import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; @@ -1916,6 +1919,69 @@ export async function toggleOnboardProfileEnabled(sector: number, enabled: boole } } +export async function applyLogitechButtonAssignment( + layer: "primary" | "g-shift", + button: number, + binding: LogitechButtonAction | LogitechButtonBinding, +): Promise { + const client = logitechClient(); + const entry = editedProfileEntry(); + if (!client || !entry || settingInProgress) return; + settingInProgress = true; + const label = typeof binding === "string" ? binding : binding.kind === "keyboard" ? "keyboard shortcut" : "media key"; + setOnboardStatus(`Assigning button ${button + 1} to ${label}…`); + recordDiagnosticCommand(`Map ${layer} button ${button + 1} to ${label}`); + try { + await client.writeActiveProfile({ + sector: entry.sector, + buttonAssignments: [{ layer, button, binding }], + }); + await reloadOnboardProfiles(); + setOnboardStatus(`Button ${button + 1} is now ${label}.`); + } catch (error) { + recordDiagnosticError(error, "Unable to change the button assignment."); + setOnboardStatus(error instanceof Error ? error.message : "Unable to change the button assignment."); + } finally { + endDeviceWrite(); + } +} + +export function applyLogitechConsumerAssignment(layer: "primary" | "g-shift", button: number, usage: number): void { + void applyLogitechButtonAssignment(layer, button, { kind: "consumer", usage }); +} + +export function applyLogitechKeyboardShortcut( + layer: "primary" | "g-shift", + button: number, + key: number, + modifiers: number, +): void { + void applyLogitechButtonAssignment(layer, button, { kind: "keyboard", key, modifiers }); +} + +export async function applyLogitechKeyboardSequence( + layer: "primary" | "g-shift", + button: number, + steps: LogitechMacroStep[], +): Promise { + const client = logitechClient(); + const entry = editedProfileEntry(); + if (!client || !entry || settingInProgress || !steps.length) return; + settingInProgress = true; + setOnboardStatus(`Saving ${steps.length}-step macro to button ${button + 1}…`); + recordDiagnosticCommand(`Map ${layer} button ${button + 1} to a ${steps.length}-step onboard macro`); + try { + await client.writeActiveProfile({ sector: entry.sector, buttonMacros: [{ layer, button, steps }] }); + await reloadOnboardProfiles(); + setOnboardStatus(`Button ${button + 1} now runs the recorded ${steps.length}-step macro.`); + } catch (error) { + recordDiagnosticError(error, "Unable to save the onboard macro."); + setOnboardStatus(error instanceof Error ? error.message : "Unable to save the onboard macro."); + } finally { + endDeviceWrite(); + } +} + export function renameOnboardProfile(sector: number): void { const entry = onboardProfiles?.find((profile) => profile.sector === sector); if (!entry || !logitechClient()) return; @@ -2294,23 +2360,29 @@ export function describeLighting(lighting: MouseLighting): string { export function applyLighting( patch: Partial>, + zoneIndex = 0, ): void { - if (!hasActiveClient() || !latestDeviceStatus?.lighting) { + const deviceStatus = latestDeviceStatus; + const source = deviceStatus?.lightingZones?.[zoneIndex] ?? (zoneIndex === 0 ? deviceStatus?.lighting : undefined); + if (!hasActiveClient() || !deviceStatus || !source) { setReadStatus("Lighting is not available for this mouse."); return; } - const staged = { ...withPendingChanges(latestDeviceStatus).lighting!, ...patch } as MouseLighting; + const pendingStatus = withPendingChanges(deviceStatus); + const stagedSource = pendingStatus.lightingZones?.[zoneIndex] ?? pendingStatus.lighting!; + const staged = { ...stagedSource, ...patch } as MouseLighting; if (!staged.mode) { setReadStatus("Pick an effect first."); return; } stageChange({ - key: "lighting", - label: `Lighting ${staged.mode.toLowerCase()}`, - command: `Set lighting to ${describeLighting(staged)}`, + key: `lighting-${zoneIndex}`, + label: `${staged.zone} lighting ${staged.mode.toLowerCase()}`, + command: `Set ${staged.zone} lighting to ${describeLighting(staged)}`, progress: `Setting ${staged.mode.toLowerCase()} lighting…`, preview: (status) => { - if (status.lighting) status.lighting = { ...status.lighting, ...staged } as MouseLighting; + if (status.lightingZones?.[zoneIndex]) status.lightingZones[zoneIndex] = { ...staged }; + if (zoneIndex === 0 && status.lighting) status.lighting = { ...status.lighting, ...staged } as MouseLighting; }, apply: async () => { await requireClientMethod("setLighting", "the lighting").setLighting(staged); @@ -2633,7 +2705,7 @@ async function loadPreviewEntries(): Promise { if (!previewModeEnabled || previewEntries.length > 0) return; const { PREVIEW_FIXTURES } = await import("../preview-fixtures"); previewEntries = [ - ["slots", "Logitech PRO X Superlight 2"], + ["slots", "Logitech G502 X PLUS"], ["superstrike", "Logitech PRO X 2 Superstrike"], ...Object.entries(PREVIEW_FIXTURES).map(([key, fixture]) => [key, fixture.label] as [string, string]), ]; @@ -2646,7 +2718,7 @@ function previewClient(): LogitechHidppClient { }; // Real prototype: the driver accessors test with instanceof. return Object.assign(Object.create(LogitechHidppClient.prototype) as LogitechHidppClient, { - writeActiveProfile: refuse, + writeActiveProfile: async () => undefined, setBunnyHoppingMs: refuse, setProfileDpiStages: refuse, setModeStatus: refuse, @@ -2663,6 +2735,8 @@ function showSlotsPreview(): void { { x: 2400, y: 2400, lod: 2 }, { x: 3200, y: 3200, lod: 3 }, ]; + const normalActions = ["Left click", "Right click", "Middle click", "Back", "Forward", "DPI Shift", "Next DPI", "Cycle profiles"] as const; + const shiftedActions = ["Previous profile", "Next profile", "Battery indicator", "Tilt left", "Tilt right", "Default DPI", "Previous DPI", "Disabled"] as const; onboardProfiles = [1, 2, 3].map((index) => ({ sector: index, enabled: index !== 3, @@ -2676,12 +2750,15 @@ function showSlotsPreview(): void { powerSaveTimeoutSeconds: 60, powerOffTimeoutSeconds: 300, bunnyHoppingMs: 100, + buttonAssignments: normalActions.map((action, button) => ({ button, action, raw: [0x90, 0, 0, 0] })), + gShiftAssignments: shiftedActions.map((action, button) => ({ button, action, raw: [0x90, 0, 0, 0] })), crcValid: true, + raw: new Uint8Array(255), } as OnboardProfile)); active = previewClient(); applyStatus({ brand: "Logitech", - name: "PRO X SUPERLIGHT 2", + name: "G502 X PLUS", ui: { family: "logitech-hidpp", lodRequiresSurface: true }, batteryPercent: 72, batteryState: "Discharging", @@ -2695,13 +2772,42 @@ function showSlotsPreview(): void { onboardProfileFormat: { id: 7, name: "unnamed (v6 + bunny hopping)", base: "v6", supported: true, verified: true, writable: true }, gamingSurfaceMode: "Auto", lightforceSwitchMode: "Hybrid", + lighting: { + zone: "Combined", modes: ["Off", "Static", "Cycling", "Wave", "Breathing single"], mode: "Static", + color: "#7c5cff", color2: null, colorModes: ["Static", "Breathing single"], dualColorModes: [], + reactiveModes: ["Cycling", "Wave", "Breathing single"], speeds: [1000, 2000, 3000, 5000, 10000], speed: 5000, + writeOnly: true, + }, + lightingZones: [ + { + zone: "Combined", modes: ["Off", "Static", "Cycling", "Wave", "Breathing single"], mode: "Static", + color: "#7c5cff", color2: null, colorModes: ["Static", "Breathing single"], dualColorModes: [], + reactiveModes: ["Cycling", "Wave", "Breathing single"], speeds: [1000, 2000, 3000, 5000, 10000], speed: 5000, + writeOnly: true, + }, + ...["#7c5cff", "#5f78ff", "#39a7ff", "#2bc8d4", "#35d59a", "#a0dc55", "#f0c247", "#ff744f"].map((color, index) => ({ + zone: `LED ${index + 1}`, + group: "Lightstrip", + hardwareZoneId: index + 1, + modes: ["Off", "Static"] as const, + mode: "Static" as const, + color, + color2: null, + colorModes: ["Static"] as const, + dualColorModes: [] as const, + reactiveModes: [] as const, + speeds: [] as const, + speed: null, + writeOnly: true, + })), + ], activeProfile: 1, deviceMode: "Onboard", connectionType: "Wireless", firmware: ["MPM 39.00.B0004"], }); setConnectionButtons(true, "Preview mode"); - setReadStatus("Preview: profile format 7 with five DPI slots."); + setReadStatus("Preview: G502 X PLUS performance, onboard profiles, button remapping and RGB lighting."); } function showSuperstrikePreview(): void { diff --git a/src/device/traits.ts b/src/device/traits.ts index 585121f7..48798f5a 100644 --- a/src/device/traits.ts +++ b/src/device/traits.ts @@ -41,6 +41,7 @@ const BY_FAMILY: Readonly>> = { vgn: SHARED_ADVANCED, wlmouse: DIRECT_MODE, lamzu: DIRECT_MODE, + "attack-shark": DIRECT_MODE, crdrako: DIRECT_MODE, atk: DIRECT_MODE, ninjutso: { ...DIRECT_MODE, ninjutso: true }, @@ -52,6 +53,7 @@ const BY_BRAND: Readonly> = { Pulsar: "pulsar", VGN: "vgn", Logitech: "logitech-hidpp", + "Attack Shark": "attack-shark", }; export function familyOf(status: MouseStatus): string { diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts index 40b2d6d5..fe5881e5 100644 --- a/src/preview-fixtures.ts +++ b/src/preview-fixtures.ts @@ -198,6 +198,29 @@ const CRDRAKO: MouseStatus = { firmware: ["Mouse 1.0.3", "Dongle 1.0.2"], }; +const ATTACKSHARK: MouseStatus = { + brand: "Attack Shark" as MouseStatus["brand"], + name: "Attack Shark R5 Ultra", + ui: { family: "attack-shark", hideUnsupportedPollingRates: true, forceShowBattery: true }, + batteryPercent: 82, + batteryState: "Discharging", + dpi: 1600, + dpiY: 1600, + supportsSeparateDpiAxes: true, + pollingRateHz: 8000, + supportedPollingRates: [500, 1000, 2000, 4000, 8000], + activeProfile: 1, + liftOffDistance: "Medium", + connectionType: "Wireless", + connectionDetail: "2.4 GHz receiver", + motionSync: true, + angleSnapping: false, + rippleControl: false, + debounceMs: 3, + sleepTimeout: 60, + firmware: ["Mouse 1.0.4", "Dongle 1.0.3"], +}; + const M3K: MouseStatus = { // The Zaunkoenig driver is being prepared in mouse-protocol. This assertion // can disappear when the website refreshes to that package revision. @@ -537,6 +560,7 @@ export const PREVIEW_FIXTURES: Record = { "egg-xm2we": { label: "Endgame Gear XM2we", status: EGG_XM2WE }, wlmouse: { label: "WLMouse BEAST X", status: WLMOUSE }, lamzu: { label: "Lamzu Atlantis OG V2", status: LAMZU }, + "attack-shark": { label: "Attack Shark R5 Ultra", status: ATTACKSHARK }, crdrako: { label: "CRDRAKO KO-ONE", status: CRDRAKO }, m3k: { label: "Zaunkoenig M3K", status: M3K }, atk: { label: "ATK A9 Ultra", status: ATK }, diff --git a/src/preview-modes.ts b/src/preview-modes.ts index 3ff5ced2..2712dcb6 100644 --- a/src/preview-modes.ts +++ b/src/preview-modes.ts @@ -15,6 +15,7 @@ export const PREVIEW_KEYS = [ "egg-xm2we", "wlmouse", "lamzu", + "attack-shark", "crdrako", "m3k", "atk", @@ -53,6 +54,7 @@ export function parsePreviewMode(value: string | null): PreviewMode | null { case "egg-xm2we": return "egg-xm2we"; case "wlmouse": return "wlmouse"; case "lamzu": return "lamzu"; + case "attack-shark": return "attack-shark"; case "crdrako": return "crdrako"; case "m3k": return "m3k"; case "atk": return "atk"; diff --git a/src/ui/device-images.test.ts b/src/ui/device-images.test.ts index 1e59b012..57bf1eb9 100644 --- a/src/ui/device-images.test.ts +++ b/src/ui/device-images.test.ts @@ -21,3 +21,10 @@ test("fixture previews resolve product art without a HID device", () => { assert.equal(deviceImage(null, "Zaunkoenig M3K"), "/devices/zaunkoenig-m3k.png"); assert.equal(deviceImage(null, "Zaunkoenig M2K"), "/devices/zaunkoenig-m3k.png"); }); + +test("Attack Shark R5 Ultra wired and wireless share the same artwork", () => { + const hid373e = (productId: number): HIDDevice => ({ vendorId: 0x373e, productId } as HIDDevice); + assert.equal(deviceImage(hid373e(0x0046)), "/devices/attackshark-r5-ultra.png"); + assert.equal(deviceImage(hid373e(0x0047)), "/devices/attackshark-r5-ultra.png"); + assert.equal(deviceImage(null, "Attack Shark R5 Ultra"), "/devices/attackshark-r5-ultra.png"); +}); diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts index c3f0d487..11595651 100644 --- a/src/ui/device-images.ts +++ b/src/ui/device-images.ts @@ -26,6 +26,9 @@ const DEVICE_IMAGES: ReadonlyMap = new Map([ // CRDRAKO KO-ONE wired and receiver transports share the same shell. ["373e:006a", "/devices/crdrako-ko-one.png"], ["373e:006b", "/devices/crdrako-ko-one.png"], + // Attack Shark R5 Ultra wired and wireless transports share the same shell. + ["373e:0046", "/devices/attackshark-r5-ultra.png"], + ["373e:0047", "/devices/attackshark-r5-ultra.png"], // OP1 8K, Purple Frost, and v2. XM2 models use different shells. ["3367:1964", "/devices/endgame-gear-op1-8k.png"], ["3367:1976", "/devices/endgame-gear-op1-8k.png"], @@ -82,6 +85,7 @@ export function deviceImage(device: HIDDevice | null | undefined, displayName = if (/\bviper\s*v2\s*pro\b/i.test(displayName)) return "/devices/razer-viper-v2-pro.png"; if (/\bnape\s*pro\b/i.test(displayName)) return "/devices/keychron-nape-pro.png"; if (/\bko-one\b/i.test(displayName)) return "/devices/crdrako-ko-one.png"; + if (/\br5\s*ultra\b/i.test(displayName)) return "/devices/attackshark-r5-ultra.png"; if (/\bm[23]k\b/i.test(displayName)) return "/devices/zaunkoenig-m3k.png"; return "/devices/unknown-device.png"; }