diff --git a/src/app/OverviewPage.tsx b/src/app/OverviewPage.tsx index 232a71a6..0fa62571 100644 --- a/src/app/OverviewPage.tsx +++ b/src/app/OverviewPage.tsx @@ -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, @@ -430,6 +430,7 @@ export function Workspace({ show(has.dpi, ["performance"]) ? : null, show(has.polling, ["performance"]) ? : null, show(has.sensor, ["performance"]) ? : null, + show(has.atkF1Sensor, ["performance"]) ? : null, show(has.lightforce, ["buttons"]) ? : null, show(has.ninjutsoSensor, ["performance"]) ? : null, @@ -462,6 +463,7 @@ export function Workspace({ show(has.incott, ["advanced"]) ? : null, show(has.atkProfile, ["profiles"]) ? : null, device && show(has.atkReceiver, ["advanced"]) ? : null, + show(has.atkF1Dongle, ["advanced"]) ? : null, show(has.onboardProfiles && !snapshot.traits.teevolution, ["profiles"]) ? : null, show(has.pulsarPro, ["profiles"]) ? : null, diff --git a/src/app/cards/AdvancedCards.tsx b/src/app/cards/AdvancedCards.tsx index 63bfe955..f0295971 100644 --- a/src/app/cards/AdvancedCards.tsx +++ b/src/app/cards/AdvancedCards.tsx @@ -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 ( +
+

CLICK

{t(locale, "adv.debounce")}

+
+ 0} + disabled={snapshot.settingInProgress} + onChange={(next) => control.applyPulsarValue("debounce", next ? 1 : 0)} + /> +
+ Debounce delay + {ms} ms +
+
+ + control.applyPulsarValue("debounce", Number(event.currentTarget.value))} + /> + +
+ +
+
+ ); + } const max = snapshot.traits.directMode ? snapshot.capabilities?.debounceMaxMs ?? 20 : snapshot.capabilities?.teevolutionProfile?.debounce.max ?? 20; @@ -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 ? ( + + ) : null} control.applyPulsarToggle("longRangeMode", next)} /> {angleTuning != null ? ( - capabilities?.angleTuningWritable + status.atkSensorMode != null ? ( +
+ undefined} + /> +
+ Sensor rotation + 0° +
+
+ + + +
+ + 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. +
+ ) : capabilities?.angleTuningWritable ? : (
@@ -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(null); + const shown = dragging ?? Math.max(100, milliseconds); + const apply = (next: number): void => { + void control.applyAtkAntiMistouch(Math.max(100, Math.min(1000, next))); + }; + return ( +
+ 0} + disabled={busy} + onChange={(next) => void control.applyAtkAntiMistouch(next ? 100 : 0)} + /> +
+ Anti-mistouch window + {milliseconds} ms +
+
+ + setDragging(Number(event.currentTarget.value))} + onChange={(event) => { + const next = Number(event.currentTarget.value); + setDragging(null); + apply(next); + }} + onBlur={() => setDragging(null)} + /> + +
+ + + ); +} + +function AngleTuningControl({ value, label }: { value: number; label: string }): ReactNode { const [dragging, setDragging] = useState(null); const shown = dragging ?? value; const apply = (next: number): void => control.applyAngleTuning(Math.max(-30, Math.min(30, next))); diff --git a/src/app/cards/AtkCards.tsx b/src/app/cards/AtkCards.tsx index 11dc2188..14ec9a26 100644 --- a/src/app/cards/AtkCards.tsx +++ b/src/app/cards/AtkCards.tsx @@ -115,3 +115,63 @@ export function AtkReceiverCard({ snapshot }: { snapshot: ControlSnapshot }): Re ); } + +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 ( +
+
+

PERFORMANCE

Sensor Sampling Rate

+
+ ({ value, label }))} + value={sensorMode} + disabled={snapshot.settingInProgress} + className="three" + onChange={(mode) => void control.selectAtkSensorMode(mode)} + /> + + {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."} + +
+ ); +} + +export function AtkDongleCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const dongleLight = snapshot.status?.atkDongleLight ?? (snapshot.status?.atkSensorMode != null ? 2 : null); + if (dongleLight == null) return null; + return ( +
+
+

PARAMETER

Dongle Light Effect

+
+ Lighting Effect Mode + ({ value, label }))} + value={dongleLight} + disabled={snapshot.settingInProgress} + onChange={(mode) => void control.selectAtkDongleLight(mode)} + /> + + Effect on the 8K receiver; write-only, confirmed on the LED. + +
+ ); +} diff --git a/src/app/cards/availability.ts b/src/app/cards/availability.ts index db6c717c..c401fbac 100644 --- a/src/app/cards/availability.ts +++ b/src/app/cards/availability.ts @@ -29,6 +29,8 @@ export interface CardAvailability { atkButtons: boolean; atkProfile: boolean; atkReceiver: boolean; + atkF1Sensor: boolean; + atkF1Dongle: boolean; mxMasterButtons: boolean; pulsarPro: boolean; onboardProfiles: boolean; @@ -67,6 +69,8 @@ const NOTHING: CardAvailability = { atkButtons: false, atkProfile: false, atkReceiver: false, + atkF1Sensor: false, + atkF1Dongle: false, mxMasterButtons: false, pulsarPro: false, onboardProfiles: false, @@ -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), }; diff --git a/src/control.css b/src/control.css index d4e0d47b..0527b5c8 100644 --- a/src/control.css +++ b/src/control.css @@ -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; } @@ -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); } diff --git a/src/device/atk-fields.d.ts b/src/device/atk-fields.d.ts new file mode 100644 index 00000000..0d139129 --- /dev/null +++ b/src/device/atk-fields.d.ts @@ -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; + } +} diff --git a/src/device/controller.ts b/src/device/controller.ts index bef043cd..42ac46d5 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -1302,6 +1302,68 @@ export async function selectAtkR1Profile(profile: number): Promise { } } +/** + * 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; + }).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; + }).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; + }).setDongleLight(mode); + }, + }); +} + export async function pairAtkR1SePlusReceiver(): Promise { if (blockedByGameProfileDraft()) return; const client = activeAs(AtkHidClient);