Skip to content
Merged
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
41 changes: 41 additions & 0 deletions docs/noir-s1.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions public/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 54 additions & 9 deletions src/app/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,10 +95,23 @@ const M2_NEX_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPositi
"scroll down": { x: 0.62, y: 0.29 },
};

const NOIR_S1_BUTTON_MARKER_POSITIONS: Readonly<Record<string, ButtonMarkerPosition>> = {
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";
Expand All @@ -124,7 +138,7 @@ function DeviceShowcase({ snapshot }: {
return (
<div className="device-showcase">
<h1 className="device-showcase-name">{status.name}</h1>
<p className="device-showcase-brand">{status.brand}</p>
<p className="device-showcase-brand">{noirBrandLabel(status)}</p>
<div className="device-diagram">
<div
className="device-diagram-canvas"
Expand Down Expand Up @@ -219,15 +233,17 @@ function DeviceShowcase({ snapshot }: {
function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const locale = snapshot.preferences.locale;
if (status?.brand !== "Noir Gear" || status.name !== "M2-NEX" || snapshot.m2nexProfiles === null) return null;
const isNoirProfile = isNoirKsnakeStatus(status);
const profileImportRef = useRef<HTMLInputElement>(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;
return (
<section
id="m2nex-profile-overview"
className="m2nex-profile-overview"
aria-label={t(locale, "m2nex.controls")}
aria-label={`${status.name} ${t(locale, "m2nex.controls")}`}
>
<div className="m2nex-profile-overview-title">
<span>{t(locale, "m2nex.overline")}</span>
Expand All @@ -250,6 +266,20 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac
{snapshot.m2nexProfileDirty ? t(locale, "m2nex.pending") : t(locale, "m2nex.saved")}
</span>
<div className="m2nex-profile-overview-actions">
<button
type="button"
disabled={snapshot.settingInProgress || snapshot.pending.busy}
onClick={control.createM2NexProfile}
>
New
</button>
<button
type="button"
disabled={snapshot.settingInProgress || snapshot.pending.busy}
onClick={() => profileImportRef.current?.click()}
>
Import
</button>
<button
className="m2nex-profile-overview-apply"
type="button"
Expand All @@ -266,6 +296,18 @@ function M2NexProfileOverview({ snapshot }: { snapshot: ControlSnapshot }): Reac
{t(locale, "m2nex.save")}
</button>
</div>
<input
ref={profileImportRef}
className="m2nex-profile-import-input"
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
void file.text().then((text) => control.importM2NexProfiles(text));
}}
/>
</section>
);
}
Expand All @@ -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 (
Expand All @@ -296,7 +338,9 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
/>
<div className="button-map-markers" aria-hidden="true">
{Object.keys(status.buttonMappings ?? {}).map((button, index) => {
const position = m2NexButtonMarkerPosition(button);
const position = isNoirS1
? noirS1ButtonMarkerPosition(button)
: m2NexButtonMarkerPosition(button);
if (!position) return null;
return (
<span
Expand Down Expand Up @@ -324,7 +368,7 @@ export function DeviceShowcaseSidebar({ snapshot }: { snapshot: ControlSnapshot
</div>
<div className="showcase-sidebar-info">
<h2 className="showcase-sidebar-name">{status.name}</h2>
<p className="showcase-sidebar-brand">{status.brand}</p>
<p className="showcase-sidebar-brand">{noirBrandLabel(status)}</p>
</div>
<div className="showcase-sidebar-status">
<span className="device-showcase-dot" aria-hidden="true" />
Expand Down Expand Up @@ -674,7 +718,8 @@ function DeviceListView({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
const pollText = liveStatus?.pollingRateHz ? `${liveStatus.pollingRateHz} Hz` : "–";
const batteryText = liveStatus?.batteryPercent != null ? `${liveStatus.batteryPercent}%` : "–%";
const imageSrc = deviceImage({ vendorId: device.vendorId, productId: device.productId } as HIDDevice, device.name);
const imageClass = deviceImageFilename(device.name) === "noir-m2-nex.png"
const imageFilename = deviceImageFilename(device.name);
const imageClass = imageFilename === "noir-m2-nex.png" || imageFilename === "noir-s1.png"
? " device-tile-image-is-portrait"
: "";
return (
Expand Down Expand Up @@ -767,7 +812,7 @@ export function OverviewPage({
const locale = preferences.locale;

const tabs = availableWorkspaceTabs(status !== null, cardAvailability(snapshot), {
hideLighting: status?.brand === "Noir Gear" && status.name === "M2-NEX",
hideLighting: isNoirKsnakeStatus(status),
});
const workspaceTab = availableWorkspaceTab(snapshot.workspaceTab, tabs);
const workspaceSnapshot = workspaceTab === snapshot.workspaceTab
Expand Down
48 changes: 39 additions & 9 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { teevolutionSensorModeUi } from "@openmouse/protocol/teevolution";
import type { KsnakeMacroProfile, KsnakeMacroStep } from "@openmouse/protocol/ksnake";
import { isPulsarProProtocol } from "../../device/traits";
import { isNoirKsnakeStatus } from "../../device/noir.ts";
import * as control from "../../device/controller";
import { PULSAR_SLEEP_OPTIONS } from "../../device/controller";
import { selectableValues, sleepLabel, sleepParts, sleepTotalSeconds, valuesWithCurrent, KEYCHRON_SLEEP_MAX_HOURS, KEYCHRON_SLEEP_MAX_SECONDS, KEYCHRON_SLEEP_MIN_SECONDS } from "../../device/options";
Expand Down Expand Up @@ -1541,8 +1542,8 @@ function cloneKsnakeMacroProfile(profile: KsnakeMacroProfile | undefined): Ksnak
export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const locale = snapshot.preferences.locale;
const isM2Nex = status?.brand === "Noir Gear" && status.name === "M2-NEX";
const macroCapable = status?.ui?.family === "ksnake" || isM2Nex;
const isNoirKsnake = isNoirKsnakeStatus(status);
const macroCapable = status?.ui?.family === "ksnake" || isNoirKsnake;
// Keep the card usable while an older hot-reloaded controller snapshot is
// still missing the newly added field. Treat that state as "not loaded" so
// the local editor is prepared instead of silently hiding the card.
Expand All @@ -1556,6 +1557,7 @@ export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): Re
const recordedStepsRef = useRef<KsnakeMacroStep[]>([]);
const recordingOriginalStepsRef = useRef<KsnakeMacroStep[] | null>(null);
const recordingClockRef = useRef<number | null>(null);
const recorderFocusRef = useRef<HTMLElement | null>(null);
const staged = snapshot.pending.keys.includes("ksnake-macros");
const disabled = snapshot.settingInProgress || snapshot.pending.busy;
const controlsDisabled = disabled || isRecording;
Expand Down Expand Up @@ -1641,6 +1643,12 @@ export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): Re
useEffect(() => {
if (!isRecording) return undefined;

// The Record button keeps focus after it is clicked. Keyboard events then
// target that button, and the recorder-control guard (correctly) ignores
// them as UI input. Move focus to a neutral capture surface so the first
// physical key pressed after starting a recording is captured.
recorderFocusRef.current?.focus();

const isRecorderControl = (target: EventTarget | null): boolean => (
target instanceof Element && Boolean(target.closest("[data-macro-recorder-control]"))
);
Expand Down Expand Up @@ -1756,7 +1764,7 @@ export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): Re
</div>
</div>
<p className="ksnake-macro-notice">
{isM2Nex ? t(locale, "macro.noticeM2nex") : t(locale, "macro.notice")}
{isNoirKsnake ? t(locale, "macro.noticeWriteOnly") : t(locale, "macro.notice")}
</p>
<label className="ksnake-macro-slot-picker">
<span>{t(locale, "macro.slotLabel")}</span>
Expand All @@ -1769,7 +1777,13 @@ export function KsnakeMacroCard({ snapshot }: { snapshot: ControlSnapshot }): Re
})}
</select>
</label>
<section className={`ksnake-macro-recorder${isRecording ? " is-recording" : ""}`} aria-live="polite">
<section
ref={recorderFocusRef}
className={`ksnake-macro-recorder${isRecording ? " is-recording" : ""}`}
aria-live="polite"
aria-label="Macro input capture"
tabIndex={-1}
>
<div className="ksnake-macro-recorder-status">
<span className="ksnake-macro-recorder-dot" aria-hidden="true" />
<div>
Expand Down Expand Up @@ -1940,13 +1954,15 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }):
if (!status?.buttonMappings || !status.buttonOptions?.length) return null;
const locale = snapshot.preferences.locale;
const options = status.buttonOptions;
const isM2Nex = status.brand === "Noir Gear"
&& status.name === "M2-NEX"
const isNoirProfile = isNoirKsnakeStatus(status)
&& snapshot.m2nexProfiles !== null;
const selectedM2NexProfile = isM2Nex
const selectedM2NexProfile = isNoirProfile
? snapshot.m2nexProfiles?.[snapshot.activeM2NexProfile]
: null;
const canResetKsnake = isNoirProfile;
const mappings = selectedM2NexProfile?.buttonMappings ?? status.buttonMappings;
const pendingButtons = new Set(snapshot.pending.keys);
const resettingButtons = pendingButtons.has("ksnake-button-reset");
// fixedButtons lands with mouse-protocol#68; read defensively so this
// builds against the published protocol until then.
const fixed = new Set((status as unknown as { fixedButtons?: readonly string[] }).fixedButtons ?? []);
Expand All @@ -1955,9 +1971,11 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }):
<div className="setting-heading compact"><div><p>BUTTONS</p><h2>{t(locale, "map.remap")}</h2></div></div>
<div className="button-map-list">
{Object.entries(status.buttonMappings).map(([button, deviceAssigned], index) => {
const assigned = mappings[button] ?? deviceAssigned;
const assigned = resettingButtons || pendingButtons.has(`button-${button}`)
? status.buttonMappings?.[button] ?? mappings[button] ?? deviceAssigned
: mappings[button] ?? deviceAssigned;
const selectId = `button-${button.toLowerCase()}-select`;
const isFixed = fixed.has(button) || (isM2Nex && button === "Left");
const isFixed = fixed.has(button) || (isNoirKsnakeStatus(status) && button === "Left");
return (
<label key={button} className={`button-map-row${isFixed ? " is-fixed" : ""}`} htmlFor={selectId}>
<span className="button-map-control">
Expand Down Expand Up @@ -1989,6 +2007,18 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }):
);
})}
</div>
{canResetKsnake ? (
<div className="button-map-footer">
<button
type="button"
className="button-map-reset"
disabled={snapshot.settingInProgress || snapshot.pending.busy}
onClick={control.resetKsnakeButtonMappings}
>
Reset to default
</button>
</div>
) : null}
{!selectedM2NexProfile ? (
<p className="field-note">{t(locale, "map.defaultNote")}</p>
) : null}
Expand Down
Loading
Loading