feat(sat): PstRotator can point the antenna too

It handles azimuth and elevation, and a great many stations already run
it in front of a controller OpsLog has never heard of. For those,
OpsLog talking to the controller itself would be a second program
fighting PstRotator over the same cable — so it hands over the bearing
instead, and lets PstRotator turn the mast.

Both kinds sit behind one small interface, chosen in Settings. Neither is
more correct than the other: the right one is whichever the station
already has working.

The 450° overlap is deliberately NOT applied on the PstRotator path.
PstRotator knows which machine is on the other end and does its own; two
programs each deciding to go the long way round is exactly how an antenna
unwinds in the middle of a pass.

Position queries are asked at most every three seconds rather than on
every tick. A PstRotator query binds a socket and waits up to a second
and a half, and many setups answer nothing at all — so one silence is
enough and it stops asking, reporting the commanded position instead and
saying that is what it is.
This commit is contained in:
2026-09-07 17:11:23 +02:00
parent 9dfa6f7d39
commit 2283734210
8 changed files with 327 additions and 41 deletions
+48 -11
View File
@@ -1848,7 +1848,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// 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 [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_type: 'easycomm', rot_pst_port: 12000, 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),
@@ -4567,7 +4567,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{!!satCfg.rot_on && (
<>
{/* Who drives the mast. Not a detail: a station already running
PstRotator must NOT have OpsLog on the same cable as well. */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.rotType')}</Label>
<Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easycomm">{t('satset.rotEasycomm')}</SelectItem>
<SelectItem value="pstrotator">{t('satset.rotPst')}</SelectItem>
</SelectContent>
</Select>
</div>
{satCfg.rot_type === 'pstrotator' && (
<>
<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.rotPstPort')}</Label>
<Input className="font-mono" value={String(satCfg.rot_pst_port ?? 12000)}
onChange={(e) => set('rot_pst_port', num(e.target.value))} />
</div>
</>
)}
</div>
{satCfg.rot_type === 'pstrotator' && (
<p className="text-xs text-muted-foreground">{t('satset.rotPstHint')}</p>
)}
<div className={cn('grid grid-cols-3 gap-3', satCfg.rot_type === 'pstrotator' && 'hidden')}>
<div className="space-y-1">
<Label>{t('satset.rotLink')}</Label>
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
@@ -4618,16 +4649,22 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</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>
{/* The rotator's range is ours to know only when we drive the
controller. PstRotator knows which machine is on the other
end and does its own overlap; two programs each deciding to
go the long way round is how an antenna unwinds mid-pass. */}
{satCfg.rot_type !== 'pstrotator' && (
<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)}
+8 -2
View File
@@ -603,7 +603,10 @@ const en: Dict = {
'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.rotHint': 'Either OpsLog drives the controller itself over EasyComm II — what SatPC32, Gpredict and Hamlib speak — or it hands the bearing to PstRotator, which many stations already run. Use PstRotator if you have it: two programs on one cable is one too many. Whichever it is, this is a separate machine from your HF rotator, and having both is normal.',
'satset.rotType': 'Driven by', 'satset.rotEasycomm': 'OpsLog (EasyComm II)', 'satset.rotPst': 'PstRotator',
'satset.rotPstPort': 'UDP port',
'satset.rotPstHint': 'PstRotator handles azimuth and elevation and already knows your controller, so OpsLog sends it the bearing and lets it turn the mast. Its UDP port is in PstRotator under Setup ▸ TCP/UDP Server — the default is 12000. Nothing else here applies: the rotators range and its overlap are PstRotators business.',
'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 (°)',
@@ -1187,7 +1190,10 @@ const fr: Dict = {
'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.rotHint': 'Soit OpsLog pilote le contrôleur lui-même en EasyComm II — le langage de SatPC32, Gpredict et Hamlib — soit il transmet le cap à PstRotator, que beaucoup de stations font déjà tourner. Utilisez PstRotator si vous lavez : deux programmes sur un même câble, cest un de trop. Dans les deux cas, cest une machine distincte du rotor HF, et avoir les deux est normal.',
'satset.rotType': 'Piloté par', 'satset.rotEasycomm': 'OpsLog (EasyComm II)', 'satset.rotPst': 'PstRotator',
'satset.rotPstPort': 'Port UDP',
'satset.rotPstHint': 'PstRotator gère azimut et élévation et connaît déjà votre contrôleur : OpsLog lui envoie le cap et le laisse tourner le pylône. Son port UDP se trouve dans PstRotator sous Setup ▸ TCP/UDP Server — 12000 par défaut. Rien dautre ici ne sapplique : la course du rotor et son recouvrement sont laffaire de PstRotator.',
'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 (°)',