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:
+72
-7
@@ -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'); }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user