feat(rotator): multiple rotors of any type, plus a CAT PTT hotkey

Settings -> Rotator is now a list like the amplifiers: add/remove rotors,
mix PstRotator / Rotator Genius / ARCO, and a Rotator Genius can drive both
its ports (one entry = two rotors). The compass gains a rotor selector, and
a per-rotor motorized-antenna flag so the boom/pattern paths show only for
the rotor carrying the Ultrabeam/SteppIR.

Also in this batch: a keyboard PTT hotkey (hold-to-talk or toggle, reusing
the audio PTT method with a CAT fallback), and TCI spot push to the
panadapter with click-to-fill of the callsign.
This commit is contained in:
2026-08-04 09:01:21 +02:00
parent 9846354bf9
commit b3fdd0b7fa
11 changed files with 964 additions and 441 deletions
+72 -7
View File
@@ -20,7 +20,7 @@ import {
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
@@ -29,7 +29,7 @@ import {
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings,
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
GetSolarData,
GetQSORate,
LoTWUserInfo,
@@ -628,6 +628,11 @@ export default function App() {
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
const digitalDefaultRef = useRef<string>('FT8');
// PTT hotkey config, refreshed by loadCATCfg. Held in a ref so the global
// key listener (registered once) always sees the current binding.
const pttHotkeyRef = useRef<{ enabled: boolean; code: string; toggle: boolean }>({ enabled: false, code: '', toggle: false });
const pttHeldRef = useRef(false); // hold-mode: PTT currently keyed by the hotkey
const pttToggledRef = useRef(false); // toggle-mode: latched TX state
// Don't override freq/band/mode the user JUST typed — track a small grace
// window after manual edits and skip CAT updates during it.
const catFreezeUntilRef = useRef<number>(0);
@@ -1764,23 +1769,28 @@ export default function App() {
: null
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
// Only the active rotor carrying the motorized antenna (Ultrabeam/SteppIR)
// shows the pattern paths; a plain rotor shows just its heading. The backend
// reports Motorized for the active rotor (true for a lone/standard rotor).
const activeRotorMotorized = (rotatorHeading as any).motorized !== false;
const beamHeadings = useMemo<number[]>(() => {
if (rotorAz == null) return [];
if (ubStatus.enabled && ubStatus.connected) {
if (activeRotorMotorized && ubStatus.enabled && ubStatus.connected) {
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
}
return [rotorAz];
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
}, [rotorAz, activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
// show where the antenna physically points (boom) vs where it radiates when
// the Ultrabeam is reversed/bidirectional.
const boomHeading = rotorAz;
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
if (!(ubStatus.enabled && ubStatus.connected)) return null;
if (!activeRotorMotorized || !(ubStatus.enabled && ubStatus.connected)) return null;
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
@@ -1927,6 +1937,48 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showToast]);
// PTT hotkey: a keyboard key keys the rig while OpsLog is focused. Registered
// once; reads the live binding from pttHotkeyRef (kept current by loadCATCfg).
// Hold-to-talk by default; toggle mode latches. We swallow the key (so the
// dedicated PTT key doesn't type a character) and ignore auto-repeat.
useEffect(() => {
const down = (e: KeyboardEvent) => {
const cfg = pttHotkeyRef.current;
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
if (e.ctrlKey || e.altKey || e.metaKey) return; // plain key only
e.preventDefault();
if (e.repeat) return;
if (cfg.toggle) {
pttToggledRef.current = !pttToggledRef.current;
(pttToggledRef.current ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(() => {});
} else if (!pttHeldRef.current) {
pttHeldRef.current = true;
PTTHotkeyDown().catch(() => {});
}
};
const up = (e: KeyboardEvent) => {
const cfg = pttHotkeyRef.current;
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
if (cfg.toggle) return; // toggle unkeys on the next press, not on release
if (pttHeldRef.current) {
pttHeldRef.current = false;
PTTHotkeyUp();
}
};
// Losing focus mid-hold would never deliver keyup — release so TX can't stick.
const blur = () => {
if (pttHeldRef.current) { pttHeldRef.current = false; PTTHotkeyUp(); }
};
window.addEventListener('keydown', down, true);
window.addEventListener('keyup', up, true);
window.addEventListener('blur', blur);
return () => {
window.removeEventListener('keydown', down, true);
window.removeEventListener('keyup', up, true);
window.removeEventListener('blur', blur);
};
}, []);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns).
useEffect(() => {
@@ -2110,6 +2162,11 @@ export default function App() {
try {
const c = await GetCATSettings();
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
pttHotkeyRef.current = {
enabled: !!(c as any).ptt_hotkey_enabled,
code: (c as any).ptt_hotkey ?? '',
toggle: !!(c as any).ptt_hotkey_toggle,
};
setCatBackend(c.backend ?? '');
} catch {}
}, []);
@@ -2571,6 +2628,11 @@ export default function App() {
const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
});
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
});
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0);
const processed = Number(p?.processed ?? 0);
@@ -2605,7 +2667,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
});
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -5552,6 +5614,9 @@ export default function App() {
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
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)))}
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
/>