Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion src/app/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 { AtkButtonCard, AtkDongleCard, AtkProfileCard, AtkReceiverCard, AtkSensorCard } from "./cards/AtkCards";
import {
DebounceCard,
EggButtonCard,
Expand Down Expand Up @@ -430,6 +430,7 @@ export function Workspace({
show(has.dpi, ["performance"]) ? <DpiCard key="dpi" snapshot={snapshot} /> : null,
show(has.polling, ["performance"]) ? <PollingCard key="polling" snapshot={snapshot} /> : null,
show(has.sensor, ["performance"]) ? <SensorCard key="sensor" snapshot={snapshot} /> : null,
show(has.atkF1Sensor, ["performance"]) ? <AtkSensorCard key="atk-sensor" snapshot={snapshot} /> : null,
show(has.lightforce, ["buttons"]) ? <LightforceCard key="lightforce" snapshot={snapshot} /> : null,
show(has.ninjutsoSensor, ["performance"])
? <NinjutsoSensorCard key="ninjutso-sensor" snapshot={snapshot} /> : null,
Expand Down Expand Up @@ -462,6 +463,7 @@ export function Workspace({
show(has.incott, ["advanced"]) ? <IncottCard key="incott" snapshot={snapshot} /> : null,
show(has.atkProfile, ["profiles"]) ? <AtkProfileCard key="atk-profile" snapshot={snapshot} /> : null,
device && show(has.atkReceiver, ["advanced"]) ? <AtkReceiverCard key="atk-receiver" snapshot={snapshot} /> : null,
show(has.atkF1Dongle, ["advanced"]) ? <AtkDongleCard key="atk-dongle" snapshot={snapshot} /> : null,
show(has.onboardProfiles && !snapshot.traits.teevolution, ["profiles"])
? <OnboardProfileCard key="onboard-profile" snapshot={snapshot} /> : null,
show(has.pulsarPro, ["profiles"]) ? <PulsarProCard key="pulsarpro" snapshot={snapshot} /> : null,
Expand Down
137 changes: 135 additions & 2 deletions src/app/cards/AdvancedCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,49 @@ export function DebounceCard({ snapshot }: { snapshot: ControlSnapshot }): React
const status = snapshot.status;
if (!status) return null;
const locale = snapshot.preferences.locale;
// F1 Ultimate mirrors the vendor HUB: on/off plus a 0-20 ms slider. The HUB
// lists 0/1/2/4/8/15/20; the driver accepts the full range and the
// read-back rejects anything the firmware refuses.
if (status.atkSensorMode != null && status.debounceMs != null) {
const ms = status.debounceMs;
const staged = snapshot.pending.keys.includes("debounce");
return (
<article id="debounce-settings" className={`setting-card${staged ? " is-staged" : ""}`}>
<div className="setting-heading compact"><div><p>CLICK</p><h2>{t(locale, "adv.debounce")}</h2></div></div>
<div className="angle-tuning-control" data-pending-key="debounce">
<SwitchRow
id="atk-debounce-toggle"
label="Key debounce"
value={ms > 0}
disabled={snapshot.settingInProgress}
onChange={(next) => control.applyPulsarValue("debounce", next ? 1 : 0)}
/>
<div className="angle-tuning-head">
<span>Debounce delay</span>
<output id="atk-debounce-value" htmlFor="atk-debounce-slider">{ms} ms</output>
</div>
<div className="angle-tuning-inputs">
<button type="button" aria-label="Debounce delay: decrease" disabled={ms <= 0} onClick={() => control.applyPulsarValue("debounce", Math.max(0, ms - 1))}>−</button>
<input
id="atk-debounce-slider"
type="range"
min={0}
max={20}
step={1}
value={ms}
disabled={snapshot.settingInProgress}
aria-label="Debounce delay"
aria-valuetext={`${ms} milliseconds`}
style={{ "--fill": `${(ms / 20) * 100}%` }}
onChange={(event) => control.applyPulsarValue("debounce", Number(event.currentTarget.value))}
/>
<button type="button" aria-label="Debounce delay: increase" disabled={ms >= 20} onClick={() => control.applyPulsarValue("debounce", Math.min(20, ms + 1))}>+</button>
</div>
<div className="angle-tuning-scale" aria-hidden="true"><span>0 ms</span><i>10 ms</i><span>20 ms</span></div>
</div>
</article>
);
}
const max = snapshot.traits.directMode
? snapshot.capabilities?.debounceMaxMs ?? 20
: snapshot.capabilities?.teevolutionProfile?.debounce.max ?? 20;
Expand Down Expand Up @@ -358,6 +401,12 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
hidden={ui?.hideRippleControl === true || traits.finalmouse}
onChange={(next) => control.applyPulsarToggle("rippleControl", next)}
/>
{status.atkAntiMistouchMs != null ? (
<AtkAntiMistouchControl
milliseconds={status.atkAntiMistouchMs}
busy={snapshot.settingInProgress}
/>
) : null}
<SwitchRow
id="performance-mode-toggle"
labelId="performance-mode-label"
Expand Down Expand Up @@ -396,7 +445,39 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
onChange={(next) => control.applyPulsarToggle("longRangeMode", next)}
/>
{angleTuning != null ? (
capabilities?.angleTuningWritable
status.atkSensorMode != null ? (
<div className="angle-tuning-control" data-pending-key="atk-rotation">
<SwitchRow
id="atk-rotation-toggle"
label="Sensor rotation"
value={false}
disabled
onChange={() => undefined}
/>
<div className="angle-tuning-head">
<span>Sensor rotation</span>
<output id="atk-rotation-value" htmlFor="atk-rotation-slider">0°</output>
</div>
<div className="angle-tuning-inputs">
<button type="button" aria-label="Sensor rotation: decrease" disabled>−</button>
<input
id="atk-rotation-slider"
type="range"
min={-30}
max={30}
step={15}
value={0}
disabled
aria-label="Sensor rotation"
aria-valuetext="0 degrees"
style={{ "--fill": "50%" }}
/>
<button type="button" aria-label="Sensor rotation: increase" disabled>+</button>
</div>
<div className="angle-tuning-scale" aria-hidden="true"><span>−30°</span><i>0°</i><span>30°</span></div>
<small className="setting-note">Precise horizontal movement regardless of mouse grip style. Rotation writes touch calibration and stay locked pending a USB capture — calibrate in ATK HUB for now.</small>
</div>
) : capabilities?.angleTuningWritable
? <AngleTuningControl value={angleTuning} label={t(locale, "adv.angleTune")} />
: (
<div className="angle-tuning-readonly field-label spaced">
Expand Down Expand Up @@ -425,8 +506,60 @@ export function ProcessingCard({ snapshot }: { snapshot: ControlSnapshot }): Rea
);
}

function AngleTuningControl({ value, label }: { value: number; label: string }): ReactNode {
/**
* ATK F1 Ultimate scroll anti-mistouch: on/off plus a 100-1000 ms window
* slider on the line below, mirroring the vendor HUB layout. Verified
* 100/500 ms on hardware; the driver accepts 10 ms steps.
*/
function AtkAntiMistouchControl({ milliseconds, busy }: { milliseconds: number; busy: boolean }): ReactNode {
const [dragging, setDragging] = useState<number | null>(null);
const shown = dragging ?? Math.max(100, milliseconds);
const apply = (next: number): void => {
void control.applyAtkAntiMistouch(Math.max(100, Math.min(1000, next)));
};
return (
<div className="angle-tuning-control" data-pending-key="atk-anti-mistouch">
<SwitchRow
id="atk-anti-mistouch-toggle"
label="Scroll Wheel Anti-Mistouch Mode"
value={milliseconds > 0}
disabled={busy}
onChange={(next) => void control.applyAtkAntiMistouch(next ? 100 : 0)}
/>
<div className="angle-tuning-head">
<span>Anti-mistouch window</span>
<output id="atk-anti-mistouch-value" htmlFor="atk-anti-mistouch-slider">{milliseconds} ms</output>
</div>
<div className="angle-tuning-inputs">
<button type="button" aria-label="Anti-mistouch window: decrease" disabled={milliseconds <= 0 || shown <= 100} onClick={() => apply(shown - 50)}>−</button>
<input
id="atk-anti-mistouch-slider"
type="range"
min={100}
max={1000}
step={50}
value={shown}
disabled={milliseconds <= 0 || busy}
aria-label="Anti-mistouch window"
aria-valuetext={`${shown} milliseconds`}
style={{ "--fill": `${((shown - 100) / 900) * 100}%` }}
onInput={(event) => setDragging(Number(event.currentTarget.value))}
onChange={(event) => {
const next = Number(event.currentTarget.value);
setDragging(null);
apply(next);
}}
onBlur={() => setDragging(null)}
/>
<button type="button" aria-label="Anti-mistouch window: increase" disabled={milliseconds <= 0 || shown >= 1000} onClick={() => apply(shown + 50)}>+</button>
</div>
<div className="angle-tuning-scale" aria-hidden="true"><span>100 ms</span><i>500 ms</i><span>1000 ms</span></div>
<div className="setting-separator" aria-hidden="true" />
</div>
);
}

function AngleTuningControl({ value, label }: { value: number; label: string }): ReactNode { const [dragging, setDragging] = useState<number | null>(null);
const shown = dragging ?? value;
const apply = (next: number): void => control.applyAngleTuning(Math.max(-30, Math.min(30, next)));

Expand Down
60 changes: 60 additions & 0 deletions src/app/cards/AtkCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,63 @@ export function AtkReceiverCard({ snapshot }: { snapshot: ControlSnapshot }): Re
</article>
);
}

const ATK_SENSOR_MODE_LABELS = ["Basic Mode", "ATK Shard Competitive Firmware", "ATK Shard Competitive Firmware MAX"] as const;
const ATK_DONGLE_LIGHT_LABELS = ["Close", "Polling Rate Effect", "Battery Gradient Light Effect", "Low Battery Effect"] as const;

/**
* F1 Ultimate extras, grouped like the ATK HUB pages: sensor sampling rate
* lives with polling under Performance, while scroll anti-mistouch and the
* receiver dongle LED live under Advanced (HUB Parameter page). Each control
* renders only when the driver actually reported the field; all three were
* verified on the F1 Ultimate 2.0 (CID 01, MID 08 over 373B:11D9).
*/
export function AtkSensorCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const sensorMode = snapshot.status?.atkSensorMode ?? null;
if (sensorMode == null) return null;
return (
<article id="atk-sensor-mode" className="setting-card">
<div className="setting-heading compact">
<div><p>PERFORMANCE</p><h2>Sensor Sampling Rate</h2></div>
</div>
<Segmented
ariaLabel="Sensor sampling mode"
options={ATK_SENSOR_MODE_LABELS.map((label, value) => ({ value, label }))}
value={sensorMode}
disabled={snapshot.settingInProgress}
className="three"
onChange={(mode) => void control.selectAtkSensorMode(mode)}
/>
<small className="setting-note setting-note-stable">
{sensorMode === 2
? "In this mode, the mouse sensor is in high performance state, high scanning frequency, more responsive control."
: sensorMode === 1
? "ATK Shard competitive firmware: balanced scan rate for match play."
: "Basic sensor mode for daily work and maximum battery life."}
</small>
</article>
);
}

export function AtkDongleCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode {
const dongleLight = snapshot.status?.atkDongleLight ?? (snapshot.status?.atkSensorMode != null ? 2 : null);
if (dongleLight == null) return null;
return (
<article id="atk-dongle-light" className="setting-card">
<div className="setting-heading compact">
<div><p>PARAMETER</p><h2>Dongle Light Effect</h2></div>
</div>
<small className="setting-note">Lighting Effect Mode</small>
<Segmented
ariaLabel="Dongle LED effect"
options={ATK_DONGLE_LIGHT_LABELS.map((label, value) => ({ value, label }))}
value={dongleLight}
disabled={snapshot.settingInProgress}
onChange={(mode) => void control.selectAtkDongleLight(mode)}
/>
<small className="setting-note">
Effect on the 8K receiver; write-only, confirmed on the LED.
</small>
</article>
);
}
8 changes: 8 additions & 0 deletions src/app/cards/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface CardAvailability {
atkButtons: boolean;
atkProfile: boolean;
atkReceiver: boolean;
atkF1Sensor: boolean;
atkF1Dongle: boolean;
mxMasterButtons: boolean;
pulsarPro: boolean;
onboardProfiles: boolean;
Expand Down Expand Up @@ -67,6 +69,8 @@ const NOTHING: CardAvailability = {
atkButtons: false,
atkProfile: false,
atkReceiver: false,
atkF1Sensor: false,
atkF1Dongle: false,
mxMasterButtons: false,
pulsarPro: false,
onboardProfiles: false,
Expand Down Expand Up @@ -173,6 +177,10 @@ export function cardAvailability(snapshot: ControlSnapshot): CardAvailability {
atkButtons: (status.atkButtonMappings?.length ?? 0) > 0,
atkProfile: status.atkProfileCount !== undefined && status.activeProfile !== null,
atkReceiver: status.atkReceiver !== undefined,
atkF1Sensor: status.atkSensorMode != null,
// Write-only with no read command: gate on F1 presence and default the
// selector to Battery (vendor default) until the first write lands.
atkF1Dongle: status.atkSensorMode != null,
mxMasterButtons: traits.logitech && (snapshot.buttons?.length ?? 0) > 0,
pulsarPro: host && isPulsarProProtocol(status),
};
Expand Down
2 changes: 2 additions & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
#polling-card .title-row p, #host-lod-row .title-row p { margin: 0; }
#polling-card .title-row h2, #host-lod-row .title-row h2 { font-size: 1.15rem; }
.setting-note { display: block; margin-top: 1rem; color: var(--text-soft); font-size: .72rem; line-height: 1.5; }
.setting-note-stable { min-height: 3em; }
.setting-action { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-top: .7rem; color: var(--text-soft); font-size: .7rem; font-weight: 500; }
.setting-action #dpi-pending { color: var(--text-strong); font-weight: 650; }
.setting-action button { padding: .4rem .65rem; border: 1px solid var(--text); border-radius: 6px; background: var(--text); color: var(--ink); font-size: .65rem; font-weight: 700; }
Expand Down Expand Up @@ -946,6 +947,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; }
.angle-tuning-inputs input[type="range"] { width: 100%; margin: 0; accent-color: var(--ui-accent); cursor: pointer; }
.angle-tuning-scale { display: flex; justify-content: space-between; margin: .12rem 2.15rem 0; color: var(--ghost); font: .48rem var(--font-mono); }
.angle-tuning-scale i { font-style: normal; }
.setting-separator { border-top: 1px solid var(--line-strong); margin-top: .55rem; }
.teevolution-dpi-light-controls { margin-top: .55rem; }

.egg-collapsible, .egg-experimental { overflow: hidden; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--card); }
Expand Down
9 changes: 9 additions & 0 deletions src/device/atk-fields.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {} from "@openmouse/protocol/drivers/mouse-types";

declare module "@openmouse/protocol/drivers/mouse-types" {
interface MouseStatus {
atkSensorMode?: number | null;
atkAntiMistouchMs?: number | null;
atkDongleLight?: number | null;
}
}
62 changes: 62 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,68 @@ export async function selectAtkR1Profile(profile: number): Promise<void> {
}
}

/**
* ATK F1 Ultimate settings (sensor mode, anti-mistouch, dongle light) use the
* shared staged flow: edits preview instantly and join the footer Apply bar
* like every other control.
*/
export function selectAtkSensorMode(mode: number): void {
if (!hasActiveClient()) return;
if (!Number.isInteger(mode) || mode < 0 || mode > 2) return;
stageChange({
key: "atk-sensor-mode",
label: `Sensor mode ${["Basic", "Shard", "MAX"][mode] ?? mode}`,
command: `Set ATK sensor mode to ${mode}`,
progress: `Setting ATK sensor mode…`,
preview: (status) => {
status.atkSensorMode = mode;
},
apply: async () => {
await (requireClientMethod("setAtkSensorMode", "the sensor mode") as unknown as {
setAtkSensorMode: (mode: number) => Promise<unknown>;
}).setAtkSensorMode(mode);
},
});
}

export function applyAtkAntiMistouch(milliseconds: number): void {
if (!hasActiveClient()) return;
if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > 2550) return;
stageChange({
key: "atk-anti-mistouch",
label: milliseconds === 0 ? "Anti-mistouch off" : `Anti-mistouch ${milliseconds} ms`,
command: `Set ATK anti-mistouch to ${milliseconds} ms`,
progress: `Setting ATK anti-mistouch…`,
preview: (status) => {
status.atkAntiMistouchMs = milliseconds;
},
apply: async () => {
await (requireClientMethod("setAntiMistouchMs", "anti-mistouch") as unknown as {
setAntiMistouchMs: (milliseconds: number) => Promise<unknown>;
}).setAntiMistouchMs(milliseconds);
},
});
}

export function selectAtkDongleLight(mode: number): void {
if (!hasActiveClient()) return;
if (!Number.isInteger(mode) || mode < 0 || mode > 3) return;
stageChange({
key: "atk-dongle-light",
label: `Dongle light ${["Off", "Polling", "Battery", "Low battery"][mode] ?? mode}`,
command: `Set ATK dongle light to ${mode}`,
progress: `Setting ATK dongle light…`,
preview: (status) => {
status.atkDongleLight = mode;
},
apply: async () => {
await (requireClientMethod("setDongleLight", "the dongle light") as unknown as {
setDongleLight: (mode: number) => Promise<unknown>;
}).setDongleLight(mode);
},
});
}

export async function pairAtkR1SePlusReceiver(): Promise<void> {
if (blockedByGameProfileDraft()) return;
const client = activeAs(AtkHidClient);
Expand Down
Loading