Skip to content

Commit c816aa6

Browse files
feat: add Noir Gear NOIR E1 support (#476)
Co-authored-by: user_1781515504933 <email_1781515504933>
1 parent b0f6af8 commit c816aa6

15 files changed

Lines changed: 234 additions & 68 deletions

‎src/app/OverviewPage.tsx‎

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from "lucide-react";
1616
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
1717
import * as control from "../device/controller";
18-
import { isNoirKsnakeStatus, isNoirS1Status, noirBrandLabel } from "../device/noir.ts";
18+
import { isNoirE1Status, isNoirKsnakeStatus, isNoirS1Status, noirBrandLabel } from "../device/noir.ts";
1919
import type { ControlSnapshot, WorkspaceTab } from "../device/types";
2020
import { t, tp, connectionText, type I18nKey } from "../i18n";
2121
import { Diagnostics, LogitechDetails } from "./Diagnostics";
@@ -104,6 +104,14 @@ const NOIR_S1_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPosit
104104
dpi: { x: 0.5, y: 0.42 },
105105
};
106106

107+
const NOIR_E1_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPosition>> = {
108+
left: { x: 0.28, y: 0.2 },
109+
right: { x: 0.72, y: 0.2 },
110+
middle: { x: 0.5, y: 0.18 },
111+
forward: { x: 0.08, y: 0.32 },
112+
backward: { x: 0.08, y: 0.5 },
113+
};
114+
107115
function m2NexButtonMarkerPosition(button: string): ButtonMarkerPosition | null {
108116
return M2_NEX_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null;
109117
}
@@ -112,6 +120,10 @@ function noirS1ButtonMarkerPosition(button: string): ButtonMarkerPosition | null
112120
return NOIR_S1_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null;
113121
}
114122

