feat(antenna): three tracking modes for Ultrabeam and SteppIR

The follow loop only ever had one behaviour — re-tune once the rig moved
further than a fixed step. The SteppIR's own controller software offers three,
and operators arrive with that mental model: every frequency change, past a
threshold, or only on a band change. They are real operating trade-offs, not
preferences: "always" keeps resonance perfect at the cost of motors running
constantly (and on a SteppIR every move inhibits transmit while the elements
travel), "band" moves them a handful of times a day.

"Always" is implemented as the threshold mode with a 1 kHz threshold rather
than as a separate branch, so all modes keep the one deadband reference that
matters: the rig frequency last commanded for, NOT the antenna's own reported
frequency — a SteppIR flips its reported frequency between the commanded value
and its home value, which would re-issue a SET on nearly every poll and leave
the operator permanently unable to transmit.

Band mode compares against the band last COMMANDED for, not the rig's previous
band, so the first move after startup and any move made by hand still get
reconciled. It applies to the immediate spot-click path too: a click inside the
band the antenna is already resonant in moves nothing.

An unset or unrecognised mode resolves to the step mode, so every config
written before this option behaves exactly as it did.

Also routes the antenna settings block through t() — it was hardcoded English.
This commit is contained in:
2026-08-12 08:31:44 +02:00
parent 99d903eb44
commit 65bbaa85f3
9 changed files with 236 additions and 60 deletions
+108 -22
View File
@@ -233,6 +233,7 @@ const (
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
@@ -12365,13 +12366,25 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := khz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
switch normMotorTrackMode(s.TrackMode) {
case motorTrackAlways:
// Every frequency change means every frequency change, including this one.
case motorTrackBand:
// The antenna is already resonant somewhere in this band — that is all the
// operator asked for in band mode, so a spot click inside it moves nothing.
if ref > 0 && bandForHz(int64(ref)*1000) == bandForHz(freqHz) {
applog.Printf("ultrabeam: followNow stays in band %q (antenna at %d kHz) — no move", bandForHz(freqHz), ref)
return
}
default:
diff := khz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
}
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(khz, st.Direction); err != nil {
@@ -14653,6 +14666,17 @@ type UltrabeamSettings struct {
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
// When the follow loop is allowed to move the motors. The three choices the
// SteppIR's own controller software offers, because operators arrive with
// that mental model:
// "always" — every frequency change. Resonance is always right, at the cost
// of motors running constantly; on a SteppIR every move also
// inhibits transmit while the elements travel.
// "step" — only past a threshold (StepKHz). The default, and the sane
// middle: the antenna follows a QSY but ignores tuning around.
// "band" — only when the band changes. Motors move a handful of times a
// day; resonance is whatever the band-entry frequency gave.
TrackMode string `json:"track_mode"`
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
// Bands the antenna covers — the follow filter. The follow loop only re-tunes
// (and only lets TX-inhibit trigger) on a band in this set; on any other band
@@ -14667,16 +14691,39 @@ type UltrabeamSettings struct {
FreqMaxMHz int `json:"freq_max_mhz"`
}
// Tracking modes. Stored as strings rather than an int so a settings row stays
// readable when diagnosing an antenna that moves too much or not at all.
const (
motorTrackAlways = "always"
motorTrackStep = "step"
motorTrackBand = "band"
)
// normMotorTrackMode keeps an unknown or empty value on the threshold mode
// instead of guessing — a config written before this option existed then
// behaves exactly as it did.
func normMotorTrackMode(m string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case motorTrackAlways:
return motorTrackAlways
case motorTrackBand:
return motorTrackBand
default:
return motorTrackStep
}
}
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
// unchanged.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50}
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50, TrackMode: motorTrackStep}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands)
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands,
keyMotorTrackMode)
if err != nil {
return out, err
}
@@ -14700,6 +14747,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
@@ -14765,6 +14813,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyUltrabeamPort: strconv.Itoa(s.Port),
keyUltrabeamFollow: boolStr(s.Follow),
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
keyMotorTrackMode: normMotorTrackMode(s.TrackMode),
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
@@ -15004,10 +15053,21 @@ func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan str
}
}
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, stop <-chan struct{}) {
func (a *App) ultrabeamFollowLoop(c motorAntenna, mode string, stepKHz int, bands []string, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
mode = normMotorTrackMode(mode)
// "Every time the frequency changes" is the threshold mode with the smallest
// threshold there is: the loop already re-tunes when the rig has moved at
// least stepKHz from the last commanded frequency, and 1 kHz makes that
// "moved at all". Expressing it this way keeps ONE decision path, so the
// deadband reference — which is the rig, not the antenna's own flaky reported
// frequency — cannot drift out of step between modes.
if mode == motorTrackAlways {
stepKHz = 1
}
lastCmdBand := "" // band of the last commanded move — the reference in band mode
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
lastRigKHz := 0 // only log when the followed rig frequency actually changes
@@ -15065,19 +15125,35 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, s
ref = c.LastSetKHz()
}
}
diff := rigKHz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
// Band mode ignores the threshold entirely: the antenna is re-tuned once
// on entering a band and then left alone however far the rig roams
// inside it. The reference is the band we last COMMANDED for, not the
// rig's previous band — otherwise a first move after startup, or any
// move the operator made by hand, would never be reconciled.
if mode == motorTrackBand {
b := bandForHz(rs.FreqHz)
if b == lastCmdBand {
continue
}
if newFreq {
applog.Printf("ultrabeam: band changed %q → %q — re-tuning to %d kHz", lastCmdBand, b, rigKHz)
}
} else {
diff := rigKHz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
}
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else {
lastCmdKHz = rigKHz
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
lastCmdBand = bandForHz(rs.FreqHz)
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, mode %s, step %d)", rigKHz, st.Direction, ref, mode, stepKHz)
}
}
}
@@ -15097,8 +15173,9 @@ type UltrabeamStatusInfo struct {
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
// Follow and StepKHz are mirrored here so the Station Control widget can show
// and change tracking without loading the whole settings block for a poll.
Follow bool `json:"follow"`
StepKHz int `json:"step_khz"`
Follow bool `json:"follow"`
StepKHz int `json:"step_khz"`
TrackMode string `json:"track_mode"`
// Bands the antenna is configured to cover — the widget offers exactly these
// as buttons rather than inventing its own list, so a band dropped in Settings
// cannot be clicked here.
@@ -15113,6 +15190,7 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Type = s.Type
out.Follow = s.Follow
out.StepKHz = s.StepKHz
out.TrackMode = normMotorTrackMode(s.TrackMode)
out.Bands = append(out.Bands, s.Bands...)
if a.motorAnt == nil {
return out
@@ -15218,7 +15296,7 @@ func (a *App) MotorNudgeKHz(deltaKHz int) error {
// opening Settings. Both are ordinary operating decisions — an operator turns
// tracking off to park the antenna and back on to resume — and a preferences
// dialog is the wrong place for something changed that often.
func (a *App) SetMotorFollow(on bool, stepKHz int) error {
func (a *App) SetMotorFollow(on bool, stepKHz int, mode string) error {
s, err := a.GetUltrabeamSettings()
if err != nil {
return err
@@ -15230,6 +15308,11 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
default:
return fmt.Errorf("step must be 25, 50 or 100 kHz")
}
// An empty mode leaves it alone, so the caller toggling tracking on and off
// does not have to know or resend it.
if strings.TrimSpace(mode) != "" {
s.TrackMode = normMotorTrackMode(mode)
}
s.Follow = on
// Persist WITHOUT the restart. SaveUltrabeamSettings tears the client down and
@@ -15251,6 +15334,9 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
if err := a.settings.Set(a.ctx, keyUltrabeamStep, strconv.Itoa(s.StepKHz)); err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyMotorTrackMode, normMotorTrackMode(s.TrackMode)); err != nil {
return err
}
a.restartMotorFollow(s)
return nil
}
@@ -15268,8 +15354,8 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
}
stop := make(chan struct{})
a.ubFollowStop = stop
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, step %d kHz", s.Bands, s.StepKHz)
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, s.Bands, stop)
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, mode %s, step %d kHz", s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
}
// UltrabeamRetract retracts all elements (storage / safe position).
+4 -2
View File
@@ -6,13 +6,15 @@
"QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones. The UDP path stamped the station profile, the DXCC and the QSL defaults but not SFI, A, K or distance — so an operator running digital had those fields empty across the whole log. Space weather is only stamped on a contact less than a day old: a logger re-broadcasting its backlog would otherwise be handed this morning readings for last month contacts.",
"Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world.",
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency. A frequency set while transmitting was not recorded, so WSJT-X was told the radio was already back on the receive frequency and never restored it.",
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link."
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link.",
"Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had."
],
"fr": [
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.",
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde.",
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission. Un changement de fréquence pendant l émission n était pas enregistré, WSJT-X croyait donc la radio déjà revenue sur la fréquence de réception et ne la remettait jamais en place.",
"PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison."
"PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison.",
"Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà."
]
},
{
+35 -14
View File
@@ -1250,8 +1250,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -3140,20 +3140,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
Follow rig frequency (auto-tune the antenna)
{t('hw.motorFollow')}
</label>
{ultrabeam.follow && (
<div className="flex items-center gap-3 pl-6">
<Label className="text-sm">Re-tune step</Label>
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="25">25 kHz</SelectItem>
<SelectItem value="50">50 kHz</SelectItem>
<SelectItem value="100">100 kHz</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
<div className="space-y-2 pl-6">
<div className="flex items-center gap-3">
<Label className="text-sm">{t('station.trackModeTip')}</Label>
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* The step is only a question in step mode the other two modes
have nothing to threshold. */}
{(ultrabeam.track_mode || 'step') === 'step' && (
<div className="flex items-center gap-3">
<Label className="text-sm">{t('hw.motorStep')}</Label>
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="25">25 kHz</SelectItem>
<SelectItem value="50">50 kHz</SelectItem>
<SelectItem value="100">100 kHz</SelectItem>
</SelectContent>
</Select>
</div>
)}
<p className="text-xs text-muted-foreground">
{(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
: (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
: t('station.trackStepTipMode')}
</p>
</div>
)}
</div>
+30 -15
View File
@@ -116,7 +116,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
);
}
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] };
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[] };
// Where each band button points the antenna.
//
@@ -277,23 +277,38 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
{/* Tracking. Here rather than only in Settings because it is an operating
decision — off to park the antenna, on to resume — not something set
up once. The step only shows when tracking is on: a threshold for
something switched off is a question the operator cannot act on. */}
<div className="flex items-center gap-2">
<button type="button"
onClick={() => run(SetMotorFollow(!ant.follow, 0))}
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
</button>
up once. Mode and step only show when tracking is on: settings for
something switched off are questions the operator cannot act on. And
the step only shows in step mode, where it is the one thing it means. */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<button type="button"
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
</button>
{ant.follow && (ant.track_mode || 'step') === 'step' && (
<select
value={String(ant.step_khz || 50)}
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
title={t('station.trackStepTip')}
>
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
</select>
)}
</div>
{ant.follow && (
<select
value={String(ant.step_khz || 50)}
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10)))}
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
title={t('station.trackStepTip')}
value={ant.track_mode || 'step'}
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
title={t('station.trackModeTip')}
>
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
</select>
)}
</div>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -982,7 +982,7 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
+2 -2
View File
@@ -1906,8 +1906,8 @@ export function SetKenwoodKeySpeed(arg1) {
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
}
export function SetMotorFollow(arg1, arg2) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2);
export function SetMotorFollow(arg1, arg2, arg3) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
}
export function SetOpsLogQSLReceived(arg1, arg2) {
+4
View File
@@ -3283,6 +3283,7 @@ export namespace main {
baud: number;
follow: boolean;
step_khz: number;
track_mode: string;
tx_inhibit: boolean;
bands: string[];
freq_min_mhz: number;
@@ -3303,6 +3304,7 @@ export namespace main {
this.baud = source["baud"];
this.follow = source["follow"];
this.step_khz = source["step_khz"];
this.track_mode = source["track_mode"];
this.tx_inhibit = source["tx_inhibit"];
this.bands = source["bands"];
this.freq_min_mhz = source["freq_min_mhz"];
@@ -3320,6 +3322,7 @@ export namespace main {
elements: number[];
follow: boolean;
step_khz: number;
track_mode: string;
bands: string[];
static createFrom(source: any = {}) {
@@ -3338,6 +3341,7 @@ export namespace main {
this.elements = source["elements"];
this.follow = source["follow"];
this.step_khz = source["step_khz"];
this.track_mode = source["track_mode"];
this.bands = source["bands"];
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import "testing"
// The tracking mode decides how often a motorized antenna's elements run, so a
// value that fails to parse must not silently become the most aggressive
// setting. Anything unrecognised — including the empty string every config
// written before this option existed contains — has to land on the threshold
// mode, which is exactly what those configs already did.
func TestNormMotorTrackMode(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"always", motorTrackAlways},
{"ALWAYS", motorTrackAlways},
{" band ", motorTrackBand},
{"step", motorTrackStep},
{"", motorTrackStep}, // never configured — behaves as before
{"everytime", motorTrackStep}, // near-miss, not "always"
{"per-band", motorTrackStep}, // near-miss, not "band"
{"25", motorTrackStep}, // a step value fed in by mistake
} {
if got := normMotorTrackMode(tc.in); got != tc.want {
t.Errorf("normMotorTrackMode(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// Band mode compares the band the rig is on against the band the antenna was
// last commanded for. That comparison is bandForHz, and the property it has to
// have is that two frequencies far apart within one band agree while two
// frequencies close together across a band edge do not — otherwise the antenna
// either never moves or moves on every QSY.
func TestBandForHzDrivesBandTracking(t *testing.T) {
same := [][2]int64{
{14000000, 14350000}, // both ends of 20 m — one band, no move
{7000000, 7200000}, // 40 m
{50000000, 52000000}, // 6 m, a wide one
}
for _, p := range same {
if a, b := bandForHz(p[0]), bandForHz(p[1]); a != b || a == "" {
t.Errorf("%.3f MHz is %q but %.3f MHz is %q — band mode would re-tune inside one band",
float64(p[0])/1e6, a, float64(p[1])/1e6, b)
}
}
// A small QSY that crosses from 30 m into 20 m has to read as a band change.
if a, b := bandForHz(10150000), bandForHz(14000000); a == b {
t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a)
}
}