Skip to content
Open
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
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 @@ -144,6 +145,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.pulsarPro, ["profiles"]) ? <PulsarProCard key="pulsarpro" snapshot={snapshot} /> : null,
].filter((node) => node !== null);

Expand Down Expand Up @@ -290,6 +294,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 @@ -263,6 +267,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 @@ -300,7 +305,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 @@ -327,6 +332,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 @@ -349,8 +361,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 @@ -368,7 +388,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 @@ -382,14 +402,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 @@ -404,14 +424,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;
profiles: boolean;
Expand Down Expand Up @@ -56,6 +59,9 @@ const NOTHING: CardAvailability = {
eggCpi: false,
eggButtons: false,
razerButtons: false,
atkButtons: false,
atkProfile: false,
atkReceiver: false,
mxMasterButtons: false,
pulsarPro: false,
profiles: false,
Expand All @@ -82,6 +88,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 @@ -117,7 +124,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 @@ -135,6 +143,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