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
+4 -2
View File
@@ -10,7 +10,8 @@
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.", "Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.",
"New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts.", "New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts.",
"SPID rotator: a Rot1Prog controller turned nearly a full circle the wrong way for every heading — its commands take three digits, not four.", "SPID rotator: a Rot1Prog controller turned nearly a full circle the wrong way for every heading — its commands take three digits, not four.",
"The compass now follows a turning antenna smoothly, and a rotator test that only reads the heading says so instead of naming PstRotator." "The compass sweeps with a turning antenna instead of jumping, and the controller is polled once for the whole app rather than by each panel.",
"A rotator test that only reads the heading now says so, instead of naming PstRotator for a move it never commanded."
], ],
"fr": [ "fr": [
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.", "Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
@@ -20,7 +21,8 @@
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».", "Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».",
"Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W.", "Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W.",
"Rotator SPID : un contrôleur Rot1Prog tournait presque un tour complet à lenvers pour chaque azimut — ses commandes tiennent trois chiffres, pas quatre.", "Rotator SPID : un contrôleur Rot1Prog tournait presque un tour complet à lenvers pour chaque azimut — ses commandes tiennent trois chiffres, pas quatre.",
"Le compas suit désormais une antenne en rotation sans à-coups, et un test de rotator qui ne fait que lire lazimut le dit au lieu de citer PstRotator." "Le compas suit lantenne en rotation au lieu de sauter, et le contrôleur est interrogé une fois pour toute lapplication, plus par chaque panneau.",
"Un test de rotator qui ne fait que lire lazimut le dit, au lieu de citer PstRotator pour un mouvement jamais commandé."
] ]
}, },
{ {
+11 -20
View File
@@ -21,7 +21,7 @@ import {
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor, RotatorGoTo, RotatorStop, SetActiveRotor,
GetDBConnectionInfo, GetLogbookRevision, GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, UILog, GetUltrabeamStatus, SetUltrabeamDirection, UILog,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
@@ -107,6 +107,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat'; import { formatDateTimeUTC } from '@/lib/dateFormat';
import { titleCase, sentenceCase } from '@/lib/textCase'; 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). // The live antenna heading, from the shared poller in lib/rotorHeading — fast
// Cheap when it is disabled — the backend just reads settings and returns. // 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
// 700 ms, not three seconds: a turning antenna moved the needle in steps of // Control's, so the controller was polled twice over whenever that tab was
// about thirteen degrees, which reads as a compass that jumps rather than one // open, and every poll opens and closes the port.
// that sweeps. A heading query is a few bytes on a serial controller or one useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);
// 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); };
}, []);
// Poll the Ultrabeam antenna for its connection + pattern direction. // Poll the Ultrabeam antenna for its connection + pattern direction.
useEffect(() => { useEffect(() => {
@@ -5496,7 +5487,7 @@ export default function App() {
</button> </button>
<button <button
type="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" 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" 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} rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
rotors={(rotatorHeading as any).rotors} rotors={(rotatorHeading as any).rotors}
activeRotor={(rotatorHeading as any).active} activeRotor={(rotatorHeading as any).active}
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).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).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'); }} onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
/> />
</div> </div>
+11 -16
View File
@@ -7,13 +7,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard'; import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard'; import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel'; import type { TGStatus } from '@/components/TunerGeniusPanel';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor, RotatorGoTo, RotatorStop, SetActiveRotor,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow, MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
ListDenkoviDevices, ListSerialPorts, TestStationDevice, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
@@ -547,27 +548,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
setStatus(Object.fromEntries(s.map((d) => [d.id, d]))); setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
} catch { /* ignore transient */ } } catch { /* ignore transient */ }
}, []); }, []);
const pollRot = useCallback(async () => {
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
}, []);
const pollAnt = useCallback(async () => { const pollAnt = useCallback(async () => {
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ } try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
}, []); }, []);
useEffect(() => { loadDevices(); }, [loadDevices]); 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(() => { useEffect(() => {
poll(); pollRot(); pollAnt(); poll(); pollAnt();
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000); const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
// The rotor gets its own, faster tick. On three seconds the compass moved in return () => window.clearInterval(id);
// steps of about thirteen degrees while the antenna was turning, which reads }, [poll, pollAnt, devices.length]);
// 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]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Reorder so `dragged` lands just before `target`. // 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. // full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = []; const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) { 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) { if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> }); widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
+87
View File
@@ -0,0 +1,87 @@
import { GetRotatorHeading } from '../../wailsjs/go/main/App';
// One poll loop for the antenna heading, shared by everything that shows it.
//
// TWO PROBLEMS THIS FIXES, both of them invisible until you count.
//
// The status bar polled, and the Station Control compass polled, and they polled
// the SAME binding — so with that tab open the rotator was asked twice as often
// as either component believed. 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 a fixed interval has no good value. Three seconds moved the needle in
// steps of about thirteen degrees on a turning antenna — a compass that jumps
// rather than sweeps. Seven hundred milliseconds sweeps beautifully and opens
// the controller's port about ten thousand times an hour to watch an antenna
// that has not moved since breakfast.
//
// So: fast while it is turning, slow while it is not. "Turning" is not something
// these controllers report — a SPID answers a position and nothing else — so it
// is inferred from the position changing, and held for a few seconds after the
// last change so the tail of a movement stays smooth.
export type RotorHeading = {
enabled: boolean; ok: boolean; azimuth: number;
rotors?: string[]; active?: number; motorized?: boolean; raw?: string;
};
const MOVING_MS = 500; // while the antenna is turning
const IDLE_MS = 3000; // while it is parked — the rate everything used before
const SETTLE_MS = 6000; // stay fast this long after the last movement
const subs = new Set<(h: RotorHeading) => void>();
let timer: number | undefined;
let inFlight = false;
let lastAz: number | null = null;
let lastMoveAt = 0;
function schedule(delay: number) {
if (timer !== undefined) window.clearTimeout(timer);
timer = window.setTimeout(tick, delay);
}
async function tick() {
// A slow controller must not stack requests behind itself: at 600 baud a SPID
// reply takes a fifth of a second on the wire alone, and a port that is still
// open from the last poll cannot be opened again.
if (inFlight) { schedule(MOVING_MS); return; }
inFlight = true;
try {
const h = (await GetRotatorHeading()) as unknown as RotorHeading;
if (h?.ok && typeof h.azimuth === 'number') {
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
lastAz = h.azimuth;
}
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
} catch {
// Leave the last heading alone: a single failed poll on a shared serial port
// is not news, and blanking the compass on one would make it flicker.
} finally {
inFlight = false;
}
if (subs.size > 0) schedule(Date.now() - lastMoveAt < SETTLE_MS ? MOVING_MS : IDLE_MS);
else timer = undefined;
}
// subscribeRotorHeading starts the loop if it is not running and returns the
// unsubscribe. The loop stops when the last subscriber leaves.
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
subs.add(fn);
if (timer === undefined && !inFlight) void tick();
return () => {
subs.delete(fn);
if (subs.size === 0 && timer !== undefined) { window.clearTimeout(timer); timer = undefined; }
};
}
// pokeRotorHeading polls at once and switches to the fast rate — call it after
// commanding a move, so the needle starts sweeping on the click rather than on
// whatever was left of a three-second tick.
export function pokeRotorHeading(): void {
lastMoveAt = Date.now();
if (timer !== undefined) window.clearTimeout(timer);
timer = undefined;
void tick();
}