Files
OpsLog/app_sat_track.go
T
rouggyandClaude Opus 5 b8491f3038 fix(sat): the antenna stops flickering, and the pass is readable from the header
THE FLICKER was a real bug and not a rotator problem. The rotator is asked where
it is at most every three seconds — a controller query binds a socket and waits —
but satTrackStep built a fresh status every second and carried over only Radio
and Error. So the antenna readout was filled in on one tick in three and blank
on the other two, which on screen is a rotator that keeps disconnecting. The
status is now rebuilt FROM the previous one, so a field nobody wrote this tick
keeps the value somebody wrote last tick.

THE HEADER now carries the two frequencies and the antenna bearing, beside the
button that started tracking. During a pass an operator watches the radio and
the antenna, not a column on the far side of the window — and that column is the
first thing they hide to get the map full width, which until now took the
numbers with it. The compass spins while the antenna is still on its way: a mast
takes tens of seconds to cross a pass, and "moving" against "stuck" is the whole
reason to look at it, which a number alone cannot show.

THE PRECISION drops from one hertz to a hundred. The Doppler moves about sixty
hertz a second on 70 cm, so the last two digits changed on every tick and the
display was a blur that could not be read and did not need to be. The radio
still gets the whole figure — the correction is computed and sent to the hertz —
this is only how much of it is worth putting in front of somebody. The shift
beside it says "+9.7 kHz" rather than "+9741 Hz", which is how it is read aloud.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:30:57 +02:00

777 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// Doppler tracking — walking the radio through a pass.
//
// The hard part of satellite tuning is not the arithmetic, it is deciding who
// owns the dial. A tracker that simply forces both frequencies fights the
// operator every time they turn the knob to follow a station across a linear
// transponder, and one that never touches the receiver leaves them chasing a
// signal that slides 9 kHz across a 70 cm pass.
//
// So: the operator owns the receiver, and the tracker follows them. Every tick
// it asks the radio where the receiver actually is. If that is where the tracker
// put it, nothing has changed and it keeps correcting from the same NOMINAL
// frequency. If it has moved further than a dial-turn's tolerance, the operator
// has chosen a new station: the tracker converts what they landed on back into a
// nominal frequency and carries on from there. The transmitter is derived from
// the nominal and never argued with — which is exactly the division of labour on
// a linear bird, where the operator listens and the radio does the sums.
import (
"fmt"
"math"
"strings"
"sync"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/cat"
"hamlog/internal/qso"
"hamlog/internal/sat"
)
// satTickEvery is how often the radio is re-pointed. One second: at the middle
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
// SSB within two or three of them and inaudible within one.
const satTickEvery = time.Second
// satDialTolerance is how far the receiver may differ from where the tracker put
// it before that difference is read as the operator tuning.
//
// 200 Hz is comfortably more than the rounding and the round-trip lag between
// setting a frequency and reading it back, and comfortably less than the
// smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
// into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458
type satTracker struct {
name string
tp int
mu sync.Mutex
// nominalDown is where the operator is, expressed as if the satellite were
// standing still. Everything else is derived from it, and it is the only
// thing a dial movement changes.
nominalDown int64
lastDown int64 // what was last sent to the radio
lastUp int64
status SatTrackStatus
fails int
// The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured.
rot satRotator
rotStep float64
rotMinE float64
rotPark bool
rotAzOnly bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{}
done chan struct{}
// wake makes the loop take a step NOW instead of at the next tick. Changing
// satellite has to move the radio at once: a second of the old bird's
// frequencies is a second of the wrong pass.
wake chan struct{}
}
// SatTrackStatus is what the tracker is doing, for the panel.
type SatTrackStatus struct {
On bool `json:"on"`
Name string `json:"name"`
Transponder string `json:"transponder"`
Mode string `json:"mode"`
NominalDown int64 `json:"nominal_down"`
NominalUp int64 `json:"nominal_up"`
DownHz int64 `json:"down_hz"`
UpHz int64 `json:"up_hz"`
Az float64 `json:"az"`
El float64 `json:"el"`
Visible bool `json:"visible"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// Where the antenna is. RotLive distinguishes a reading from the controller
// from the last position it was TOLD to go to — a stuck rotator must not be
// able to hide behind a command it never carried out.
RotOn bool `json:"rot_on"`
RotAz float64 `json:"rot_az"`
RotEl float64 `json:"rot_el"`
RotLive bool `json:"rot_live"`
// RotAzOnly: the elevation is not being driven and RotEl means nothing.
// Sent so the panel can leave it out rather than draw an antenna lying on
// the horizon, which is what an undriven zero looks like.
RotAzOnly bool `json:"rot_az_only"`
}
// StartSatelliteTracking arms the radio and starts following the satellite.
func (a *App) StartSatelliteTracking(name string, transponder int) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
a.StopSatelliteTracking()
t := &satTracker{
name: b.Name,
tp: transponder,
nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}),
done: make(chan struct{}),
wake: make(chan struct{}, 1),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
r, rerr := a.newSatRotator(set)
if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else {
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
t.rotAzOnly = set.RotAzOnly
t.status.RotAzOnly = set.RotAzOnly
}
}
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
// it can still be tuned to the downlink, which is most of a receive-heavy
// pass, and saying so beats refusing to track at all.
radio := "downlink-only"
if a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
applog.Printf("sat: could not arm satellite mode: %v", err)
t.status.Error = err.Error()
} else {
radio = "sat"
a.applySatRadio(b.Transponders[transponder])
}
}
t.status.Radio = radio
a.satTrackMu.Lock()
a.satTrack = t
a.satTrackMu.Unlock()
go a.satTrackLoop(t)
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
return nil
}
// StopSatelliteTracking hands the radio back.
func (a *App) StopSatelliteTracking() {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrack = nil
a.satTrackMu.Unlock()
if t == nil {
return
}
close(t.stop)
<-t.done
if a.cat != nil && a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
applog.Printf("sat: could not disarm satellite mode: %v", err)
}
}
applog.Printf("sat: tracking stopped (%s)", t.name)
a.emitSatTrack(SatTrackStatus{})
}
// TestSatelliteRotator opens the configured controller and asks it where it is.
//
// The one question worth asking before a pass: is this port the rotator, and
// does it talk back? A controller that accepts commands silently is a normal,
// working one — so that answer is a success with a caveat, not a failure.
func (a *App) TestSatelliteRotator() (string, error) {
set := a.satSettings()
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
c, err := a.newSatRotator(set)
if err != nil {
return "", err
}
defer c.Close()
az, el, live, err := c.Heading()
if err != nil {
return "", err
}
if !live {
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
}
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
}
// GetSatelliteTracking reports what the tracker is doing.
func (a *App) GetSatelliteTracking() SatTrackStatus {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return SatTrackStatus{}
}
t.mu.Lock()
defer t.mu.Unlock()
return t.status
}
// satTrackedNominal is the nominal downlink the tracker is currently working
// from, or 0 when it is not tracking this satellite and transponder.
func (a *App) satTrackedNominal(name string, transponder int) int64 {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
return t.nominalDown
}
func (a *App) emitSatTrack(s SatTrackStatus) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:track", s)
}
}
func (a *App) satTrackLoop(t *satTracker) {
defer close(t.done)
defer t.releaseRotator()
tick := time.NewTicker(satTickEvery)
defer tick.Stop()
for {
a.satTrackStep(t)
select {
case <-t.stop:
return
case <-t.wake:
case <-tick.C:
}
}
}
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
// what changed.
func (a *App) satTrackStep(t *satTracker) {
_, birds, _ := a.satParts()
b, ok := birds.Find(t.name)
if !ok || t.tp >= len(b.Transponders) {
return
}
tp := b.Transponders[t.tp]
t.mu.Lock()
nominal := t.nominalDown
lastDown, lastUp := t.lastDown, t.lastUp
t.mu.Unlock()
// Where the satellite is, and how fast it is running away. A geostationary
// bird is neither: its range rate is zero, so the zero position below gives
// a zero shift without a special case, and asking for a look angle we do not
// need would only fail on a station with no locator.
var pos sat.Position
visible := true
if !b.Geostationary {
obs, err := a.satObserver()
if err != nil {
t.setError(err.Error())
return
}
real, ok := a.satResolve(t.name)
if !ok {
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
return
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, time.Now().UTC())
if err != nil {
t.setError(err.Error())
return
}
pos = p
visible = p.Visible()
}
// The fractional shift, positive when the satellite is approaching. Only the
// dial arithmetic below needs it as a number; the pair itself comes from
// sat.Doppler, so there is exactly one place where the sign of a correction
// is decided.
factor := -pos.RangeRate / satLightKmS
// Where did the operator leave the receiver? If it is not where the tracker
// put it, they have moved to another station and that is the new nominal.
if lastDown > 0 && tp.Linear() {
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
if abs64i(actual-lastDown) > satDialTolerance {
moved := satNominalFromDial(actual, factor)
if moved >= tp.DownLo && moved <= tp.DownHi {
nominal = moved
t.mu.Lock()
t.nominalDown = moved
t.mu.Unlock()
}
}
}
}
nomUp := tp.UplinkFor(nominal)
sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz
t.mu.Lock()
// Rebuilt from the old one, not from nothing.
//
// The rotator fields are written by readRotator, which runs at most every
// three seconds — a controller query binds a socket and waits. Building a
// fresh status here dropped them on every OTHER tick, so the antenna
// readout appeared for one second in three and vanished again, which reads
// as a rotator that keeps disconnecting.
st := t.status
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
st.NominalDown, st.NominalUp = nominal, nomUp
st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
t.status = st
t.mu.Unlock()
t.pointRotator(pos, b.Geostationary)
t.readRotator()
t.mu.Lock()
out := t.status
t.mu.Unlock()
a.emitSatTrack(out)
// Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
// under a couple of hundred matters at all.
step := int64(20)
if strings.EqualFold(tp.Mode, "FM") {
step = 200
}
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
return
}
downMode, upMode := satSidebands(tp)
if lastDown != 0 {
// Set once, at the start of the pass — see satMode/satSetMode.
downMode, upMode = "", ""
}
err := a.satTune(down, up, downMode, upMode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
t.status.Error = ""
} else {
t.fails++
t.status.Error = err.Error()
}
fails := t.fails
t.mu.Unlock()
if err != nil && (fails == 1 || fails%30 == 0) {
// Once, then once every half minute: a radio that has gone away must be
// visible in the log without filling it.
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
}
}
// satNominalFromDial turns a frequency the operator tuned to into the nominal
// one it corresponds to.
//
// The inverse of the downlink correction: what comes out of the transponder at
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
// operator hunt across a linear passband without the tracker dragging them back
// — where they land becomes the new truth, and the uplink follows it.
func satNominalFromDial(heardHz int64, factor float64) int64 {
if heardHz <= 0 || factor <= -1 {
return heardHz
}
return int64(math.Round(float64(heardHz) / (1 + factor)))
}
// pointRotator keeps the antenna on the satellite.
//
// Below the configured elevation the rotator is left alone. Not because the
// numbers stop being right — they are right all the way round the orbit — but
// because a rotator that chases a satellite through the far side of the earth
// spends the whole night turning, and a mast is a mechanical thing with a
// finite number of turns in it.
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
if t.rot == nil {
return
}
if !geostationary && pos.El < t.rotMinE {
return
}
// A step below the beamwidth is a command for nothing. Compared against what
// was last COMMANDED rather than where the rotator says it is: a rotator in
// motion is always somewhere between the two, and comparing against that
// would order a fresh move on every tick of a slew.
az, el := pos.Az, pos.El
if geostationary {
// A satellite that does not move needs pointing once. Its own az/el were
// not computed (there is nothing to compute), so leave the rotator where
// the operator put it.
if t.rotSent {
return
}
}
// In azimuth-only mode the elevation is never commanded, so comparing it
// would find a difference on every tick and send a command for nothing —
// the antenna ordered to the same bearing once a second for the whole pass.
moved := math.Abs(az-t.rotAz) >= t.rotStep
if !t.rotAzOnly {
moved = moved || math.Abs(el-t.rotEl) >= t.rotStep
}
if t.rotSent && !moved {
return
}
if err := t.rot.Point(az, el); err != nil {
t.setError(err.Error())
return
}
t.rotAz, t.rotEl, t.rotSent = az, el, true
}
// readRotator asks the controller where it actually is, for the display.
//
// Separate from the pointing, and it runs on every tick rather than only when a
// command was sent: watching the antenna crawl towards the bearing is how an
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
// controller that does not answer says so once and is not asked again.
func (t *satTracker) readRotator() {
if t.rot == nil {
return
}
// Not on every tick. A PstRotator query binds a socket and waits up to a
// second and a half for an answer, and a held serial port still costs a
// round trip; three seconds is often enough to watch an antenna slew and
// rare enough not to sit in the way of the tuning.
if time.Since(t.rotReadAt) < 3*time.Second {
return
}
t.rotReadAt = time.Now()
az, el, live, err := t.rot.Heading()
t.mu.Lock()
defer t.mu.Unlock()
if err != nil {
t.status.RotOn = true
return
}
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
}
// releaseRotator hands the mast back at the end of a pass.
func (t *satTracker) releaseRotator() {
if t.rot == nil {
return
}
if t.rotPark && t.rotSent {
// Elevation down first and azimuth to north: a dish or a pair of yagis
// left pointing at the sky is what a gale takes away.
if err := t.rot.Point(0, 0); err != nil {
applog.Printf("sat: could not park the rotator: %v", err)
}
}
t.rot.Close()
t.rot = nil
}
func (t *satTracker) setError(msg string) {
t.mu.Lock()
t.status.Error = msg
t.mu.Unlock()
}
// satTune sends the pair to whichever radio is connected.
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
return a.cat.SatDo(func(st cat.SatTuner) error {
return st.TuneSatellite(downHz, upHz, downMode, upMode)
})
}
// No satellite pair on this backend: the downlink is what it can do, and the
// operator was told so when tracking started (Radio = "downlink-only").
if err := a.cat.SetFrequency(downHz); err != nil {
return err
}
if downMode != "" {
return a.cat.SetMode(downMode)
}
return nil
}
// satReceiveHz is where the receiver is, asked of the backend that knows.
func (a *App) satReceiveHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatReceiveHz()
hz = v
return e
})
return hz, err
}
st := a.cat.State()
if st.RxFreqHz > 0 {
return st.RxFreqHz, nil
}
return st.FreqHz, nil
}
func abs64i(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// ── What goes in the log ────────────────────────────────────────────────────
// applySatellite stamps a QSO made through a satellite.
//
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
// stations working each other through a transponder read different numbers off
// their dials at the same instant — that is what Doppler means — and the only
// figure they can both agree on, and the only one that means anything to
// somebody reading the log later, is the transponder's own. LoTW matches on the
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
// of where one radio happened to be.
func (a *App) applySatellite(q *qso.QSO) {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return
}
t.mu.Lock()
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
az, el := t.status.Az, t.status.El
t.mu.Unlock()
if name == "" || down <= 0 {
return
}
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
// imported, or logged from a second radio while the tracker happened to be
// running, keeps what it was given.
if strings.TrimSpace(q.PropMode) == "" {
q.PropMode = "SAT"
}
if q.PropMode != "SAT" {
return // they said it was something else — meteor scatter, EME
}
if strings.TrimSpace(q.SatName) == "" {
q.SatName = name
}
if strings.TrimSpace(q.SatMode) == "" {
q.SatMode = satModeLetters(up, down)
}
// The transmit frequency is the uplink and the receive frequency the
// downlink — which is the one place a satellite QSO differs from every other
// kind, and the reason FREQ alone cannot describe one.
if up > 0 {
q.FreqHz = &up
if b := bandForHz(up); b != "" {
q.Band = b
}
}
d := down
q.FreqRXHz = &d
if b := bandForHz(down); b != "" {
q.BandRX = b
}
if q.AntAz == nil && (az != 0 || el != 0) {
v := az
q.AntAz = &v
}
if q.AntEl == nil && el != 0 {
v := el
q.AntEl = &v
}
}
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
// are what every satellite operator writes on a QSL card.
func satModeLetters(upHz, downHz int64) string {
u, d := satBandLetter(upHz), satBandLetter(downHz)
if u == "" || d == "" {
return ""
}
return u + "/" + d
}
func satBandLetter(hz int64) string {
switch {
case hz <= 0:
return ""
case hz < 30_000_000:
return "A" // 10 m — mode A's downlink
case hz < 148_000_000:
return "V" // 2 m
case hz < 450_000_000:
return "U" // 70 cm
case hz < 1_300_000_000:
return "L" // 23 cm
case hz < 2_500_000_000:
return "S" // 13 cm
case hz < 6_000_000_000:
return "C" // 6 cm
case hz < 11_000_000_000:
return "X" // 3 cm
}
return "K" // 24 GHz and above
}
// applySatAntennas puts each satellite slice on the antenna configured for ITS
// band.
//
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
// only ever applied by the entry form on a band change — to the active slice.
// A pass never goes through that path: the tracker arms two slices itself, on
// two different bands, and both were left on whatever the radio last used. A
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
// nothing at all, having configured exactly the thing that was being ignored.
//
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
// a correction of ten kilohertz cannot change the band, and the nominal pair is
// what the operator's configuration is written against.
func (a *App) applySatRadio(tp sat.Transponder) {
if a.cat == nil || !a.cat.SatCapable() {
return
}
// The CTCSS tone first: an FM bird will not answer without it, and it is the
// one setting an operator cannot make from the front panel once a pass has
// started. Zero turns it off, which is what a linear bird needs.
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatTone(tp.CTCSS)
}); err != nil {
applog.Printf("sat: could not set the uplink tone: %v", err)
}
m, err := a.GetFlexBandAntennas()
if err != nil || len(m) == 0 {
return
}
// The downlink is received, so it takes that band's RX antenna; the uplink
// is transmitted, so it takes that band's TX antenna.
rxAnt := m[bandForHz(tp.DownLo)].RX
txAnt := m[bandForHz(tp.UpLo)].TX
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt)
}); err != nil {
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
// the wrong antenna is still a pass.
applog.Printf("sat: could not set the satellite antennas: %v", err)
}
}
// satSidebands is which sideband to set on each side of a linear transponder.
//
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
// sideband comes back on upper. Setting USB at both ends — which is what
// happened until now — put the operator's own audio through the transponder
// upside down, which is unreadable at the far end and sounds like nothing much
// at ours.
//
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
// and FM down, and CW is CW whichever way round the passband runs.
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
return tp.Mode, tp.Mode
}
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
// on the AO-7 10 m downlink, which would be lower sideband on HF.
if tp.Inverting {
return "USB", "LSB"
}
return "USB", "USB"
}
// RetargetSatelliteTracking points the tracker at a different satellite without
// letting go of the radio.
//
// Two birds are often up at once, and an operator switching between them found
// the frequencies stayed on the first: the panel's selection is the DISPLAY's,
// while the tracker held its own name and went on following what it was started
// with. Stopping and starting worked, which is how it was discovered, and is
// also how a Flex loses and rebuilds both its slices for no reason.
//
// So the radio stays armed and the rotator stays open, and only what is being
// followed changes. Everything derived from the old satellite is cleared so the
// next step sets it afresh: the frequencies, the mode on both slices (set once
// per satellite, not per tick), the antennas and the tone — the new bird may be
// U/V where the old one was V/U, which swaps which slice is on which band.
func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
// Not tracking: this is simply a start.
return a.StartSatelliteTracking(name, transponder)
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
tp := b.Transponders[transponder]
t.mu.Lock()
t.name, t.tp = b.Name, transponder
t.nominalDown = tp.Centre()
// Zeroed so the next step tunes and sets the mode again rather than deciding
// nothing has changed.
t.lastDown, t.lastUp, t.fails = 0, 0, 0
// And so the antenna is commanded at once instead of waiting for the new
// satellite to drift a step away from where the old one happened to be.
t.rotSent = false
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
t.status.Error = ""
t.mu.Unlock()
if a.cat != nil && a.cat.SatCapable() {
a.applySatRadio(tp)
}
select {
case t.wake <- struct{}{}:
default: // a step is already pending; it will pick this up
}
applog.Printf("sat: now tracking %s (%s)", b.Name, tp.Label)
return nil
}