feat(sat): point the antenna — EasyComm II az/el rotator

EasyComm is what satellite rotator controllers agreed on, so a box that
works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its
own settings rather than the HF rotator's: an az/el pair is a different
machine on a different port, and an operator who has both must not have
to choose.

A great many EasyComm controllers — the Arduino trackers above all —
accept commands and never say a word back. That is legal and common, so a
silent controller is not treated as a broken one: it is still driven, and
the last commanded position is reported in its place, marked as commanded
rather than read. A stuck rotator must not be able to hide behind an
order it never carried out, which is why the panel shows the antenna's
position beside the satellite's.

The 450° overlap is the reason a satellite rotator is worth having, so it
is used: a pass crossing north continues past 360 instead of unwinding
three quarters of a turn with the antenna sweeping the ground. Below the
configured elevation the mast is left alone — the numbers are right all
the way round the orbit, but a rotator that chases a satellite through
the far side of the earth spends the night turning, and a mast has a
finite number of turns in it.
This commit is contained in:
2026-09-07 11:34:35 +02:00
parent 465481f8f1
commit 90e363f49e
11 changed files with 953 additions and 11 deletions
@@ -52,6 +52,7 @@ type Track = {
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
};
const MAP_VIEW_SAT = 'opslog.satMapView';
@@ -445,6 +446,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{tp?.inverting && <span>{t('sat.inverting')}</span>}
{bird?.geostationary && <span>{t('sat.geo')}</span>}
</div>
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</div>
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
+171
View File
@@ -19,6 +19,7 @@ import {
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings,
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
@@ -228,6 +229,7 @@ type SectionId =
| 'antgenius'
| 'tunergenius'
| 'psu'
| 'satellite'
| 'pgxl'
| 'flex'
| 'relayauto'
@@ -294,6 +296,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
const hardware: TreeNode[] = [
{ kind: 'item', label: t('sec.cat'), id: 'cat' },
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
{ kind: 'item', label: t('sec.satellite'), id: 'satellite' },
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
@@ -1679,6 +1682,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
// Satellites: the observer and the az/el rotator. The rest of the satellite
// settings (favourites, the pass window) are set in the tab itself, where
// they are used.
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, rot_min_el: 0, rot_step: 5, rot_park: false });
const [satTest, setSatTest] = useState('');
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
// each with its own connection. Saved as a whole via SaveAmplifiers.
@@ -2286,6 +2294,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
try { setSatCfg(await GetSatSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
setBackupCfg(b as any);
setQslDefaults(qd as any);
@@ -2330,6 +2339,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
try { setSatCfg(await GetSatSettings() as any); } catch {}
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
@@ -2531,6 +2541,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
await SavePSUSettings(psuCfg as any);
await SaveSatSettings(satCfg as any);
await SaveAmplifiers(amps as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
@@ -4365,6 +4376,165 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
);
}
// Satellites: where the antenna is, and the machine that points it.
//
// The observer belongs here rather than in the tab because it is a property
// of the station, and the rotator because it is a second machine on a second
// port — a station with an HF rotator and an az/el pair must be able to have
// both, and choosing between them in one panel would be the wrong question.
function SatellitePanelSettings() {
const ports = wkPorts;
const setPorts = setWkPorts;
const set = (k: string, v: any) => setSatCfg((s: any) => ({ ...s, [k]: v }));
const num = (v: string) => parseInt(v.replace(/[^0-9-]/g, ''), 10) || 0;
return (
<>
<SectionHeader title={t('sec.satellite')} hint={t('satset.hint')} />
<div className="space-y-5 max-w-xl">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.grid')}</Label>
<Input className="font-mono" placeholder={t('satset.gridPlaceholder')}
value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
</div>
<div className="space-y-1">
<Label>{t('satset.altM')}</Label>
<Input className="font-mono" value={String(satCfg.alt_m ?? 0)}
onChange={(e) => set('alt_m', num(e.target.value))} />
</div>
<div className="space-y-1">
<Label>{t('satset.minEl')}</Label>
<Input className="font-mono" value={String(satCfg.min_el ?? 10)}
onChange={(e) => set('min_el', num(e.target.value))} />
</div>
</div>
<p className="text-xs text-muted-foreground">{t('satset.gridHint')}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('satset.windowH')}</Label>
<Input className="font-mono" value={String(satCfg.window_h ?? 24)}
onChange={(e) => set('window_h', num(e.target.value))} />
</div>
<label className="flex items-end gap-2 text-sm cursor-pointer pb-2">
<Checkbox checked={!!satCfg.auto_tle} onCheckedChange={(c) => set('auto_tle', !!c)} />
{t('satset.autoTle')}
</label>
</div>
<div className="border-t border-border/60 pt-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">{t('satset.rotor')}</h4>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!satCfg.rot_on} onCheckedChange={(c) => set('rot_on', !!c)} />
{t('satset.rotEnable')}
</label>
<p className="text-xs text-muted-foreground">{t('satset.rotHint')}</p>
{!!satCfg.rot_on && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.rotLink')}</Label>
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="serial">{t('satset.rotSerial')}</SelectItem>
<SelectItem value="tcp">{t('satset.rotTcp')}</SelectItem>
</SelectContent>
</Select>
</div>
{satCfg.rot_transport === 'tcp' ? (
<>
<div className="space-y-1">
<Label>{t('satset.rotHost')}</Label>
<Input className="font-mono" placeholder="127.0.0.1"
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t('satset.rotPort')}</Label>
<Input className="font-mono" value={String(satCfg.rot_port ?? 4533)}
onChange={(e) => set('rot_port', num(e.target.value))} />
</div>
</>
) : (
<>
<div className="space-y-1">
<Label>{t('satset.rotCom')}</Label>
<div className="flex items-center gap-2">
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
</Button>
</div>
</div>
<div className="space-y-1">
<Label>{t('satset.rotBaud')}</Label>
<Input className="font-mono" value={String(satCfg.rot_baud ?? 9600)}
onChange={(e) => set('rot_baud', num(e.target.value))} />
</div>
</>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.rotRange')}</Label>
<Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="360">360°</SelectItem>
<SelectItem value="450">450°</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('satset.rotMinEl')}</Label>
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
onChange={(e) => set('rot_min_el', num(e.target.value))} />
</div>
<div className="space-y-1">
<Label>{t('satset.rotStep')}</Label>
<Input className="font-mono" value={String(satCfg.rot_step ?? 5)}
onChange={(e) => set('rot_step', num(e.target.value))} />
</div>
</div>
<p className="text-xs text-muted-foreground">{t('satset.rotRangeHint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!satCfg.rot_park} onCheckedChange={(c) => set('rot_park', !!c)} />
{t('satset.rotPark')}
</label>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={async () => {
setSatTest(t('satset.rotTesting'));
try {
// Saved first: the test opens the port from the STORED
// settings, and testing what is on screen rather than what
// is stored is the classic way to prove a COM port that is
// not the one about to be used.
await SaveSatSettings(satCfg as any);
setSatTest(String(await TestSatelliteRotator()));
} catch (e: any) { setSatTest(String(e?.message ?? e)); }
}}>
{t('satset.rotTest')}
</Button>
{satTest && <span className="text-xs text-muted-foreground">{satTest}</span>}
</div>
</>
)}
</div>
</div>
</>
);
}
function PGXLPanelSettings() {
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
// presents it as brand + model.
@@ -8370,6 +8540,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
antgenius: AntGeniusPanelSettings,
tunergenius: TunerGeniusPanelSettings,
psu: PSUPanelSettings,
satellite: SatellitePanelSettings,
pgxl: PGXLPanelSettings,
flex: () => (
<div className="space-y-6">
+32
View File
@@ -587,6 +587,22 @@ const en: Dict = {
'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',
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'your stations',
'satset.gridHint': 'Leave the locator empty to use your stations own. Altitude is the antenna above sea level — it changes the horizon, and so the start and end of a low pass.',
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Lowest pass (°)', 'satset.windowH': 'Predict ahead (hours)',
'satset.autoTle': 'Fetch fresh elements at startup when they are more than three days old',
'satset.rotor': 'Azimuth / elevation rotator',
'satset.rotEnable': 'Point a rotator at the satellite while tracking',
'satset.rotHint': 'EasyComm II — what SatPC32, Gpredict and Hamlib speak, so any controller that works with those works here. This is a separate machine from your HF rotator: having both is normal.',
'satset.rotLink': 'Connection', 'satset.rotSerial': 'Serial (COM)', 'satset.rotTcp': 'Network (TCP)',
'satset.rotHost': 'Address', 'satset.rotPort': 'Port', 'satset.rotCom': 'COM port', 'satset.rotBaud': 'Baud',
'satset.rotRange': 'Rotator range', 'satset.rotMinEl': 'Start above (°)', 'satset.rotStep': 'Move by (°)',
'satset.rotRangeHint': 'A 450° rotator follows a pass straight through north instead of unwinding, so it is used that way when it can be. “Start above” leaves the mast alone until the satellite is worth pointing at; “move by” is the smallest change worth a command — keep it inside your beamwidth.',
'satset.rotPark': 'Park at north, elevation zero, when tracking stops',
'satset.rotTest': 'Test the rotator', 'satset.rotTesting': 'asking the controller…',
};
const fr: Dict = {
@@ -1137,6 +1153,22 @@ const fr: Dict = {
'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',
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'celui de la station',
'satset.gridHint': 'Laissez le locator vide pour utiliser celui de votre station. Laltitude est celle de lantenne au-dessus du niveau de la mer — elle change lhorizon, donc le début et la fin dun passage rasant.',
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Passage minimal (°)', 'satset.windowH': 'Prévoir sur (heures)',
'satset.autoTle': 'Récupérer des éléments frais au démarrage quand ils ont plus de trois jours',
'satset.rotor': 'Rotor azimut / élévation',
'satset.rotEnable': 'Pointer un rotor vers le satellite pendant le suivi',
'satset.rotHint': 'EasyComm II — le langage de SatPC32, Gpredict et Hamlib : tout contrôleur qui fonctionne avec eux fonctionne ici. Cest une machine distincte de votre rotor HF : avoir les deux est normal.',
'satset.rotLink': 'Connexion', 'satset.rotSerial': 'Série (COM)', 'satset.rotTcp': 'Réseau (TCP)',
'satset.rotHost': 'Adresse', 'satset.rotPort': 'Port', 'satset.rotCom': 'Port COM', 'satset.rotBaud': 'Bauds',
'satset.rotRange': 'Course du rotor', 'satset.rotMinEl': 'Démarrer au-dessus de (°)', 'satset.rotStep': 'Déplacer par (°)',
'satset.rotRangeHint': 'Un rotor 450° suit un passage à travers le nord sans se dérouler ; il est donc utilisé ainsi quand il le peut. « Démarrer au-dessus de » laisse le pylône tranquille tant que le satellite ne mérite pas d’être pointé ; « déplacer par » est le plus petit écart qui vaut une commande — gardez-le à lintérieur de votre ouverture de faisceau.',
'satset.rotPark': 'Ranger au nord, élévation zéro, à larrêt du suivi',
'satset.rotTest': 'Tester le rotor', 'satset.rotTesting': 'interrogation du contrôleur…',
};
const dicts: Record<Lang, Dict> = { en, fr };
+2
View File
@@ -1429,6 +1429,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
export function TestSatelliteRotator():Promise<string>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
+4
View File
@@ -2790,6 +2790,10 @@ export function TestRotatorDevice(arg1, arg2) {
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
}
export function TestSatelliteRotator() {
return window['go']['main']['App']['TestSatelliteRotator']();
}
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
+28
View File
@@ -4082,6 +4082,16 @@ export namespace main {
auto_tle: boolean;
grid: string;
alt_m: number;
rot_on: boolean;
rot_transport: string;
rot_host: string;
rot_port: number;
rot_com: string;
rot_baud: number;
rot_max_az: number;
rot_min_el: number;
rot_step: number;
rot_park: boolean;
static createFrom(source: any = {}) {
return new SatSettings(source);
@@ -4095,6 +4105,16 @@ export namespace main {
this.auto_tle = source["auto_tle"];
this.grid = source["grid"];
this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"];
this.rot_transport = source["rot_transport"];
this.rot_host = source["rot_host"];
this.rot_port = source["rot_port"];
this.rot_com = source["rot_com"];
this.rot_baud = source["rot_baud"];
this.rot_max_az = source["rot_max_az"];
this.rot_min_el = source["rot_min_el"];
this.rot_step = source["rot_step"];
this.rot_park = source["rot_park"];
}
}
export class SatTLEInfo {
@@ -4150,6 +4170,10 @@ export namespace main {
visible: boolean;
radio: string;
error: string;
rot_on: boolean;
rot_az: number;
rot_el: number;
rot_live: boolean;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
@@ -4170,6 +4194,10 @@ export namespace main {
this.visible = source["visible"];
this.radio = source["radio"];
this.error = source["error"];
this.rot_on = source["rot_on"];
this.rot_az = source["rot_az"];
this.rot_el = source["rot_el"];
this.rot_live = source["rot_live"];
}
}