feat(sat): follow the azimuth only
A satellite tracker that insists on an elevation motor is a tracker switched off for nearly everybody. A pass at the edge of the footprint — which is most of them — never climbs above ten or fifteen degrees for its whole length, and a yagi's beamwidth swallows that: the bearing alone is enough, and it is how most stations that work satellites are actually built. The same switch rescues an az/el station whose elevation motor has failed. So it is an option, not a silent fallback, because it does cost something: a bird straight overhead is a moving azimuth and a bearing that means nothing, and whether to accept that is the operator's call. With it on, any rotor in the list can be chosen — the PstRotator, the Rotator Genius, the ARCO, the tower already turned for HF. That works because the per-backend command dispatch moved out of the three RotatorGoTo/Stop/Heading methods into linkGoTo/linkStop/linkHeading, so the satellite tracker drives any of the seven backends through the same code the compass uses instead of a second implementation of each. GetRotatorHeading loses sixty lines of near-duplicate switch in the process, and a rotor with no elevation axis now says so (HasElevation) rather than reporting a zero that looks like a real bearing. One trap, with a test on it: the step check compared both axes, so with the elevation never commanded its difference stayed above the step for the whole pass and every tick ordered the antenna to the bearing it was already on. A mast has a finite number of turns in it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -60,7 +60,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;
|
||||
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
|
||||
};
|
||||
|
||||
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||
@@ -811,7 +811,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
{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>
|
||||
{/* No elevation when none is being driven: an undriven zero
|
||||
draws an antenna lying on the horizon, which is a bearing
|
||||
and not the absence of one. */}
|
||||
<span className="font-medium">
|
||||
{tracking.rot_az_only
|
||||
? fmtDeg(tracking.rot_az)
|
||||
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
||||
</span>
|
||||
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
||||
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1931,7 +1931,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_id: '', 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_id: '', rot_az_only: false, 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),
|
||||
@@ -4701,13 +4701,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('satset.rotPickNone')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{satRotors.length === 0 && <SelectItem value="_" disabled>{t('satset.rotNoneConfigured')}</SelectItem>}
|
||||
{/* The azimuth-only rotors are LISTED, and disabled. An
|
||||
{/* The azimuth-only rotors are always LISTED. Without
|
||||
the switch below they are greyed and say why — an
|
||||
operator who owns one rotator and does not see it
|
||||
concludes OpsLog cannot find it; shown greyed with
|
||||
"azimuth only" beside it, they learn the real thing. */}
|
||||
concludes OpsLog cannot find it, where "azimuth
|
||||
only" beside it teaches the real thing. With the
|
||||
switch on, every rotor is fair game. */}
|
||||
{satRotors.map((r: any) => (
|
||||
<SelectItem key={r.key} value={r.key} disabled={!r.has_el}>
|
||||
{(r.name || r.type) + (r.has_el ? '' : ` — ${t('satset.rotAzOnly')}`)}
|
||||
<SelectItem key={r.key} value={r.key} disabled={!r.has_el && !satCfg.rot_az_only}>
|
||||
{(r.name || r.type) + (r.has_el ? '' : ` — ${t('satset.rotAzOnlyTag')}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -4718,11 +4720,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('satset.rotPickHint')}</p>
|
||||
{satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
|
||||
{!satCfg.rot_az_only && satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
|
||||
<p className="text-xs text-[var(--warning)]">{t('satset.rotNoElAtAll')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Azimuth only. Not a fallback — it is how most stations that
|
||||
work satellites are actually built, and refusing to track
|
||||
without an elevation motor turned the feature off for every
|
||||
operator with a tower and no az/el mast. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!satCfg.rot_az_only}
|
||||
onCheckedChange={(c) => set('rot_az_only', !!c)} />
|
||||
<span>
|
||||
{t('satset.rotAzOnly')}
|
||||
<span className="block text-xs text-muted-foreground">{t('satset.rotAzOnlyHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('satset.rotMinEl')}</Label>
|
||||
|
||||
@@ -605,6 +605,7 @@ const en: Dict = {
|
||||
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
|
||||
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
||||
'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
|
||||
'sat.rotAzOnly': 'azimuth only',
|
||||
'sat.tipAos': 'Rises', 'sat.tipLos': 'Sets', 'sat.tipMaxEl': 'Peak',
|
||||
'sat.tipBelow': 'below the horizon', 'sat.tipNoPass': 'no pass in the prediction window',
|
||||
'sat.approaching': 'approaching', 'sat.receding': 'receding', 'sat.below': 'below the horizon',
|
||||
@@ -628,7 +629,9 @@ const en: Dict = {
|
||||
'satset.rotor': 'Azimuth / elevation rotator',
|
||||
'satset.rotEnable': 'Point a rotator at the satellite while tracking',
|
||||
'satset.rotHint': 'Point the antenna at the satellite through one of the rotators you have already configured. It is usually a separate machine from your HF rotator, and having both is normal — an az/el mast follows the pass while the beam stays where it was.',
|
||||
'satset.rotPick': 'Rotator', 'satset.rotPickNone': '— choose a rotator —', 'satset.rotAzOnly': 'azimuth only',
|
||||
'satset.rotPick': 'Rotator', 'satset.rotPickNone': '— choose a rotator —',
|
||||
'satset.rotAzOnly': 'Follow the azimuth only', 'satset.rotAzOnlyTag': 'azimuth only',
|
||||
'satset.rotAzOnlyHint': 'For a station with an ordinary azimuth rotator and no elevation motor. A pass at the edge of the footprint stays between the horizon and about 15° for its whole length, and a beam’s beamwidth covers that — so the bearing alone works. What you give up is the high passes, where a satellite overhead has a bearing that means nothing. With this on, any rotator can be chosen above.',
|
||||
'satset.rotNoneConfigured': 'No rotator configured yet',
|
||||
'satset.rotPickHint': 'One of the rotators from Settings ▸ Rotator. Add or change an interface there — a mast is described once, and this page only says which one follows the satellite.',
|
||||
'satset.rotNoElAtAll': 'None of your rotators has an elevation axis. Add an az/el interface in Settings ▸ Rotator — ERC-M, EasyComm, SPID Rot2Prog, or PstRotator with elevation ticked.',
|
||||
@@ -1220,6 +1223,7 @@ const fr: Dict = {
|
||||
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
|
||||
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
||||
'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
|
||||
'sat.rotAzOnly': 'azimut seul',
|
||||
'sat.tipAos': 'Lever', 'sat.tipLos': 'Coucher', 'sat.tipMaxEl': 'Culmination',
|
||||
'sat.tipBelow': 'sous l’horizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision',
|
||||
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous l’horizon',
|
||||
@@ -1243,7 +1247,9 @@ const fr: Dict = {
|
||||
'satset.rotor': 'Rotor azimut / élévation',
|
||||
'satset.rotEnable': 'Pointer un rotor vers le satellite pendant le suivi',
|
||||
'satset.rotHint': 'Pointe l’antenne vers le satellite avec l’un des rotors déjà configurés. C’est en général une machine distincte du rotor HF, et avoir les deux est normal — le pylône azimut/élévation suit le passage pendant que la beam reste où elle était.',
|
||||
'satset.rotPick': 'Rotor', 'satset.rotPickNone': '— choisir un rotor —', 'satset.rotAzOnly': 'azimut seul',
|
||||
'satset.rotPick': 'Rotor', 'satset.rotPickNone': '— choisir un rotor —',
|
||||
'satset.rotAzOnly': 'Suivre l’azimut seulement', 'satset.rotAzOnlyTag': 'azimut seul',
|
||||
'satset.rotAzOnlyHint': 'Pour une station avec un rotor d’azimut ordinaire et sans moteur d’élévation. Un passage en bord d’empreinte reste entre l’horizon et 15° environ sur toute sa durée, et l’ouverture d’une beam couvre ça — le cap seul suffit donc. Ce qu’on perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire. Avec cette option, n’importe quel rotor peut être choisi ci-dessus.',
|
||||
'satset.rotNoneConfigured': 'Aucun rotor configuré pour le moment',
|
||||
'satset.rotPickHint': 'Un des rotors de Réglages ▸ Rotator. Ajoutez ou modifiez une interface là-bas — un pylône se décrit une seule fois, et cette page dit seulement lequel suit le satellite.',
|
||||
'satset.rotNoElAtAll': 'Aucun de vos rotors n’a d’axe d’élévation. Ajoutez une interface azimut/élévation dans Réglages ▸ Rotator — ERC-M, EasyComm, SPID Rot2Prog, ou PstRotator avec l’élévation cochée.',
|
||||
|
||||
@@ -4171,6 +4171,7 @@ export namespace main {
|
||||
alt_m: number;
|
||||
rot_on: boolean;
|
||||
rot_id: string;
|
||||
rot_az_only: boolean;
|
||||
rot_min_el: number;
|
||||
rot_step: number;
|
||||
rot_park: boolean;
|
||||
@@ -4189,6 +4190,7 @@ export namespace main {
|
||||
this.alt_m = source["alt_m"];
|
||||
this.rot_on = source["rot_on"];
|
||||
this.rot_id = source["rot_id"];
|
||||
this.rot_az_only = source["rot_az_only"];
|
||||
this.rot_min_el = source["rot_min_el"];
|
||||
this.rot_step = source["rot_step"];
|
||||
this.rot_park = source["rot_park"];
|
||||
@@ -4286,6 +4288,7 @@ export namespace main {
|
||||
rot_az: number;
|
||||
rot_el: number;
|
||||
rot_live: boolean;
|
||||
rot_az_only: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SatTrackStatus(source);
|
||||
@@ -4310,6 +4313,7 @@ export namespace main {
|
||||
this.rot_az = source["rot_az"];
|
||||
this.rot_el = source["rot_el"];
|
||||
this.rot_live = source["rot_live"];
|
||||
this.rot_az_only = source["rot_az_only"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user