123+
function noirE1ButtonMarkerPosition(button: string): ButtonMarkerPosition | null {
124+
return NOIR_E1_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null;
125+
}
126+
115127
// Device artwork requests go straight to the documented GitHub issue form
116128
// (public/devices/README.md, .github/ISSUE_TEMPLATE/device-artwork.yml).
117129
const ARTWORK_ISSUE_URL = "https://github.com/OpenMouse-Project/openmouse/issues/new?template=device-artwork.yml";
@@ -247,12 +259,12 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac
247259
>
248260
<div className="m2nex-profile-overview-title">
249261
<span>{t(locale, "m2nex.overline")}</span>
250-
<strong>{t(locale, "m2nex.label")}</strong>
262+
<strong>{status.name}</strong>
251263
</div>
252264
<label className="m2nex-profile-overview-picker" htmlFor="m2nex-profile-overview-select">
253265
<select
254266
id="m2nex-profile-overview-select"
255-
aria-label={t(locale, "m2nex.select")}
267+
aria-label={`${status.name} profile`}
256268
value={snapshot.activeM2NexProfile}
257269
disabled={snapshot.settingInProgress || snapshot.pending.busy}
258270
onChange={(event) => control.selectM2NexProfile(Number(event.currentTarget.value))}
@@ -318,6 +330,7 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
318330
const locale = snapshot.preferences.locale;
319331
const image = snapshot.deviceArtwork;
320332
const isNoirS1 = isNoirS1Status(status);
333+
const isNoirE1 = isNoirE1Status(status);
321334
const showButtonMarkers = snapshot.workspaceTab === "buttons"
322335
&& isNoirKsnakeStatus(status)
323336
&& status.buttonMappings != null;
@@ -337,22 +350,34 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
337350
}}
338351
/>
339352
<div className="button-map-markers" aria-hidden="true">
340-
{Object.keys(status.buttonMappings ?? {}).map((button, index) => {
341-
const position = isNoirS1
342-
? noirS1ButtonMarkerPosition(button)
343-
: m2NexButtonMarkerPosition(button);
344-
if (!position) return null;
345-
return (
346-
<span
347-
key={button}
348-
className="button-map-marker"
349-
style={{ left: `${position.x * 100}%`, top: `${position.y * 100}%` }}
350-
>
351-
{String(index + 1).padStart(2, "0")}
352-
</span>
353-
);
354-
})}
353+
{Object.keys(status.buttonMappings ?? {})
354+
.filter((button) => !isNoirE1 || button.trim().toLowerCase() !== "dpi")
355+
.map((button, index) => {
356+
const position = isNoirS1
357+
? noirS1ButtonMarkerPosition(button)
358+
: isNoirE1
359+
? noirE1ButtonMarkerPosition(button)
360+
: m2NexButtonMarkerPosition(button);
361+
if (!position) return null;
362+
return (
363+
<span
364+
key={button}
365+
className="button-map-marker"
366+
style={{ left: `${position.x * 100}%`, top: `${position.y * 100}%` }}
367+
>
368+
{String(index + 1).padStart(2, "0")}
369+
</span>
370+
);
371+
})}
355372
</div>
373+
{isNoirE1 ? (
374+
<div className="button-map-underside-callout" aria-hidden="true">
375+
<span className="button-map-underside-badge">
376+
<span className="button-map-underside-number">06</span>
377+
<span>DPI <small>UNDERSIDE</small></span>
378+
</span>
379+
</div>
380+
) : null}
356381
</div>
357382
) : (
358383
<img
@@ -719,7 +744,7 @@ function DeviceListView({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
719744
const batteryText = liveStatus?.batteryPercent != null ? `${liveStatus.batteryPercent}%` : "–%";
720745
const imageSrc = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name);
721746
const imageFilename = deviceImageFilename(device.name);
722-
const imageClass = imageFilename === "noir-m2-nex.png" || imageFilename === "noir-s1.png"
747+
const imageClass = imageFilename === "noir-m2-nex.png" || imageFilename === "noir-s1.png" || imageFilename === "noir-e1.png"
723748
? " device-tile-image-is-portrait"
724749
: "";
725750
return (

‎src/app/cards/DpiCard.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from "@openmouse/protocol/drivers/logitech/onboard-profiles";
77
import * as control from "../../device/controller";
88
import type { ControlSnapshot, LiftOffLevel } from "../../device/types";
9-
import { isNoirKsnakeStatus, isNoirS1Status, NOIR_DPI_STAGE_COLORS } from "../../device/noir.ts";
9+
import { isNoirE1Status, isNoirKsnakeStatus, isNoirS1Status, NOIR_DPI_STAGE_COLORS } from "../../device/noir.ts";
1010
import { closestDpiOption, dpiPresetValues } from "../../dpi-presets";
1111
import { t, tp } from "../../i18n";
1212
import { LiftOffDistance, hasLiftOff } from "./PerformanceCards";
@@ -471,7 +471,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
471471
|| (Boolean(status?.ui?.dpiStageEditor)
472472
&& Array.isArray(status?.dpiStages)
473473
&& (status?.dpiStages?.length ?? 0) > 0);
474-
const singleDpiAvailable = !isNoirS1Status(status);
474+
const singleDpiAvailable = !isNoirS1Status(status) && !isNoirE1Status(status);
475475

476476
const label = (source: typeof status): string => `${source.dpi.toLocaleString()} DPI`;
477477

‎src/app/cards/availability.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,34 @@ test("the generic button card follows the driver\'s mappings, not the advanced s
454454
assert.equal(has.processing, false, "the rest of the advanced section stays closed");
455455
});
456456

457+
test("NOIR E1 gets the same core configurator controls even without a family hint", () => {
458+
const has = cardAvailability(snapshot({
459+
status: {
460+
brand: "K-snake",
461+
name: "NOIR E1",
462+
ui: { showAdvancedSection: true },
463+
buttonMappings: {
464+
Left: "Left click",
465+
Right: "Right click",
466+
Middle: "Middle click",
467+
Forward: "Forward",
468+
Backward: "Backward",
469+
DPI: "DPI loop",
470+
},
471+
buttonOptions: ["Left click", "Right click", "Scroll up", "DPI loop"],
472+
dpiStages: [800, 1200, 1600, 3200, 5000, 12000],
473+
scrollDirection: "Forward",
474+
lighting: {} as never,
475+
},
476+
}));
477+
assert.equal(has.dpi, true);
478+
assert.equal(has.polling, true);
479+
assert.equal(has.buttonMapping, true);
480+
assert.equal(has.ksnakeScroll, true);
481+
assert.equal(has.ksnakeMacros, true);
482+
assert.equal(has.lightingAdvanced, false);
483+
});
484+
457485
test("the G-Wolves XVI generation opens sleep, debounce and processing, but not signal", () => {
458486
const cards = cardAvailability(snapshot({
459487
status: {

‎src/app/cards/availability.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
157157
&& status.debounceMs !== null && status.debounceMs !== undefined,
158158
sleep: host && (traits.sleep || capabilities?.sleepOptions != null || razerSleep)
159159
&& ui?.hideSleepCard !== true,
160-
ksnakeScroll: ui?.family === "ksnake" && status.scrollDirection != null,
160+
ksnakeScroll: (ui?.family === "ksnake" || isNoirKsnake) && status.scrollDirection != null,
161161
lowPower: host && razerLowPower,
162162
processing: host && processing,
163163
ninjutsoSensor: host && traits.ninjutso

‎src/control.css‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,53 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
14821482
line-height: 1;
14831483
transform: translate(-50%, -50%);
14841484
}
1485+
.button-map-underside-callout {
1486+
display: flex;
1487+
flex-direction: column;
1488+
align-items: center;
1489+
margin-top: .18rem;
1490+
line-height: 1;
1491+
}
1492+
.button-map-underside-callout::before {
1493+
width: 1px;
1494+
height: .72rem;
1495+
border-radius: 2px;
1496+
background: #727985;
1497+
content: "";
1498+
}
1499+
.button-map-underside-badge {
1500+
display: inline-flex;
1501+
align-items: center;
1502+
gap: .42rem;
1503+
padding: .2rem .58rem .2rem .22rem;
1504+
border: 1px solid #41464f;
1505+
border-radius: 999px;
1506+
background: #24272d;
1507+
box-shadow: 0 2px 8px rgb(0 0 0 / 32%);
1508+
color: #e0e3e8;
1509+
font: 650 .59rem var(--font-mono);
1510+
letter-spacing: .04em;
1511+
text-transform: uppercase;
1512+
}
1513+
.button-map-underside-badge small {
1514+
margin-left: .2rem;
1515+
color: #9ba2ac;
1516+
font: inherit;
1517+
font-size: .51rem;
1518+
letter-spacing: .06em;
1519+
}
1520+
.button-map-underside-number {
1521+
display: grid;
1522+
width: 1.42rem;
1523+
height: 1.42rem;
1524+
place-items: center;
1525+
border: 1px solid #fff;
1526+
border-radius: 50%;
1527+
background: #f2f3f5;
1528+
color: #202329;
1529+
font: 700 .57rem var(--font-mono);
1530+
letter-spacing: .02em;
1531+
}
14851532
.showcase-sidebar-info { width: 100%; min-width: 0; margin-top: 1rem; }
14861533
.showcase-sidebar-name {
14871534
margin: 0;

0 commit comments

Comments
 (0)