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 -16
View File
@@ -7,13 +7,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
RotatorGoTo, RotatorStop, SetActiveRotor,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
@@ -547,27 +548,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
} catch { /* ignore transient */ }
}, []);
const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []);
const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []);
useEffect(() => { loadDevices(); }, [loadDevices]);
// The heading comes from the shared poller: one loop for the whole app, fast
// while the antenna turns and slow while it is parked. Every poll opens and
// closes the controller's port, and this panel used to run its own alongside
// the status bar's — the same controller asked twice over.
useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
useEffect(() => {
poll(); pollRot(); pollAnt();
poll(); pollAnt();
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
// The rotor gets its own, faster tick. On three seconds the compass moved in
// steps of about thirteen degrees while the antenna was turning, which reads
// as a needle that jumps rather than one that sweeps — and an operator
// watching a tower wants to see it move. One heading query is a few bytes on
// a slow serial line or one short TCP exchange; the relay boards and the
// antenna controller are the expensive polls, and they stay at three
// seconds.
const rotId = window.setInterval(() => { pollRot(); }, 700);
return () => { window.clearInterval(id); window.clearInterval(rotId); };
}, [poll, pollRot, pollAnt, devices.length]);
return () => window.clearInterval(id);
}, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`.
@@ -673,7 +668,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });