diff --git a/public/devices/README.md b/public/devices/README.md index 27da93fc..7a9e0e49 100644 --- a/public/devices/README.md +++ b/public/devices/README.md @@ -182,3 +182,8 @@ package: place and falls back to the placeholder until then. Not yet cleared for licensing: it is vendor product art, so treat it as a request rather than an approved asset. + +`dareu-a950-pro-mg.png` was supplied by a contributor as a transparent top-down +render of the Dareu A950 PRO Mg. It is used locally for both the wired mouse +and its 2.4 GHz receiver. The image originated from Dareu's All in One Web +product artwork; confirm redistribution terms before a public release. diff --git a/public/devices/dareu-a950-pro-mg.png b/public/devices/dareu-a950-pro-mg.png new file mode 100644 index 00000000..17f0c57a Binary files /dev/null and b/public/devices/dareu-a950-pro-mg.png differ diff --git a/src/app/App.tsx b/src/app/App.tsx index 292f1236..65adb8fc 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -39,7 +39,7 @@ import { PulsarProCard, SignalCard, SleepCard, - TeevolutionDpiLightingCard, + DpiLightingCard, } from "./cards/AdvancedCards"; import { cardAvailability } from "./cards/availability"; import type { MouseStatus } from "@openmouse/protocol/drivers"; @@ -51,7 +51,9 @@ function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; + const has = cardAvailability(snapshot); const locale = snapshot.preferences.locale; + const powerOverview = status.ui?.powerOverview === true; const isWired = status.connectionType === "Wired"; const showBattery = !snapshot.traits.eggControls && (status.ui?.forceShowBattery || !isWired || status.batteryPercent !== null); @@ -60,7 +62,7 @@ function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode && status.dongleLedEnabled !== null && status.dongleLedEnabled !== undefined; - return ( + return <>
- ); + {powerOverview && (has.teevolutionDpiLighting || has.sleep) ? ( +
+ {has.teevolutionDpiLighting ? : null} + {has.sleep ? : null} +
+ ) : null} + ; } function Workspace({ @@ -124,6 +132,7 @@ function Workspace({ const locale = snapshot.preferences.locale; const has = cardAvailability(snapshot); const show = (available: boolean, tabs: readonly WorkspaceTab[]): boolean => available && on(tab, tabs); + const powerOverview = status.ui?.powerOverview === true; const performance = [ show(has.dpi, ["performance"]) ? : null, @@ -135,7 +144,7 @@ function Workspace({ const advanced = [ show(has.signal, ["advanced"]) ? : null, show(has.debounce, ["buttons"]) ? : null, - show(has.sleep, ["advanced"]) ? : null, + !powerOverview && show(has.sleep, ["advanced"]) ? : null, show(has.lightingAdvanced, ["advanced"]) ? : null, show(has.ninjutsoSensor, ["performance"]) @@ -169,8 +178,8 @@ function Workspace({ const lighting = [ show(has.lighting, ["lighting"]) ? : null, - show(has.teevolutionDpiLighting, ["lighting"]) - ? : null, + !powerOverview && show(has.teevolutionDpiLighting, ["lighting"]) + ? : null, ].filter((node) => node !== null); const showProfiles = show(has.profiles, ["profiles"]); @@ -315,7 +324,7 @@ export function App(): ReactNode { var tempTabs = WORKSPACE_TAB_ORDER; const has = cardAvailability(snapshot); if(status==null)return tempTabs; - if(!has.lighting&&!has.teevolutionDpiLighting)tempTabs=tempTabs.filter(tab=>tab!="lighting") + if(!has.lighting&&(!has.teevolutionDpiLighting||status.ui?.powerOverview===true))tempTabs=tempTabs.filter(tab=>tab!="lighting") if(!has.eggButtons&& !has.razerButtons&& !has.mxMasterButtons&& diff --git a/src/app/cards/AdvancedCards.tsx b/src/app/cards/AdvancedCards.tsx index 0a802068..05c80639 100644 --- a/src/app/cards/AdvancedCards.tsx +++ b/src/app/cards/AdvancedCards.tsx @@ -428,29 +428,35 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea ); } -export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { +export function DpiLightingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; - const profile = snapshot.capabilities?.teevolutionProfile; + const profile = snapshot.capabilities?.dpiLighting; if (!status || !profile) return null; const locale = snapshot.preferences.locale; const lightMode = status.dpiLedMode ?? 0; - const staged = snapshot.pending.keys.some((key) => key.startsWith("teevolution-dpi-light-")); + const teevolution = snapshot.traits.teevolution; + const powerOverview = status.ui?.powerOverview === true; + const staged = snapshot.pending.keys.some((key) => key.startsWith("teevolution-dpi-light-") || key.startsWith("dpi-light-")); + const apply = (setting: "mode" | "brightness" | "speed", value: number): void => { + if (teevolution) control.applyTeevolutionDpiLighting(setting, value); + else control.applyDpiLighting(setting, value); + }; return (
-

LIGHTING

{t(locale, "adv.dpiIndicator")}

+

{powerOverview ? "POWER" : "LIGHTING"}

{t(locale, "adv.dpiIndicator")}

@@ -464,16 +470,16 @@ export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnap control.applyTeevolutionDpiLighting("brightness", Number(event.currentTarget.value))} + onChange={(event) => apply("brightness", Number(event.currentTarget.value))} /> @@ -486,25 +492,42 @@ export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnap control.applyTeevolutionDpiLighting("speed", Number(event.currentTarget.value))} + onChange={(event) => apply("speed", Number(event.currentTarget.value))} /> - {t(locale, "adv.dpiStageNote")} + {profile.sleepTimeouts?.length && status.dpiLedSleepTimeout != null ? ( + + ) : null} + {teevolution ? {t(locale, "adv.dpiStageNote")} : null}
); } +/** @deprecated Kept as an import alias for integrations built before generic DPI lighting. */ +export const TeevolutionDpiLightingCard = DpiLightingCard; + export function NinjutsoSensorCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; diff --git a/src/app/cards/DpiCard.tsx b/src/app/cards/DpiCard.tsx index 1426dace..7c2b0692 100644 --- a/src/app/cards/DpiCard.tsx +++ b/src/app/cards/DpiCard.tsx @@ -315,7 +315,7 @@ function AxisControls({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { id="apply-logitech-axes" className="axis-apply" type="button" - onClick={() => control.applyLogitechAxisDpi(Number(x), Number(y))} + onClick={() => control.applySeparateDpiAxes(Number(x), Number(y))} > {t(locale, "common.apply")} @@ -338,8 +338,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode && Array.isArray(status.dpiStages) && status.dpiStages.length > 0 && !slotsAvailable; - const showSeparateDpiAxes = snapshot.traits.logitech - && status.supportsSeparateDpiAxes === true + const showSeparateDpiAxes = status.supportsSeparateDpiAxes === true && !slotsAvailable; const common = dpiPresetValues(snapshot.dpiOptions); diff --git a/src/app/cards/availability.test.ts b/src/app/cards/availability.test.ts index 04cf8f2e..458455de 100644 --- a/src/app/cards/availability.test.ts +++ b/src/app/cards/availability.test.ts @@ -124,6 +124,26 @@ test("ATK exposes its processing and DPI-lighting cards from reported controls", assert.equal(has.teevolutionDpiLighting, true); }); +test("Dareu exposes its verified mouse and DPI-indicator sleep controls", () => { + const has = cardAvailability(snapshot({ + status: { + brand: "Dareu", + ui: { + family: "dareu", + showAdvancedSection: true, + dpiLighting: { + modes: [0, 1, 2], brightness: [1, 2], speed: [1, 2], sleepTimeouts: [60, 0], + }, + }, + sleepTimeout: 180, + dpiLedSleepTimeout: 180, + }, + })); + assert.equal(has.advancedHost, true); + assert.equal(has.sleep, true); + assert.equal(has.teevolutionDpiLighting, true); +}); + test("ATK inspection cards require data actually read from the device", () => { const empty = cardAvailability(snapshot({ status: { brand: "VXE", ui: { family: "atk" } } })); assert.equal(empty.atkButtons, false); diff --git a/src/control-devices.css b/src/control-devices.css index ce24ac1d..72bf5dd5 100644 --- a/src/control-devices.css +++ b/src/control-devices.css @@ -12,9 +12,9 @@ #pulsar-advanced .sleep-time-picker input[type="number"]::-webkit-inner-spin-button { margin: 0; appearance: none; } #pulsar-advanced .sleep-time-sep { flex: 0 0 auto; padding-top: .48rem; color: var(--muted); font-size: .9rem; line-height: 1.2; } -#teevolution-dpi-lighting select { width: 100%; box-sizing: border-box; margin-top: .2rem; padding: .48rem .55rem; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: var(--surface-input); color: var(--text-input); font: inherit; color-scheme: dark; } -#teevolution-dpi-lighting select:focus { border-color: var(--faint); box-shadow: 0 0 0 2px rgb(255 255 255 / 4%); } -#teevolution-dpi-lighting select:disabled, #teevolution-dpi-lighting input:disabled { cursor: not-allowed; opacity: .45; } +#teevolution-dpi-lighting select, #sleep-select { width: 100%; box-sizing: border-box; margin-top: .2rem; padding: .48rem .55rem; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: var(--surface-input); color: var(--text-input); font: inherit; color-scheme: dark; } +#teevolution-dpi-lighting select:focus, #sleep-select:focus { border-color: var(--faint); box-shadow: 0 0 0 2px rgb(255 255 255 / 4%); } +#teevolution-dpi-lighting select:disabled, #teevolution-dpi-lighting input:disabled, #sleep-select:disabled { cursor: not-allowed; opacity: .45; } #pulsar-advanced.egg-advanced-layout { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: .55rem !important; } #pulsar-advanced.egg-advanced-layout > .setting-card { min-height: 0 !important; padding: .72rem !important; } @@ -88,4 +88,3 @@ #egg-cpi-stage-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } #egg-button-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } } - diff --git a/src/control.css b/src/control.css index ed13393d..b3ff1108 100644 --- a/src/control.css +++ b/src/control.css @@ -789,6 +789,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .switch-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .2rem 0; color: var(--dim); font-size: .7rem; } .field-label { display: block; color: var(--faint); font-size: .62rem; } .field-label.spaced { margin-top: .35rem; } +#sleep-select { width: 100%; } .teevolution-dpi-light-controls { margin-top: .55rem; } .egg-collapsible, .egg-experimental { overflow: hidden; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--card); } @@ -1140,6 +1141,7 @@ body { height: 100vh; overflow: hidden; } .workspace-tab-empty small { display: block; margin-top: .45rem; color: var(--text-faint); font-size: .64rem; } #performance-settings { order: 3; } +#power-overview-settings { order: 3; } #lighting-settings { order: 4; } #logitech-onboard, #nape-layers { order: 5; } /* The MX Master feature cards are the primary Advanced-tab content; without an diff --git a/src/device/controller.ts b/src/device/controller.ts index 1e74c070..132faccc 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -556,6 +556,18 @@ function readCapabilities(): DeviceCapabilities { const razer = activeAs(RazerHidClient); const dm = dmClient(); const keychron = keychronNapeClient(); + const teevolutionProfile = (teevolutionClient()?.getModelProfile() + ?? teevolutionProfileForCid(14)) as TeevolutionProfile | null; + const hintedLighting = latestDeviceStatus?.ui?.dpiLighting; + const dpiLighting = hintedLighting && hintedLighting.modes.length > 0 + && hintedLighting.brightness.length > 0 && hintedLighting.speed.length > 0 + ? { + modes: hintedLighting.modes, + brightness: { min: Math.min(...hintedLighting.brightness), max: Math.max(...hintedLighting.brightness) }, + speed: { min: Math.min(...hintedLighting.speed), max: Math.max(...hintedLighting.speed) }, + sleepTimeouts: hintedLighting.sleepTimeouts, + } + : teevolutionProfile?.dpiLighting ?? null; return { canDisableSleep: dm?.canDisableSleep === true, // Any client may publish these; the two named drivers are just the ones @@ -572,8 +584,8 @@ function readCapabilities(): DeviceCapabilities { razerSleepOptions: razer?.getSleepOptions() ?? null, razerLowPowerOptions: razer?.getLowPowerOptions() ?? null, lowPowerPollingCeiling: razer?.getLowPowerPollingCeiling() ?? null, - teevolutionProfile: (teevolutionClient()?.getModelProfile() - ?? teevolutionProfileForCid(14)) as TeevolutionProfile | null, + teevolutionProfile, + dpiLighting, }; } @@ -1430,6 +1442,7 @@ function applyStatus(deviceStatus: MouseStatus, statusKey?: string): void { function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { latestDeviceStatus = deviceStatus; + capabilities = readCapabilities(); latestDiagnosticStatus = deviceStatus; lastRenderedStatusKey = statusKey ?? JSON.stringify(deviceStatus); const status = withPendingChanges(deviceStatus); @@ -1593,7 +1606,6 @@ async function activateClientNow(client: SupportedClient): Promise { lastSleepSeconds = status.sleepTimeout ?? keychron.getSleepOptions()[0] ?? 60; } deviceStatuses.set(client.device, status); - capabilities = readCapabilities(); applyStatus(status); await readButtons(); await loadNapeKeymap(status.napeLayer ?? editedNapeLayer ?? 1); @@ -1633,7 +1645,6 @@ async function showPulsarExplorer(client: PulsarClient): Promise { const status = await client.readStatus(); dpiOptions = client.getDpiOptions(); deviceStatuses.set(client.device, status); - capabilities = readCapabilities(); applyStatus(status); startAutomaticRefresh(); } @@ -2089,6 +2100,34 @@ export function applyLogitechAxisDpi(dpiX: number, dpiY: number): void { }); } +/** Apply independently reported X/Y DPI through the device's verified axis setter. */ +export function applySeparateDpiAxes(dpiX: number, dpiY: number): void { + if (latestDeviceStatus?.supportsSeparateDpiAxes !== true) return; + if (!dpiOptions.includes(dpiX) || !dpiOptions.includes(dpiY)) { + setReadStatus(st("ctl.axesAdvertised")); + return; + } + stageChange({ + key: "dpi", + label: `DPI X ${dpiX.toLocaleString()} · Y ${dpiY.toLocaleString()}`, + command: `Set DPI axes to X ${dpiX.toLocaleString()} / Y ${dpiY.toLocaleString()}`, + progress: `Setting X ${dpiX.toLocaleString()} · Y ${dpiY.toLocaleString()} DPI…`, + preview: (status) => { + status.dpi = dpiX; + status.dpiY = dpiY; + }, + apply: async () => { + const client = requireSettingsClient() as unknown as { + setDpiAxes?: (x: number, y: number) => Promise; + setDpi?: (x: number, y: number) => Promise; + }; + if (client.setDpiAxes) await client.setDpiAxes(dpiX, dpiY); + else if (activeAs(LogitechHidppClient)?.setDpi) await client.setDpi!(dpiX, dpiY); + else throw new Error("This mouse does not support separate X/Y DPI changes yet."); + }, + }); +} + export function setAnalogTuningMode(mode: "independent" | "both"): void { analogTuning = { ...analogTuning, mode }; emit(); @@ -3162,8 +3201,11 @@ export function applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean } export function applyPulsarValue(setting: "debounce" | "sleep", value: number): void { - if (!(pulsarClient() ?? dmClient() ?? orbitalClient() ?? razerClient() - ?? viperClient() ?? teevolutionClient() ?? vgnClient() ?? keychronNapeClient() ?? wallhackMouseClient())) return; + const client = setting === "sleep" + ? activeSettingsClient() + : pulsarClient() ?? dmClient() ?? orbitalClient() ?? razerClient() + ?? viperClient() ?? teevolutionClient() ?? vgnClient() ?? keychronNapeClient() ?? wallhackMouseClient(); + if (!client || (setting === "sleep" && !("setSleepTimeout" in client))) return; const asleep = value !== WLMOUSE_SLEEP_NEVER; stageChange({ key: setting, @@ -3446,6 +3488,55 @@ export function applyTeevolutionDpiLighting(setting: "mode" | "brightness" | "sp }); } +/** Shared DPI-indicator write path for non-Teevolution drivers that publish ranges. */ +export function applyDpiLighting(setting: "mode" | "brightness" | "speed", value: number): void { + const lighting = latestDeviceStatus?.ui?.dpiLighting; + if (!lighting) return; + const allowed = setting === "mode" ? lighting.modes : setting === "brightness" ? lighting.brightness : lighting.speed; + if (!allowed.includes(value)) return; + const names = { mode: "effect", brightness: "brightness", speed: "speed" } as const; + stageChange({ + key: `dpi-light-${setting}`, + group: "dpi-lighting", + label: `DPI indicator ${names[setting]} ${value}`, + command: `Set DPI indicator ${names[setting]} to ${value}`, + progress: `Setting DPI indicator ${names[setting]}…`, + preview: (status) => { + if (setting === "mode") status.dpiLedMode = value; + if (setting === "brightness") status.dpiLedBrightness = value; + if (setting === "speed") status.dpiLedSpeed = value; + }, + apply: async () => { + const status = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; + if (!status || status.dpiLedMode == null || status.dpiLedBrightness == null || status.dpiLedSpeed == null) { + throw new Error("The current DPI indicator settings are unavailable."); + } + const client = requireClientMethod("setDpiLighting", "DPI indicator lighting") as unknown as { + setDpiLighting(mode: number, brightness: number, speed: number): Promise; + }; + await client.setDpiLighting(status.dpiLedMode, status.dpiLedBrightness, status.dpiLedSpeed); + }, + }); +} + +export function applyDpiLightingSleepTimeout(seconds: number): void { + const allowed = latestDeviceStatus?.ui?.dpiLighting?.sleepTimeouts; + if (!allowed?.includes(seconds)) return; + stageChange({ + key: "dpi-light-sleep", + label: `DPI indicator sleep ${sleepLabel(seconds, interfacePreferences.locale)}`, + command: `Set DPI indicator sleep to ${sleepLabel(seconds, interfacePreferences.locale)}`, + progress: "Setting DPI indicator sleep…", + preview: (status) => { status.dpiLedSleepTimeout = seconds; }, + apply: async () => { + const client = requireClientMethod("setDpiLedSleepTimeout", "DPI indicator sleep") as unknown as { + setDpiLedSleepTimeout(value: number): Promise; + }; + await client.setDpiLedSleepTimeout(seconds); + }, + }); +} + export function applyEggFilter(setting: "slamclick" | "motionJitter", enabled: boolean): void { if (!eggClient()) return; const label = setting === "slamclick" ? "slamclick filter" : "motion-jitter filter"; @@ -3914,8 +4005,6 @@ async function showFixturePreview(name: PreviewMode): Promise { } 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); if (name === "nape-pro") { const layer = fixture.status.napeLayer ?? 1; diff --git a/src/device/traits.ts b/src/device/traits.ts index c8fd72a2..ddabf9c2 100644 --- a/src/device/traits.ts +++ b/src/device/traits.ts @@ -56,6 +56,7 @@ const BY_FAMILY: Readonly>> = { // MCHOSE reads debounce and sleep from its config blob and writes both, but // it is not a direct-mode (CompX) driver, so it takes the plain flags. mchose: { advancedSection: true, sleep: true, debounce: true }, + dareu: { advancedSection: true, sleep: true }, }; const BY_BRAND: Readonly> = { diff --git a/src/device/types.ts b/src/device/types.ts index 7a6eeac8..e5136ea8 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -37,6 +37,7 @@ export interface TeevolutionProfile { modes: readonly (0 | 1 | 2)[]; brightness: { min: number; max: number }; speed: { min: number; max: number }; + sleepTimeouts?: readonly number[]; }; } @@ -49,6 +50,8 @@ export interface DeviceCapabilities { razerLowPowerOptions: number[] | null; lowPowerPollingCeiling: number | null; teevolutionProfile: TeevolutionProfile | null; + /** DPI-indicator range reported by any driver, independent of its family. */ + dpiLighting: TeevolutionProfile["dpiLighting"] | null; } export interface SidebarDevice { diff --git a/src/supported-mice.test.ts b/src/supported-mice.test.ts index 4f11f60b..6094a936 100644 --- a/src/supported-mice.test.ts +++ b/src/supported-mice.test.ts @@ -26,6 +26,7 @@ import { RAZER_PRODUCTS } from "@openmouse/protocol/razer-devices"; import { TEEVOLUTION_PRODUCT_IDS } from "@openmouse/protocol/teevolution"; import { ZAUNKOENIG_PRODUCT_IDS } from "@openmouse/protocol/zaunkoenig"; import { CORSAIR_PRODUCT_IDS } from "@openmouse/protocol/corsair"; +import { DAREU_PRODUCT_IDS } from "@openmouse/protocol/dareu"; import { KSNAKE_PRODUCTS } from "@openmouse/protocol/ksnake"; import { MICE, STATUS, type Mouse, type Status } from "./supported-mice.ts"; @@ -100,6 +101,7 @@ const PID_UNIVERSE = new Set([ ...TEEVOLUTION_PRODUCT_IDS, ...ZAUNKOENIG_PRODUCT_IDS, ...CORSAIR_PRODUCT_IDS, + ...DAREU_PRODUCT_IDS, ...NINJUTSO_LEGACY_MOUSE_PRODUCT_IDS, ...NINJUTSO_MOUSE_PRODUCT_IDS, ...NINJUTSO_LEGACY_RECEIVER_PRODUCT_IDS, @@ -174,3 +176,9 @@ test("every pinned PID on a coverage claim exists in the protocol registry", () } } }); + +test("Dareu A950 catalog PIDs match its protocol catalog", () => { + const dareu = MICE.find((mouse) => mouse.brand === "Dareu" && mouse.model === "A950 PRO Mg"); + assert.deepEqual(dareu?.pids, [...DAREU_PRODUCT_IDS]); + for (const pid of DAREU_PRODUCT_IDS) assert.ok(PID_UNIVERSE.has(pid)); +}); diff --git a/src/supported-mice.ts b/src/supported-mice.ts index 29852edd..8d46be1b 100644 --- a/src/supported-mice.ts +++ b/src/supported-mice.ts @@ -63,6 +63,11 @@ export const TABS: Array<{ key: Status | "all"; label: string }> = [ ]; export const MICE: Mouse[] = [ + // DAREU ─────────────────────────────────────────────────────────────── + { brand: "Dareu", model: "A950 PRO Mg", status: "pr", req: 0, + pids: [0x1117, 0x1114], + note: "Jm WebHID driver for wired PID 0x1117 and 2.4 GHz receiver PID 0x1114; receiver VID 0x260d and report-8 channel measured, direct-path hardware validation still needed" }, + // LOGITECH ───────────────────────────────────────────────────────────── { brand: "Logitech", model: "G502 (all variants)", status: "supported", req: 42, pids: [0xc07d, 0xc08b], diff --git a/src/ui/device-images.test.ts b/src/ui/device-images.test.ts index 8a7ae906..55520067 100644 --- a/src/ui/device-images.test.ts +++ b/src/ui/device-images.test.ts @@ -68,6 +68,13 @@ test("Attack Shark R5 Ultra wired and wireless share the same artwork", () => { assert.equal(deviceImage(null, "Attack Shark R5 Ultra"), CDN + "attackshark-r5-ultra.png"); }); +test("Dareu A950 PRO Mg wired and receiver paths use the supplied product artwork", () => { + const dareuArt = "/devices/dareu-a950-pro-mg.png"; + assert.equal(deviceImage(dev(0x260d, 0x1117)), dareuArt); + assert.equal(deviceImage(dev(0x260d, 0x1114)), dareuArt); + assert.equal(deviceImage(null, "Dareu A950 PRO Mg"), dareuArt); +}); + test("Attack Shark R2 resolves by name (PID 0x402D is shared with the M5 Pro)", () => { assert.equal(deviceImage(null, "Attack Shark R2"), CDN + "attackshark-r2.png"); // The shared receiver PID must NOT resolve to the R2 render. diff --git a/src/ui/device-images.ts b/src/ui/device-images.ts index eca2d23f..43598c00 100644 --- a/src/ui/device-images.ts +++ b/src/ui/device-images.ts @@ -2,15 +2,16 @@ * Top-down product art for the persistent device panel, keyed by the identifiers WebHID already reports so that * drivers stay free of asset paths and no new UI hint is needed. * - * Files are hosted in the `openmouse-devices` Cloudflare R2 bucket (public - * access via its r2.dev URL, see `DEVICE_IMAGE_BASE_URL` below) rather than - * committed to the repo, so this map holds bare filenames only. A key whose - * file is missing in the bucket therefore fails at load rather than at build - * time, so the panel drops the thumbnail on that error and keeps the layout - * it had before any art existed. See `public/devices/README.md` for how to - * upload new art. + * Most files are hosted in the `openmouse-devices` Cloudflare R2 bucket + * (public access via its r2.dev URL, see `DEVICE_IMAGE_BASE_URL` below) + * rather than committed to the repo. Local images in `public/devices` are + * served directly by Vite. A missing image fails at load, so the panel drops + * the thumbnail and keeps its existing layout. See `public/devices/README.md` + * for provenance and upload guidance. */ +const DAREU_A950_PRO_MG_IMAGE = "/devices/dareu-a950-pro-mg.png"; + const DEVICE_IMAGES: ReadonlyMap = new Map([ ["046d:c07d", "logitech-g502.png"], ["046d:c095", "logitech-g502-x-plus.png"], @@ -145,6 +146,9 @@ const DEVICE_IMAGES: ReadonlyMap = new Map([ // K-snake X11 wired / 2.4 GHz dongle share the same shell. ["a8a4:2255", "ksnake-x11.png"], ["a8a5:2255", "ksnake-x11.png"], + // A950 PRO Mg wired mouse and its dedicated 2.4 GHz receiver. + ["260d:1117", DAREU_A950_PRO_MG_IMAGE], + ["260d:1114", DAREU_A950_PRO_MG_IMAGE], // Microsoft Intellimouse ["045e:0823", "microsoft-classic-intellimouse.png"], ["045e:082a", "microsoft-pro-intellimouse.png"], @@ -226,6 +230,7 @@ function resolveDeviceImageFilename(device: HIDDevice | null | undefined, displa if (/\bf1\s*v2\b/i.test(displayName)) return "atk-f1-v2-ultra-max.png"; // Catches any A7 V2 variant whose product id is not pinned above. if (/\ba7\s*v2\b/i.test(displayName)) return "mchose-a7-v2.png"; + if (/\ba950\s*pro\s*mg\b/i.test(displayName)) return DAREU_A950_PRO_MG_IMAGE; if (/\b(finalmouse|starlight|ulx)\b/i.test(displayName)) return "finalmouse-ulx.png"; if (/\borbital\b/i.test(displayName)) return "unknown-device.png"; if (/\bmoddo/i.test(displayName)) return "unknown-device.png"; @@ -247,5 +252,6 @@ function resolveDeviceImageFilename(device: HIDDevice | null | undefined, displa const DEVICE_IMAGE_BASE_URL = "https://pub-ac470fd1b7084597b8a4a45cfc3318fc.r2.dev/"; export function deviceImage(device: HIDDevice | null | undefined, displayName = ""): string { - return DEVICE_IMAGE_BASE_URL + resolveDeviceImageFilename(device, displayName); + const image = resolveDeviceImageFilename(device, displayName); + return image.startsWith("/") ? image : DEVICE_IMAGE_BASE_URL + image; }