feat(sat): Doppler tracking on the radio

The hard part of satellite tuning is not the arithmetic, it is deciding
who owns the dial. A tracker that forces both frequencies fights the
operator every time they turn the knob to follow a station across a
linear transponder; one that never touches the receiver leaves them
chasing a signal that slides nine kilohertz across a 70 cm pass.

So the operator owns the receiver and the tracker follows them. Every
second it asks the radio where the receiver actually is. Where it put it,
nothing has changed. Further than a dial-turn's tolerance, and the
operator has chosen a station: what they landed on is converted back into
a nominal frequency, and the transmitter is derived from that. Which is
the division of labour on a linear bird — the operator listens, the radio
does the sums.

Three ways to reach the radio, because a satellite pair is a shape of
operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is
asked for its OWN satellite mode: it pairs main and sub, gives full
duplex, and keeps the dials linked the way its designers meant, which is
always better than an imitation built out of split. A Flex gets two
slices, A the downlink and B the uplink, created when missing, because
"slice B does not exist" is not something to make an operator fix at the
start of a ten-minute pass. Everything else gets the downlink, and is
told so — half the job announced beats half the job hidden.

What goes in the log is the NOMINAL pair. Two stations working each other
through a transponder read different numbers off their dials at the same
instant; the only figure they can both agree on is the transponder's own.
FREQ is the uplink and FREQ_RX the downlink — the one place a satellite
QSO differs from every other kind, and the reason FREQ alone cannot
describe one.
This commit is contained in:
2026-09-07 11:27:06 +02:00
parent 680bf410fe
commit 465481f8f1
17 changed files with 1146 additions and 4 deletions
+55 -1
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste } from 'lucide-react';
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste, Radio } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
GetSatSettings, SaveSatSettings,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
@@ -45,6 +46,13 @@ type Tuning = {
ctcss: number; inverting: boolean;
az: number; el: number; range_km: number; range_rate: number; visible: boolean;
};
type Track = {
on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
};
const MAP_VIEW_SAT = 'opslog.satMapView';
@@ -76,6 +84,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [passes, setPasses] = useState<Pass[]>([]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [tracking, setTracking] = useState<Track | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [favs, setFavs] = useState<string[]>([]);
@@ -159,6 +168,32 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
return () => window.clearInterval(id);
}, [loadPasses]);
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
// rate, so a panel opened while tracking is already running is not blank
// until the next tick.
useEffect(() => {
const read = async () => {
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
};
read();
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
const id = window.setInterval(read, 10_000);
return () => { off(); window.clearInterval(id); };
}, []);
const toggleTracking = async () => {
setErr('');
try {
if (tracking?.on) {
await StopSatelliteTracking();
setTracking(null);
} else {
await StartSatelliteTracking(sel, tpIdx);
setTracking((await GetSatelliteTracking()) as any);
}
} catch (e: any) { setErr(String(e?.message ?? e)); }
};
const refreshTle = async () => {
setBusy(true); setErr('');
try {
@@ -336,6 +371,25 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<Star className={cn('size-3.5', favs.includes(bird.name) && 'fill-warning text-warning')} />
</Button>
)}
{/* Tracking is the one button on this panel that touches the radio, so
it says which of the two things it is doing: holding both ends of
the pass, or only the receiver on a rig with one. */}
<Button
size="sm"
variant={tracking?.on ? 'default' : 'outline'}
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
onClick={toggleTracking}
disabled={!bird?.transponders?.length}
title={tracking?.on
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
: t('sat.trackTip')}
>
<Radio className="size-3.5" />
{tracking?.on ? t('sat.tracking') : t('sat.track')}
</Button>
{tracking?.on && tracking.radio === 'downlink-only' && (
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
)}
<div className="flex-1" />
<span className={cn('text-[11px] tabular-nums', tle?.stale ? 'text-warning' : 'text-muted-foreground')}>
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''}
+10
View File
@@ -582,6 +582,11 @@ const en: Dict = {
'sat.noElements': 'no elements', 'sat.inverting': 'inverting', 'sat.geo': 'geostationary', 'sat.favTip': 'Track this satellite by default',
'sat.paste': 'Paste…', 'sat.pasteTip': 'Paste elements for a satellite no feed carries yet. They are kept in their own file and survive every refresh.',
'sat.pastePrompt': 'Paste the elements (name, then the two lines):', 'sat.pasteNone': 'Nothing usable in that text.',
'sat.track': 'Track', 'sat.tracking': 'Tracking',
'sat.trackTip': 'Put the radio on this satellite and keep it there: the downlink and the uplink both corrected for Doppler, once a second. Tune the receiver freely — the transmitter follows where you land.',
'sat.trackingFull': 'Tracking both ends of the pass. Tune the receiver freely; the transmitter follows.',
'sat.trackingDown': 'Tracking the downlink only — this radio has one receiver.',
'sat.downlinkOnly': 'downlink only', 'sat.nominal': 'nominal',
};
const fr: Dict = {
@@ -1127,6 +1132,11 @@ const fr: Dict = {
'sat.noElements': 'sans éléments', 'sat.inverting': 'inverseur', 'sat.geo': 'géostationnaire', 'sat.favTip': 'Suivre ce satellite par défaut',
'sat.paste': 'Coller…', 'sat.pasteTip': 'Collez les éléments dun satellite quaucun flux ne diffuse encore. Ils sont conservés dans leur propre fichier et survivent à chaque mise à jour.',
'sat.pastePrompt': 'Collez les éléments (nom, puis les deux lignes) :', 'sat.pasteNone': 'Rien dutilisable dans ce texte.',
'sat.track': 'Suivre', 'sat.tracking': 'Suivi',
'sat.trackTip': 'Met la radio sur ce satellite et ly garde : descente et montée corrigées du Doppler, chaque seconde. Accordez le récepteur librement — l’émetteur suit là où vous vous posez.',
'sat.trackingFull': 'Les deux bouts du passage sont suivis. Accordez le récepteur librement, l’émetteur suit.',
'sat.trackingDown': 'Seule la descente est suivie — cette radio na quun récepteur.',
'sat.downlinkOnly': 'descente seule', 'sat.nominal': 'nominal',
};
const dicts: Record<Lang, Dict> = { en, fr };
+6
View File
@@ -605,6 +605,8 @@ export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Posi
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
export function GetScpStatus():Promise<main.ScpStatus>;
@@ -1379,10 +1381,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function StopSatelliteTracking():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>;
+12
View File
@@ -1142,6 +1142,10 @@ export function GetSatelliteTLEInfo() {
return window['go']['main']['App']['GetSatelliteTLEInfo']();
}
export function GetSatelliteTracking() {
return window['go']['main']['App']['GetSatelliteTracking']();
}
export function GetSatelliteTuning(arg1, arg2, arg3) {
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
}
@@ -2690,6 +2694,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StartSatelliteTracking(arg1, arg2) {
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
@@ -2698,6 +2706,10 @@ export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function StopSatelliteTracking() {
return window['go']['main']['App']['StopSatelliteTracking']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
+36
View File
@@ -4136,6 +4136,42 @@ export namespace main {
return a;
}
}
export class SatTrackStatus {
on: boolean;
name: string;
transponder: string;
mode: string;
nominal_down: number;
nominal_up: number;
down_hz: number;
up_hz: number;
az: number;
el: number;
visible: boolean;
radio: string;
error: string;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.on = source["on"];
this.name = source["name"];
this.transponder = source["transponder"];
this.mode = source["mode"];
this.nominal_down = source["nominal_down"];
this.nominal_up = source["nominal_up"];
this.down_hz = source["down_hz"];
this.up_hz = source["up_hz"];
this.az = source["az"];
this.el = source["el"];
this.visible = source["visible"];
this.radio = source["radio"];
this.error = source["error"];
}
}
export class SatTuning {
name: string;