diff --git a/docs/noir-s1.md b/docs/noir-s1.md new file mode 100644 index 00000000..abc70aa3 --- /dev/null +++ b/docs/noir-s1.md @@ -0,0 +1,41 @@ +# NOIR S1 support notes + +## Implementation + +The NOIR S1 uses the existing OpenMouse K-snake protocol client (the app pins +`@openmouse/protocol` 0.22.0). This change adds S1-specific configurator +behavior to the OpenMouse UI; it does not add or fork a protocol driver. + +The S1 UI exposes the settings reported by the device: button assignments, +the six DPI stages, polling rate, scroll direction, and the K-snake macro +editor. The Overview profile picker keeps six named slots in browser storage; +Apply writes the selected slot to the mouse. It is not a claim that those six +slots are stored as native onboard profiles. + +## Hardware validation recorded during development + +- Connected and operated over USB wired and the 2.4 GHz receiver. +- Button remapping, macro assignment/playback, and DPI-stage cycling were + confirmed by the user on hardware. +- Profiles 2–6 were exercised over 2.4 GHz; restoring the button defaults was + also confirmed on hardware. +- The 2.4 GHz Hardware Test reported 13 checks passed, 0 failed, and 1 skipped. + The skipped polling-rate sample required mouse movement, which was not + observed during that run. This is manual-device evidence, not CI coverage. + +## Known limitations + +- Macro definitions cannot be reliably read back from the firmware. OpenMouse + starts the editor with blank local slots and writes the table when saved; + users should save/export their macro setup before changing computers or + clearing browser storage. +- The profile slots are browser-local and must be applied to change device + settings. They are not onboard profile switching. +- Left click is fixed by the shared K-snake protocol and is intentionally not + offered as a remappable target. +- “Reset to default” covers button mappings only. A complete vendor-style + factory reset is not implemented or validated by this contribution. +- The vendor configurator image is not bundled. The UI reserves + `noir-s1.png`; a maintainer-hosted artwork request and rights review are + separate from this code change. Until the asset is uploaded, the image + request/fallback flow applies. diff --git a/public/devices/README.md b/public/devices/README.md index 9901283e..4f14332e 100644 --- a/public/devices/README.md +++ b/public/devices/README.md @@ -166,6 +166,11 @@ previews, but it is deliberately not committed here. Before publishing the asset, Noir Gear must confirm redistribution rights or OpenMouse should use a maintainer-created silhouette instead. +`noir-s1.png` is reserved for the NOIR S1 name-fallback mapping. The code PR +does not include a vendor configurator render; request artwork separately via +the Device artwork request template and confirm redistribution rights before +the asset is uploaded. + `delux-m800-mini.png` — the name-fallback mapping in `src/ui/device-images.ts` is in place (keyed on reported names like "Delux M800 Mini", "Delux M800 Pro", "Delux M800 Mini (Wireless)"). The render was supplied from Delux's official diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index 326df2f6..284a5e82 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import * as control from "../device/controller"; +import { isNoirKsnakeStatus, isNoirS1Status, noirBrandLabel } from "../device/noir.ts"; import type { ControlSnapshot, WorkspaceTab } from "../device/types"; import { t, tp, connectionText, type I18nKey } from "../i18n"; import { Diagnostics, LogitechDetails } from "./Diagnostics"; @@ -94,10 +95,23 @@ const M2_NEX_BUTTON_MARKER_POSITIONS: Readonly> = { + left: { x: 0.39, y: 0.24 }, + right: { x: 0.61, y: 0.24 }, + middle: { x: 0.5, y: 0.31 }, + forward: { x: 0.27, y: 0.47 }, + backward: { x: 0.27, y: 0.59 }, + dpi: { x: 0.5, y: 0.42 }, +}; + function m2NexButtonMarkerPosition(button: string): ButtonMarkerPosition | null { return M2_NEX_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null; } +function noirS1ButtonMarkerPosition(button: string): ButtonMarkerPosition | null { + return NOIR_S1_BUTTON_MARKER_POSITIONS[button.trim().toLowerCase()] ?? null; +} + // Device artwork requests go straight to the documented GitHub issue form // (public/devices/README.md, .github/ISSUE_TEMPLATE/device-artwork.yml). const ARTWORK_ISSUE_URL = "https://github.com/OpenMouse-Project/openmouse/issues/new?template=device-artwork.yml"; @@ -124,7 +138,7 @@ function DeviceShowcase({ snapshot }: { return (

{status.name}

-

{status.brand}

+

{noirBrandLabel(status)}

(null); + if (!status || !isNoirProfile || snapshot.m2nexProfiles === null) return null; const profile = snapshot.m2nexProfiles[snapshot.activeM2NexProfile]; if (!profile) return null; const applyDisabled = snapshot.settingInProgress || snapshot.pending.count > 0; @@ -227,7 +243,7 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac
{t(locale, "m2nex.overline")} @@ -250,6 +266,20 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac {snapshot.m2nexProfileDirty ? t(locale, "m2nex.pending") : t(locale, "m2nex.saved")}
+ +
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (!file) return; + void file.text().then((text) => control.importM2NexProfiles(text)); + }} + />
); } @@ -275,9 +317,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot if (!status) return null; const locale = snapshot.preferences.locale; const image = snapshot.deviceArtwork; + const isNoirS1 = isNoirS1Status(status); const showButtonMarkers = snapshot.workspaceTab === "buttons" - && status.brand === "Noir Gear" - && status.name === "M2-NEX" + && isNoirKsnakeStatus(status) && status.buttonMappings != null; return ( @@ -296,7 +338,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot />

- {isM2Nex ? t(locale, "macro.noticeM2nex") : t(locale, "macro.notice")} + {isNoirKsnake ? t(locale, "macro.noticeWriteOnly") : t(locale, "macro.notice")}

-
+