perf(rotator): one adaptive heading poll for every controller type

Asked whether the faster poll applies to all the rotator backends. It does —
GetRotatorHeading is one binding over PstRotator, Rotator Genius, GS-232/ARCO,
DCU-1 and SPID alike, and it reads only the ACTIVE rotor, so two towers do not
double it. But counting what that costs turned up two things worth fixing.

Every backend builds a fresh client per call — spid.New, gs232.NewSerial,
dcu1.NewSerial, rotgenius.New — so one poll is one OPEN and CLOSE of a serial
port or a TCP connection, not a read on a link already up. And the status bar
and the Station Control compass each ran their own interval against that same
binding, so with the tab open the controller was asked twice over. At the 700 ms
I had just set, that was nearly three port opens a second on a 600-baud line.

Both now share one loop, and it adapts: 500 ms while the position is changing,
3 s once it has been still for six seconds. These controllers do not report
"moving" — a SPID answers a position and nothing else — so it is inferred from
the position itself, and held briefly after the last change so the tail of a
movement stays smooth. Commanding a move, a stop, or switching rotor polls at
once, so the needle starts sweeping on the click.

A slow controller cannot stack requests behind itself either: at 600 baud a SPID
reply takes a fifth of a second on the wire alone, and a port still open from
the last poll cannot be opened again.
This commit is contained in:
2026-08-16 13:39:23 +02:00
parent f405e71d7d
commit 191e92ad62
4 changed files with 113 additions and 38 deletions
+11 -20
View File
@@ -21,7 +21,7 @@ import {
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
RotatorGoTo, RotatorStop, SetActiveRotor,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
@@ -107,6 +107,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
import { titleCase, sentenceCase } from '@/lib/textCase';
@@ -2390,22 +2391,12 @@ export default function App() {
};
}, []);
// Poll the rotator for the live antenna heading (status bar and compass).
// Cheap when it is disabled — the backend just reads settings and returns.
//
// 700 ms, not three seconds: a turning antenna moved the needle in steps of
// about thirteen degrees, which reads as a compass that jumps rather than one
// that sweeps. A heading query is a few bytes on a serial controller or one
// short exchange over TCP.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
};
tick();
const id = window.setInterval(tick, 700);
return () => { alive = false; window.clearInterval(id); };
}, []);
// The live antenna heading, from the shared poller in lib/rotorHeading — fast
// while the antenna turns, slow while it is parked, and ONE loop however many
// panels are watching. This used to be its own interval alongside Station
// Control's, so the controller was polled twice over whenever that tab was
// open, and every poll opens and closes the port.
useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);
// Poll the Ultrabeam antenna for its connection + pattern direction.
useEffect(() => {
@@ -5496,7 +5487,7 @@ export default function App() {
</button>
<button
type="button"
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
onClick={() => { pokeRotorHeading(); RotatorStop().catch((err) => setError(String(err?.message ?? err))); }}
title="Stop rotation"
className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
>
@@ -6286,8 +6277,8 @@ export default function App() {
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
rotors={(rotatorHeading as any).rotors}
activeRotor={(rotatorHeading as any).active}
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
onSelectRotor={(i) => { SetActiveRotor(i).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
onGoto={(az) => { RotatorGoTo(Math.round(az), -1).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
/>
</div>