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:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 station’s',
|
||||
'satset.gridHint': 'Leave the locator empty to use your station’s 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 n’a qu’un 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 l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet Satellites.',
|
||||
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'celui de la station',
|
||||
'satset.gridHint': 'Laissez le locator vide pour utiliser celui de votre station. L’altitude est celle de l’antenne au-dessus du niveau de la mer — elle change l’horizon, donc le début et la fin d’un 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. C’est 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 à l’intérieur de votre ouverture de faisceau.',
|
||||
'satset.rotPark': 'Ranger au nord, élévation zéro, à l’arrêt du suivi',
|
||||
'satset.rotTest': 'Tester le rotor', 'satset.rotTesting': 'interrogation du contrôleur…',
|
||||
};
|
||||
|
||||
const dicts: Record<Lang, Dict> = { en, fr };
|
||||
|
||||
Reference in New Issue
Block a user