Skip to content
Merged
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DpiCard } from "./cards/DpiCard";
import { LightforceCard, PollingCard, SensorCard } from "./cards/PerformanceCards";
import { LightingCard } from "./cards/LightingCard";
import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards";
import { AtkButtonCard, AtkProfileCard, AtkReceiverCard } from "./cards/AtkCards";
import {
DebounceCard,
EggButtonCard,
Expand Down Expand Up @@ -147,6 +148,9 @@ function Workspace({
show(has.razerButtons, ["buttons"]) ? <RazerButtonCard key="razerbuttons" snapshot={snapshot} /> : null,
show(has.mxMasterButtons, ["buttons"])
? <MxMasterButtonsCard key="mxmaster-buttons" snapshot={snapshot} /> : null,
show(has.atkButtons, ["buttons"]) ? <AtkButtonCard key="atk-buttons" snapshot={snapshot} /> : null,
show(has.atkProfile, ["profiles"]) ? <AtkProfileCard key="atk-profile" snapshot={snapshot} /> : null,
show(has.atkReceiver, ["advanced"]) ? <AtkReceiverCard key="atk-receiver" snapshot={snapshot} /> : null,
show(has.powerMode, ["performance"])
? <PowerModeCard key="power-mode" snapshot={snapshot} /> : null,
show(has.buttonMapping, ["buttons"])
Expand Down Expand Up @@ -299,6 +303,7 @@ export function App(): ReactNode {
if(!has.eggButtons&&
!has.razerButtons&&
!has.mxMasterButtons&&
!has.atkButtons&&
!has.debounce&&
!has.lightforce&&
!has.eggSpdt&&
Expand Down
50 changes: 35 additions & 15 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export function DebounceCard({ snapshot }: { snapshot: ControlSnapshot }): React
const max = snapshot.traits.directMode
? snapshot.capabilities?.debounceMaxMs ?? 20
: snapshot.capabilities?.teevolutionProfile?.debounce.max ?? 20;
const offered = snapshot.capabilities?.debounceOptions;
const options = offered
? selectableValues([...offered], status.debounceMs) ?? offered
: Array.from({ length: max + 1 }, (_, ms) => ms);
const staged = snapshot.pending.keys.includes("debounce");
return (
<article id="debounce-settings" className={`setting-card${staged ? " is-staged" : ""}`}>
Expand All @@ -141,7 +145,7 @@ export function DebounceCard({ snapshot }: { snapshot: ControlSnapshot }): React
disabled={status.debounceMs === null || status.debounceMs === undefined}
onChange={(event) => control.applyPulsarValue("debounce", Number(event.currentTarget.value))}
>
{Array.from({ length: max + 1 }, (_, ms) => <option key={ms} value={ms}>{ms} ms</option>)}
{options.map((ms) => <option key={ms} value={ms}>{ms} ms</option>)}
</select>
</article>
);
Expand Down Expand Up @@ -265,6 +269,7 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
const performanceLabel = status.brand === "CRDRAKO"
? "Competitive mode"
: status.brand === "Teevolution" ? "Highest performance" : "Performance mode";
const angleSnappingLabel = status.ui?.family === "atk" ? "Straight-line correction" : "Angle snapping";

return (
<article id="processing-settings" className="setting-card">
Expand Down Expand Up @@ -302,7 +307,7 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
/>
<SwitchRow
id="angle-snapping-toggle"
label="Angle snapping"
label={angleSnappingLabel}
value={status.angleSnapping}
hidden={ui?.hideAngleSnapping === true || traits.finalmouse}
onChange={(next) => control.applyPulsarToggle("angleSnapping", next)}
Expand All @@ -329,6 +334,13 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
hidden={status.hyperMode == null}
onChange={(next) => control.applyPulsarToggle("hyperMode", next)}
/>
<SwitchRow
id="long-range-mode-toggle"
label="Ultra long range"
value={status.longRangeMode}
hidden={status.longRangeMode == null}
onChange={(next) => control.applyPulsarToggle("longRangeMode", next)}
/>

{traits.teevolution && teevolutionProfile ? (
<label id="teevolution-performance-duration-row" className="field-label spaced">
Expand All @@ -351,8 +363,16 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea

export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
const profile = snapshot.capabilities?.teevolutionProfile;
if (!status || !profile) return null;
const teevolution = snapshot.capabilities?.teevolutionProfile?.dpiLighting;
const profile = status?.ui?.dpiLighting;
if (!status || (!profile && !teevolution)) return null;
const modes = profile?.modes ?? teevolution!.modes;
const brightness = profile?.brightness
?? Array.from({ length: teevolution!.brightness.max - teevolution!.brightness.min + 1 },
(_, index) => teevolution!.brightness.min + index);
const speeds = profile?.speed
?? Array.from({ length: teevolution!.speed.max - teevolution!.speed.min + 1 },
(_, index) => teevolution!.speed.min + index);
const lightMode = status.dpiLedMode ?? 0;
const staged = snapshot.pending.keys.some((key) => key.startsWith("teevolution-dpi-light-"));
return (
Expand All @@ -370,7 +390,7 @@ export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnap
onChange={(event) => control.applyTeevolutionDpiLighting("mode", Number(event.currentTarget.value))}
>
{([[0, "Off"], [1, "Steady"], [2, "Breathing"]] as const)
.filter(([value]) => profile.dpiLighting.modes.includes(value))
.filter(([value]) => modes.includes(value))
.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
Expand All @@ -384,14 +404,14 @@ export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnap
<input
id="teevolution-dpi-light-brightness"
type="range"
min={profile.dpiLighting.brightness.min}
max={profile.dpiLighting.brightness.max}
min={Math.min(...brightness)}
max={Math.max(...brightness)}
step={1}
value={status.dpiLedBrightness ?? profile.dpiLighting.brightness.min}
value={status.dpiLedBrightness ?? brightness[0]}
disabled={lightMode !== 1 || status.dpiLedBrightness == null}
style={{
"--fill": `${((status.dpiLedBrightness ?? profile.dpiLighting.brightness.min) - profile.dpiLighting.brightness.min)
/ Math.max(1, profile.dpiLighting.brightness.max - profile.dpiLighting.brightness.min) * 100}%`,
"--fill": `${((status.dpiLedBrightness ?? brightness[0]!) - Math.min(...brightness))
/ Math.max(1, Math.max(...brightness) - Math.min(...brightness)) * 100}%`,
}}
onChange={(event) => control.applyTeevolutionDpiLighting("brightness", Number(event.currentTarget.value))}
/>
Expand All @@ -406,14 +426,14 @@ export function TeevolutionDpiLightingCard({ snapshot }: { snapshot: ControlSnap
<input
id="teevolution-dpi-light-speed"
type="range"
min={profile.dpiLighting.speed.min}
max={profile.dpiLighting.speed.max}
min={Math.min(...speeds)}
max={Math.max(...speeds)}
step={1}
value={status.dpiLedSpeed ?? profile.dpiLighting.speed.min}
value={status.dpiLedSpeed ?? speeds[0]}
disabled={lightMode !== 2 || status.dpiLedSpeed == null}
style={{
"--fill": `${((status.dpiLedSpeed ?? profile.dpiLighting.speed.min) - profile.dpiLighting.speed.min)
/ Math.max(1, profile.dpiLighting.speed.max - profile.dpiLighting.speed.min) * 100}%`,
"--fill": `${((status.dpiLedSpeed ?? speeds[0]!) - Math.min(...speeds))
/ Math.max(1, Math.max(...speeds) - Math.min(...speeds)) * 100}%`,
}}
onChange={(event) => control.applyTeevolutionDpiLighting("speed", Number(event.currentTarget.value))}
/>
Expand Down
117 changes: 117 additions & 0 deletions src/app/cards/AtkCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import * as control from "../../device/controller";
import type { ControlSnapshot } from "../../device/types";
import { Segmented } from "../ui";

export function AtkProfileCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const status = snapshot.status;
if (!status || status.activeProfile === null || status.atkProfileCount === undefined) return null;
return (
<article id="atk-profile-status" className="setting-card">
<div className="setting-heading compact">
<div><p>ONBOARD</p><h2>Configuration bank</h2></div>
<output>{status.activeProfile} / {status.atkProfileCount}</output>
</div>
<Segmented
ariaLabel="Active configuration bank"
options={Array.from({ length: status.atkProfileCount }, (_, index) => ({
value: index + 1,
label: String(index + 1),
}))}
value={status.activeProfile}
disabled={snapshot.settingInProgress}
onChange={(profile) => void control.selectAtkR1Profile(profile)}
/>
<small className="setting-note">
Switching changes the active firmware-managed bank and requires readback from the mouse.
</small>
</article>
);
}

export function AtkButtonCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const mappings = snapshot.status?.atkButtonMappings;
if (!mappings?.length) return null;
return (
<article id="atk-button-status" className="setting-card">
<div className="setting-heading compact">
<div><p>BUTTONS</p><h2>Onboard assignments</h2></div>
<output>{mappings.length}</output>
</div>
<div className="button-remap-list">
{mappings.map((mapping) => (
<div className="button-remap-row" key={mapping.id}>
<span>{mapping.name}</span>
<output title={`EEPROM 0x${mapping.address.toString(16).padStart(4, "0")} · ${mapping.raw}`}>
{mapping.checksumValid ? mapping.action : `Invalid checksum · ${mapping.raw}`}
</output>
</div>
))}
</div>
<small className="setting-note">
Read from the active bank. Assignment writes, shortcuts, and macros remain locked pending reversible validation.
</small>
</article>
);
}

export function AtkReceiverCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const [pairingPrepared, setPairingPrepared] = useState(false);
const cardRef = useRef<HTMLElement>(null);
const startPairingRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (pairingPrepared) startPairingRef.current?.focus();
}, [pairingPrepared]);
const closePreparation = (): void => {
setPairingPrepared(false);
window.requestAnimationFrame(() => cardRef.current?.focus());
};
const startPairing = (): void => {
closePreparation();
void control.pairAtkR1SePlusReceiver();
};
const receiver = snapshot.status?.atkReceiver;
if (!receiver) return null;
const pairing = receiver.pairingStatus === null
? "Unavailable"
: receiver.pairingSecondsRemaining
? `Status ${receiver.pairingStatus} · ${receiver.pairingSecondsRemaining}s remaining`
: `Status ${receiver.pairingStatus}`;
return (
<article ref={cardRef} id="atk-receiver-status" className="setting-card" tabIndex={-1}>
<div className="setting-heading compact">
<div><p>WIRELESS</p><h2>Receiver</h2></div>
<output>{receiver.online ? "Mouse online" : "Mouse offline"}</output>
</div>
<div className="button-remap-list">
<div className="button-remap-row"><span>RF identifier</span><output>{receiver.rfId}</output></div>
<div className="button-remap-row"><span>Pairing state</span><output>{pairing}</output></div>
</div>
{snapshot.atkR1SePlusPairingAvailable ? (
pairingPrepared ? (
<div className="setting-action">
<small className="setting-note">
Unplug the mouse cable and select 2.4 GHz mode. After starting, hold left click, wheel click, and right click until the indicator flashes, then release.
</small>
<button
ref={startPairingRef}
type="button"
disabled={snapshot.settingInProgress}
onClick={startPairing}
>
Ready, start pairing
</button>
<button type="button" disabled={snapshot.settingInProgress} onClick={closePreparation}>Cancel</button>
</div>
) : (
<div className="setting-action">
<small className="setting-note">Pairing is verified for the VXE R1 SE+ (CID 02, MID 20).</small>
<button type="button" disabled={snapshot.settingInProgress} onClick={() => setPairingPrepared(true)}>Pair R1 SE+</button>
</div>
)
) : (
<small className="setting-note">Pairing controls are unavailable on this receiver model.</small>
)}
</article>
);
}
13 changes: 11 additions & 2 deletions src/app/cards/DpiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ function DpiStages({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
<div className="dpi-slot-rule" />
</>
) : null}
<div id="dpi-stage-list" className="dpi-slot-list dpi-stage-list">
<div id="dpi-stage-list" className={`dpi-slot-list dpi-stage-list${status.dpiStageColors ? " has-colors" : ""}`}>
<div className="dpi-slot-row dpi-slot-head">
<span /><span>DPI</span>
<span /><span>DPI</span>{status.dpiStageColors ? <span>Color</span> : null}
</div>
{stages.map((dpi, index) => {
const isActive = index === active;
Expand All @@ -225,6 +225,15 @@ function DpiStages({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
disabled={disabled}
onChange={(event) => control.applyDpiStageValue(index, Number(event.currentTarget.value))}
/>
{status.dpiStageColors ? (
<input
type="color"
aria-label={`Stage ${index + 1} color`}
value={status.dpiStageColors[index] ?? "#000000"}
disabled={disabled}
onChange={(event) => control.applyDpiStageColor(index, event.currentTarget.value)}
/>
) : null}
</div>
);
})}
Expand Down
38 changes: 38 additions & 0 deletions src/app/cards/availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,44 @@ test("Pulsar keeps the shared advanced cards", () => {
assert.equal(has.eggFilter, false);
});

test("ATK exposes its processing and DPI-lighting cards from reported controls", () => {
const has = cardAvailability(snapshot({
status: {
brand: "VXE",
ui: {
family: "atk",
showAdvancedSection: true,
dpiLighting: { modes: [0, 1, 2], brightness: [0, 1, 2], speed: [0, 1, 2] },
},
longRangeMode: false,
},
}));
assert.equal(has.advancedHost, true);
assert.equal(has.processing, 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);
assert.equal(empty.atkProfile, false);
assert.equal(empty.atkReceiver, false);

const inspected = cardAvailability(snapshot({
status: {
brand: "VXE",
ui: { family: "atk" },
activeProfile: 0,
atkProfileCount: 4,
atkButtonMappings: [{ id: "left" }] as never,
atkReceiver: { online: true } as never,
},
}));
assert.equal(inspected.atkButtons, true);
assert.equal(inspected.atkProfile, true);
assert.equal(inspected.atkReceiver, true);
});

test("Keychron Nape Pro gets Auto sleep without debounce or signal", () => {
const has = cardAvailability(snapshot({
status: {
Expand Down
13 changes: 12 additions & 1 deletion src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export interface CardAvailability {
eggCpi: boolean;
eggButtons: boolean;
razerButtons: boolean;
atkButtons: boolean;
atkProfile: boolean;
atkReceiver: boolean;
mxMasterButtons: boolean;
pulsarPro: boolean;
onboardProfiles: boolean;
Expand Down Expand Up @@ -59,6 +62,9 @@ const NOTHING: CardAvailability = {
eggCpi: false,
eggButtons: false,
razerButtons: false,
atkButtons: false,
atkProfile: false,
atkReceiver: false,
mxMasterButtons: false,
pulsarPro: false,
onboardProfiles: false,
Expand Down Expand Up @@ -88,6 +94,7 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
|| (status.rippleControl != null && ui?.hideRippleControl !== true)
|| (status.performanceMode != null && !traits.eggFamily && !traits.finalmouse)
|| status.hyperMode != null
|| status.longRangeMode != null
|| status.sensorMode != null || status.performanceDuration != null
);

Expand Down Expand Up @@ -126,7 +133,8 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
&& Boolean(status.ninjutsoSystemMode || status.ninjutsoOpticalEngine),
ninjutsoClick: host && traits.ninjutso
&& Boolean(status.ninjutsoHyperClick != null || status.ninjutsoSlamClick),
teevolutionDpiLighting: host && traits.teevolution && capabilities?.teevolutionProfile != null,
teevolutionDpiLighting: host && (ui?.dpiLighting != null
|| (traits.teevolution && capabilities?.teevolutionProfile != null)),
finalmouse: host && traits.finalmouse,
eggFilter: eggs,
eggSpdt: eggs,
Expand All @@ -144,6 +152,9 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
// the driver's own field directly. Only the base RazerHidClient populates
// it, and only for a product whose profile sets buttonMapping.
razerButtons: status.razerButtonMappings != null,
atkButtons: (status.atkButtonMappings?.length ?? 0) > 0,
atkProfile: status.atkProfileCount !== undefined && status.activeProfile !== null,
atkReceiver: status.atkReceiver !== undefined,
mxMasterButtons: traits.logitech && (snapshot.buttons?.length ?? 0) > 0,
pulsarPro: host && isPulsarProProtocol(status),
};
Expand Down
Loading