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
+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">