feat(sat): Doppler tracking on the radio
The hard part of satellite tuning is not the arithmetic, it is deciding who owns the dial. A tracker that forces both frequencies fights the operator every time they turn the knob to follow a station across a linear transponder; one that never touches the receiver leaves them chasing a signal that slides nine kilohertz across a 70 cm pass. So the operator owns the receiver and the tracker follows them. Every second it asks the radio where the receiver actually is. Where it put it, nothing has changed. Further than a dial-turn's tolerance, and the operator has chosen a station: what they landed on is converted back into a nominal frequency, and the transmitter is derived from that. Which is the division of labour on a linear bird — the operator listens, the radio does the sums. Three ways to reach the radio, because a satellite pair is a shape of operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is asked for its OWN satellite mode: it pairs main and sub, gives full duplex, and keeps the dials linked the way its designers meant, which is always better than an imitation built out of split. A Flex gets two slices, A the downlink and B the uplink, created when missing, because "slice B does not exist" is not something to make an operator fix at the start of a ten-minute pass. Everything else gets the downlink, and is told so — half the job announced beats half the job hidden. What goes in the log is the NOMINAL pair. Two stations working each other through a transponder read different numbers off their dials at the same instant; the only figure they can both agree on is the transponder's own. FREQ is the uplink and FREQ_RX the downlink — the one place a satellite QSO differs from every other kind, and the reason FREQ alone cannot describe one.
This commit is contained in:
@@ -893,6 +893,10 @@ type App struct {
|
||||
satStore *sat.Store // orbital elements, by satellite name
|
||||
satBirds *sat.Birds // uplink/downlink plan
|
||||
satFetch *sat.Fetcher // element feeds + the on-disk cache
|
||||
// satTrack is the live tracker: the goroutine that walks the radio through a
|
||||
// pass. nil when nothing is being tracked.
|
||||
satTrackMu sync.Mutex
|
||||
satTrack *satTracker
|
||||
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
@@ -1960,6 +1964,10 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
applog.Printf("shutdown: closing autostart programs")
|
||||
a.CloseAutostartPrograms()
|
||||
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
|
||||
// Before CAT goes down: disarming satellite mode takes the radio out of full
|
||||
// duplex and puts the transmitter back where the operator is listening, and
|
||||
// that has to happen while the link is still up.
|
||||
a.StopSatelliteTracking()
|
||||
applog.Printf("shutdown: stopping UDP")
|
||||
if a.udp != nil {
|
||||
a.udp.StopAll()
|
||||
@@ -3086,6 +3094,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
}
|
||||
}()
|
||||
a.applyStationDefaults(&q, true)
|
||||
// Before fillRXDefaults, which copies the transmit frequency into the receive
|
||||
// one: on a satellite the two are on different bands, and letting that copy
|
||||
// happen first would bury the downlink under the uplink.
|
||||
a.applySatellite(&q)
|
||||
fillRXDefaults(&q)
|
||||
fillDistance(&q)
|
||||
a.applyDXCCNumber(&q)
|
||||
@@ -16789,6 +16801,9 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
// of the process.
|
||||
a.disarmAutoCall("profile switch")
|
||||
a.autoCallEngine().Reset()
|
||||
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
||||
// may be a different station on a different antenna.
|
||||
a.StopSatelliteTracking()
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
|
||||
@@ -638,6 +638,12 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa
|
||||
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
|
||||
}
|
||||
t := b.Transponders[transponder]
|
||||
if downHz <= 0 {
|
||||
// While the tracker is running it owns the nominal frequency — it moves
|
||||
// as the operator tunes. Reading the centre of the passband instead would
|
||||
// show a frequency nobody is on the moment they hunt for a station.
|
||||
downHz = a.satTrackedNominal(b.Name, transponder)
|
||||
}
|
||||
if downHz <= 0 {
|
||||
downHz = t.Centre()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
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
|
||||
|
||||
stop chan struct{}
|
||||
done 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"`
|
||||
}
|
||||
|
||||
// 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{}),
|
||||
}
|
||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
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{})
|
||||
}
|
||||
|
||||
// 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)
|
||||
tick := time.NewTicker(satTickEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
a.satTrackStep(t)
|
||||
select {
|
||||
case <-t.stop:
|
||||
return
|
||||
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()
|
||||
t.status = SatTrackStatus{
|
||||
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
||||
NominalDown: nominal, NominalUp: nomUp,
|
||||
DownHz: down, UpHz: up,
|
||||
Az: pos.Az, El: pos.El, Visible: visible,
|
||||
Radio: t.status.Radio, Error: t.status.Error,
|
||||
}
|
||||
t.mu.Unlock()
|
||||
a.emitSatTrack(t.status)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
mode := tp.Mode
|
||||
if lastDown != 0 {
|
||||
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
||||
}
|
||||
err := a.satTune(down, up, mode, mode)
|
||||
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)))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
// The dial arithmetic has to be the exact inverse of the correction, or every
|
||||
// touch of the knob would nudge the nominal frequency a little further off and
|
||||
// the uplink would walk across the passband over a pass.
|
||||
func TestSatNominalFromDialRoundTrip(t *testing.T) {
|
||||
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
|
||||
// covers a low orbit overhead.
|
||||
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
|
||||
p := sat.Position{RangeRate: rate}
|
||||
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
|
||||
sh := sat.Doppler(p, nominal, 0)
|
||||
factor := -rate / satLightKmS
|
||||
got := satNominalFromDial(sh.DownHz, factor)
|
||||
if diff := got - nominal; diff > 1 || diff < -1 {
|
||||
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
|
||||
rate, nominal, sh.DownHz, got, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
|
||||
// the downlink's — the order operators write and the order ADIF wants.
|
||||
func TestSatModeLetters(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
up, down int64
|
||||
want string
|
||||
}{
|
||||
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
|
||||
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
|
||||
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
|
||||
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
|
||||
{"receive only", 0, 145_800_000, ""},
|
||||
} {
|
||||
if got := satModeLetters(tc.up, tc.down); got != tc.want {
|
||||
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -3,10 +3,12 @@
|
||||
"version": "0.27.17",
|
||||
"date": "",
|
||||
"en": [
|
||||
"[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched."
|
||||
"[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched.",
|
||||
"Doppler tracking drives the radio. Track puts the rig on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if they are missing, full duplex on) — and any other radio on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. QSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be."
|
||||
],
|
||||
"fr": [
|
||||
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode."
|
||||
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode.",
|
||||
"Le suivi Doppler pilote la radio. « Suivre » met le poste sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quelle autre radio sur la descente seule, ce qu'elle annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Les QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste } from 'lucide-react';
|
||||
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste, Radio } from 'lucide-react';
|
||||
import {
|
||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
|
||||
GetSatSettings, SaveSatSettings,
|
||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,6 +46,13 @@ type Tuning = {
|
||||
ctcss: number; inverting: boolean;
|
||||
az: number; el: number; range_km: number; range_rate: number; visible: boolean;
|
||||
};
|
||||
type Track = {
|
||||
on: boolean; name: string; transponder: string; mode: string;
|
||||
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||
az: number; el: number; visible: boolean;
|
||||
radio: string; // "sat" | "downlink-only" | ""
|
||||
error: string;
|
||||
};
|
||||
|
||||
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||
|
||||
@@ -76,6 +84,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
const [passes, setPasses] = useState<Pass[]>([]);
|
||||
const [tuning, setTuning] = useState<Tuning | null>(null);
|
||||
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
|
||||
const [tracking, setTracking] = useState<Track | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [favs, setFavs] = useState<string[]>([]);
|
||||
@@ -159,6 +168,32 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [loadPasses]);
|
||||
|
||||
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
|
||||
// rate, so a panel opened while tracking is already running is not blank
|
||||
// until the next tick.
|
||||
useEffect(() => {
|
||||
const read = async () => {
|
||||
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
|
||||
};
|
||||
read();
|
||||
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
|
||||
const id = window.setInterval(read, 10_000);
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const toggleTracking = async () => {
|
||||
setErr('');
|
||||
try {
|
||||
if (tracking?.on) {
|
||||
await StopSatelliteTracking();
|
||||
setTracking(null);
|
||||
} else {
|
||||
await StartSatelliteTracking(sel, tpIdx);
|
||||
setTracking((await GetSatelliteTracking()) as any);
|
||||
}
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
};
|
||||
|
||||
const refreshTle = async () => {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
@@ -336,6 +371,25 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
<Star className={cn('size-3.5', favs.includes(bird.name) && 'fill-warning text-warning')} />
|
||||
</Button>
|
||||
)}
|
||||
{/* Tracking is the one button on this panel that touches the radio, so
|
||||
it says which of the two things it is doing: holding both ends of
|
||||
the pass, or only the receiver on a rig with one. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tracking?.on ? 'default' : 'outline'}
|
||||
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
|
||||
onClick={toggleTracking}
|
||||
disabled={!bird?.transponders?.length}
|
||||
title={tracking?.on
|
||||
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
|
||||
: t('sat.trackTip')}
|
||||
>
|
||||
<Radio className="size-3.5" />
|
||||
{tracking?.on ? t('sat.tracking') : t('sat.track')}
|
||||
</Button>
|
||||
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className={cn('text-[11px] tabular-nums', tle?.stale ? 'text-warning' : 'text-muted-foreground')}>
|
||||
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''}
|
||||
|
||||
@@ -582,6 +582,11 @@ const en: Dict = {
|
||||
'sat.noElements': 'no elements', 'sat.inverting': 'inverting', 'sat.geo': 'geostationary', 'sat.favTip': 'Track this satellite by default',
|
||||
'sat.paste': 'Paste…', 'sat.pasteTip': 'Paste elements for a satellite no feed carries yet. They are kept in their own file and survive every refresh.',
|
||||
'sat.pastePrompt': 'Paste the elements (name, then the two lines):', 'sat.pasteNone': 'Nothing usable in that text.',
|
||||
'sat.track': 'Track', 'sat.tracking': 'Tracking',
|
||||
'sat.trackTip': 'Put the radio on this satellite and keep it there: the downlink and the uplink both corrected for Doppler, once a second. Tune the receiver freely — the transmitter follows where you land.',
|
||||
'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',
|
||||
};
|
||||
|
||||
const fr: Dict = {
|
||||
@@ -1127,6 +1132,11 @@ const fr: Dict = {
|
||||
'sat.noElements': 'sans éléments', 'sat.inverting': 'inverseur', 'sat.geo': 'géostationnaire', 'sat.favTip': 'Suivre ce satellite par défaut',
|
||||
'sat.paste': 'Coller…', 'sat.pasteTip': 'Collez les éléments d’un satellite qu’aucun flux ne diffuse encore. Ils sont conservés dans leur propre fichier et survivent à chaque mise à jour.',
|
||||
'sat.pastePrompt': 'Collez les éléments (nom, puis les deux lignes) :', 'sat.pasteNone': 'Rien d’utilisable dans ce texte.',
|
||||
'sat.track': 'Suivre', 'sat.tracking': 'Suivi',
|
||||
'sat.trackTip': 'Met la radio sur ce satellite et l’y garde : descente et montée corrigées du Doppler, chaque seconde. Accordez le récepteur librement — l’émetteur suit là où vous vous posez.',
|
||||
'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',
|
||||
};
|
||||
|
||||
const dicts: Record<Lang, Dict> = { en, fr };
|
||||
|
||||
Vendored
+6
@@ -605,6 +605,8 @@ export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Posi
|
||||
|
||||
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
|
||||
|
||||
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
|
||||
|
||||
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
@@ -1379,10 +1381,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function StopSatelliteTracking():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function SyncFolderNow():Promise<number>;
|
||||
|
||||
@@ -1142,6 +1142,10 @@ export function GetSatelliteTLEInfo() {
|
||||
return window['go']['main']['App']['GetSatelliteTLEInfo']();
|
||||
}
|
||||
|
||||
export function GetSatelliteTracking() {
|
||||
return window['go']['main']['App']['GetSatelliteTracking']();
|
||||
}
|
||||
|
||||
export function GetSatelliteTuning(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -2690,6 +2694,10 @@ export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StartSatelliteTracking(arg1, arg2) {
|
||||
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -2698,6 +2706,10 @@ export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
export function StopSatelliteTracking() {
|
||||
return window['go']['main']['App']['StopSatelliteTracking']();
|
||||
}
|
||||
|
||||
export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
@@ -4136,6 +4136,42 @@ export namespace main {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SatTrackStatus {
|
||||
on: boolean;
|
||||
name: string;
|
||||
transponder: string;
|
||||
mode: string;
|
||||
nominal_down: number;
|
||||
nominal_up: number;
|
||||
down_hz: number;
|
||||
up_hz: number;
|
||||
az: number;
|
||||
el: number;
|
||||
visible: boolean;
|
||||
radio: string;
|
||||
error: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SatTrackStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.on = source["on"];
|
||||
this.name = source["name"];
|
||||
this.transponder = source["transponder"];
|
||||
this.mode = source["mode"];
|
||||
this.nominal_down = source["nominal_down"];
|
||||
this.nominal_up = source["nominal_up"];
|
||||
this.down_hz = source["down_hz"];
|
||||
this.up_hz = source["up_hz"];
|
||||
this.az = source["az"];
|
||||
this.el = source["el"];
|
||||
this.visible = source["visible"];
|
||||
this.radio = source["radio"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
export class SatTuning {
|
||||
name: string;
|
||||
|
||||
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SatTuner is a backend that can be put on a satellite: a receiver on one band
|
||||
// and a transmitter on another, both moving under Doppler, at the same time.
|
||||
//
|
||||
// It is a separate interface from the per-manufacturer ones because what a
|
||||
// satellite needs is not a manufacturer's feature — it is a shape of operating
|
||||
// that a FlexRadio and an IC-9700 both provide and reach in completely
|
||||
// different ways. A backend that cannot do it simply does not implement this,
|
||||
// and the caller falls back to tuning the downlink alone rather than pretending.
|
||||
type SatTuner interface {
|
||||
// SetSatellite arms or disarms satellite operation: the rig's own satellite
|
||||
// mode where it has one, two slices where it has those. Disarming must leave
|
||||
// the radio somewhere an operator can work from, not half-configured.
|
||||
SetSatellite(on bool) error
|
||||
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
|
||||
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
|
||||
// an empty one leaves that side's mode alone.
|
||||
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
||||
// SatReceiveHz is where the receiver actually is. The operator tunes it to
|
||||
// follow a station across a linear transponder, and that dial movement is
|
||||
// the input the whole tracker works from — without reading it back, a
|
||||
// tracker fights the operator instead of helping them.
|
||||
SatReceiveHz() (int64, error)
|
||||
}
|
||||
|
||||
// SatCapable reports whether the active backend can hold a satellite pair.
|
||||
func (m *Manager) SatCapable() bool {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
_, ok := b.(SatTuner)
|
||||
return ok
|
||||
}
|
||||
|
||||
// SatDo dispatches a satellite control onto the CAT goroutine.
|
||||
func (m *Manager) SatDo(fn func(SatTuner) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
st, ok := b.(SatTuner)
|
||||
if !ok {
|
||||
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
|
||||
}
|
||||
return fn(st)
|
||||
})
|
||||
}
|
||||
|
||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||
func (m *Manager) exec(fn func(Backend) error) error {
|
||||
|
||||
@@ -49,6 +49,11 @@ const (
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||
// CmdVFO selects which receiver subsequent commands address. On the two-band
|
||||
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
|
||||
// band the uplink, so every satellite frequency set is "point at a band, then
|
||||
// tune it".
|
||||
CmdVFO = 0x07
|
||||
|
||||
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||
|
||||
@@ -112,6 +117,17 @@ const (
|
||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||
SubSwMN = 0x48 // manual notch on/off
|
||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
|
||||
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
|
||||
// two dials linked the way the radio's designers meant. Asking it to do that
|
||||
// is always better than building the same thing out of split.
|
||||
SubSwSatellite = 0x5A
|
||||
|
||||
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
|
||||
// addresses.
|
||||
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
|
||||
SubVFOSub = 0xD1 // SUB band — the uplink
|
||||
SubVFOExchange = 0xB0 // swap main and sub
|
||||
)
|
||||
|
||||
// CW break-in modes (CmdSwitch 0x47).
|
||||
|
||||
+20
-1
@@ -67,6 +67,14 @@ type Flex struct {
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
|
||||
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
|
||||
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
|
||||
// one it will close again.
|
||||
satOn bool
|
||||
satRX int
|
||||
satTX int
|
||||
satCreatedTX bool
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||
@@ -227,7 +235,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if splitSeq {
|
||||
delete(f.pendingSplit, seq)
|
||||
}
|
||||
// The same reply carries the index of a slice created for a satellite
|
||||
// pair; which of the two it is was recorded when it was asked for.
|
||||
satRole := f.pendingSat[seq]
|
||||
if satRole != "" {
|
||||
delete(f.pendingSat, seq)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if splitSeq && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||
}
|
||||
}
|
||||
if satRole != "" && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.adoptSatSlice(satRole, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connection ended.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// Satellite operation on a FlexRadio.
|
||||
//
|
||||
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
|
||||
// is the downlink and slice B the uplink — the arrangement every Flex satellite
|
||||
// operator already uses by hand — with the transmitter on B and full duplex on,
|
||||
// so the operator hears their own signal come back through the transponder.
|
||||
// The transverters that put 145 and 435 MHz within the radio's reach are
|
||||
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
|
||||
// sends the real satellite frequency and SmartSDR does the arithmetic.
|
||||
//
|
||||
// The two slices are CREATED when they are missing, because "slice B does not
|
||||
// exist" is not a thing to make the operator fix at the start of a ten-minute
|
||||
// pass. Only what OpsLog created is taken away again on disarming: a slice the
|
||||
// operator opened is theirs.
|
||||
|
||||
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
|
||||
func (f *Flex) SetSatellite(on bool) error {
|
||||
f.mu.Lock()
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if !on {
|
||||
return f.satDisarm()
|
||||
}
|
||||
|
||||
// The downlink slice is the one the operator is already on: taking the
|
||||
// active slice rather than insisting on index 0 means arming the satellite
|
||||
// does not move them off the receiver they were listening to.
|
||||
f.mu.Lock()
|
||||
rxIdx, _ := f.mainSliceLocked()
|
||||
var txIdx = -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
|
||||
txIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
f.satRX, f.satTX = rxIdx, txIdx
|
||||
f.satOn = true
|
||||
f.mu.Unlock()
|
||||
|
||||
// Full duplex before anything else: without it the radio mutes the receiver
|
||||
// on transmit, and an operator who cannot hear their own downlink has no way
|
||||
// to know they are in the passband at all.
|
||||
f.send("radio set full_duplex_enabled=1")
|
||||
|
||||
if rxIdx < 0 {
|
||||
// A radio with no slice at all. One is created; the status that comes
|
||||
// back adopts it as the downlink.
|
||||
f.satCreate("rx", 145.900, "USB")
|
||||
}
|
||||
if txIdx < 0 {
|
||||
f.satCreate("tx", 435.100, "USB")
|
||||
} else {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||
}
|
||||
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) satDisarm() error {
|
||||
f.mu.Lock()
|
||||
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
|
||||
f.mu.Unlock()
|
||||
|
||||
f.send("radio set full_duplex_enabled=0")
|
||||
if created && tx >= 0 {
|
||||
f.send(fmt.Sprintf("slice remove %d", tx))
|
||||
}
|
||||
// Transmit goes back where the operator is listening. A radio left
|
||||
// transmitting on a slice that no longer exists — or on the uplink band with
|
||||
// the satellite gone — is not somewhere anyone should be handed back.
|
||||
if rx >= 0 {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", rx))
|
||||
}
|
||||
applog.Printf("flex: satellite disarmed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// satCreate asks for a slice and remembers what it is for; the index arrives in
|
||||
// the reply (see the R-line handler), which is where the role is applied.
|
||||
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
|
||||
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
|
||||
if seq <= 0 {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.pendingSat == nil {
|
||||
f.pendingSat = map[int]string{}
|
||||
}
|
||||
f.pendingSat[seq] = role
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// adoptSatSlice records a freshly created slice in its role. Called from the
|
||||
// reply handler with the index the radio assigned.
|
||||
func (f *Flex) adoptSatSlice(role string, idx int) {
|
||||
f.mu.Lock()
|
||||
switch role {
|
||||
case "rx":
|
||||
f.satRX = idx
|
||||
case "tx":
|
||||
f.satTX = idx
|
||||
f.satCreatedTX = true
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if role == "tx" {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||
}
|
||||
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||
}
|
||||
|
||||
// TuneSatellite moves the two slices.
|
||||
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||
f.mu.Lock()
|
||||
rx, tx := f.satRX, f.satTX
|
||||
connected := f.conn != nil
|
||||
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
|
||||
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
|
||||
}
|
||||
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
|
||||
f.slices[tx].freqHz = upHz
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if rx < 0 {
|
||||
// The slice was asked for and its index has not come back yet. Nothing is
|
||||
// wrong — the next Doppler step, a second later, will find it.
|
||||
return nil
|
||||
}
|
||||
if downHz > 0 {
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||
f.satMode(rx, downMode, downHz)
|
||||
}
|
||||
if tx >= 0 && upHz > 0 {
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||
f.satMode(tx, upMode, upHz)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// satMode sets a slice's mode only when it is not already there. A mode command
|
||||
// on every Doppler step is a command a second per slice for a whole pass, and
|
||||
// SmartSDR redraws the filter each time.
|
||||
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
||||
// including the parts of a passband that would be an LSB band down on HF.
|
||||
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||
mode = "USB"
|
||||
}
|
||||
fm := adifModeToFlex(mode, freqHz)
|
||||
if fm == "" {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
s := f.slices[idx]
|
||||
same := s != nil && strings.EqualFold(s.mode, fm)
|
||||
if s != nil {
|
||||
s.mode = fm
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if same {
|
||||
return
|
||||
}
|
||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||
}
|
||||
|
||||
// SatReceiveHz is where the downlink slice sits.
|
||||
//
|
||||
// From the cache, not from a read: SmartSDR pushes every slice change as it
|
||||
// happens, so the cached value is what the radio said, and there is no round
|
||||
// trip to pay for once a second.
|
||||
func (f *Flex) SatReceiveHz() (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.satRX < 0 {
|
||||
return 0, fmt.Errorf("flex: no downlink slice")
|
||||
}
|
||||
s := f.slices[f.satRX]
|
||||
if s == nil || !s.inUse {
|
||||
return 0, fmt.Errorf("flex: the downlink slice has gone")
|
||||
}
|
||||
return s.freqHz, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// Satellite operation on an Icom.
|
||||
//
|
||||
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
|
||||
// the IC-9100 — and on those the right thing to do is ask the radio for it
|
||||
// rather than build an imitation out of split. Their satellite mode pairs the
|
||||
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
|
||||
// and keeps the two dials linked the way the designers meant. Every other Icom
|
||||
// has one receiver on one band: it can be tuned to the downlink, and that is
|
||||
// the whole truth about what it can do on a cross-band satellite.
|
||||
//
|
||||
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
|
||||
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
|
||||
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
|
||||
// misbehaving, the log lines below name every frame sent.
|
||||
|
||||
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
|
||||
// because the radio has no second receiver and the two are on different bands.
|
||||
//
|
||||
// A distinct error rather than a silent half-success: a tracker that quietly
|
||||
// stops transmitting where the operator expects it to is worse than one that
|
||||
// says it cannot. The caller reports it once, not once per Doppler step.
|
||||
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
|
||||
|
||||
// SetSatellite arms the rig's own satellite mode.
|
||||
func (b *IcomSerial) SetSatellite(on bool) error {
|
||||
if !b.satNative {
|
||||
// Nothing to arm and nothing to break: the tuning path below does what
|
||||
// this radio can do without any mode change. Refusing here would deny an
|
||||
// operator the downlink, which is most of the value on a receive-heavy
|
||||
// pass.
|
||||
b.satOn = on
|
||||
return nil
|
||||
}
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
|
||||
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
|
||||
}
|
||||
b.satOn = on
|
||||
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
|
||||
if on {
|
||||
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
|
||||
// loop, the logged frequency, the operator's dial — reads the selected
|
||||
// band, and on a satellite the band worth reading is the one carrying the
|
||||
// downlink.
|
||||
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
|
||||
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||
if downHz <= 0 {
|
||||
return fmt.Errorf("icom: no downlink frequency")
|
||||
}
|
||||
if !b.satNative {
|
||||
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
|
||||
}
|
||||
// MAIN — the downlink.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
return fmt.Errorf("icom: could not select the main band: %w", err)
|
||||
}
|
||||
if err := b.SetFrequency(downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
// SUB — the uplink.
|
||||
if upHz > 0 {
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
|
||||
return fmt.Errorf("icom: could not select the sub band: %w", err)
|
||||
}
|
||||
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
|
||||
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
|
||||
merr := b.satSetMode(upMode, upHz)
|
||||
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
|
||||
// uplink as its frequency, and every band-dependent thing in OpsLog —
|
||||
// the log, the antenna, the amplifier — would follow the transmitter
|
||||
// onto the wrong band.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
applog.Printf("icom: could not return to the main band: %v", err)
|
||||
}
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
if merr != nil {
|
||||
return merr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// satSetMode sets the mode of whichever band is currently selected. An empty
|
||||
// mode leaves it alone — a linear transponder is worked in one mode for a whole
|
||||
// pass, and re-sending it every second is traffic for nothing.
|
||||
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return nil
|
||||
}
|
||||
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
|
||||
// is wrong here twice over: the dial may still be on the other band, and on
|
||||
// satellites USB is the convention on both sides whatever the frequency.
|
||||
code, data, err := b.modeCode(satSideband(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.setModeBytes(mode, code, data)
|
||||
}
|
||||
|
||||
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
|
||||
// and the downlink, including the parts of a linear transponder that fall in
|
||||
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
|
||||
// 29 MHz among them — are still USB by convention, so there is no exception to
|
||||
// make.
|
||||
func satSideband(mode string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
|
||||
return "USB"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// SatReceiveHz is where the receiver is now.
|
||||
func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
||||
if b.satNative {
|
||||
// The selected band is MAIN — see TuneSatellite, which always returns to
|
||||
// it — so the ordinary frequency read is the downlink.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
applog.Printf("icom: sat readback could not select main: %v", err)
|
||||
}
|
||||
}
|
||||
return b.readFreq()
|
||||
}
|
||||
|
||||
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
||||
//
|
||||
// The downlink is set, because that is what the operator is listening to. The
|
||||
// uplink is set through split only when it is close enough to be on the same
|
||||
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
|
||||
// out of reach rather than quietly skipped.
|
||||
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
|
||||
if err := b.SetFrequency(downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if upHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
// One megahertz apart is the working definition of "the same band" here: it
|
||||
// covers a transponder's own passband and any sensible transverter pairing,
|
||||
// and excludes every real cross-band satellite (145 / 435 MHz).
|
||||
if abs64(upHz-downHz) > 1_000_000 {
|
||||
return ErrSatUplinkUnreachable
|
||||
}
|
||||
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.satOn {
|
||||
return nil
|
||||
}
|
||||
return b.exec(civ.CmdSplit, boolByte(true))
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -94,6 +94,13 @@ type IcomSerial struct {
|
||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||
// via ScopeData from the binding goroutine).
|
||||
dualScope bool
|
||||
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
|
||||
// which have a real satellite mode of their own. Everything else gets the
|
||||
// downlink and, where the uplink is reachable, split.
|
||||
satNative bool
|
||||
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
|
||||
// once rather than on every Doppler step.
|
||||
satOn bool
|
||||
// Set when the rig rejects the waveform-output command in both shapes: it has
|
||||
// no stream to give, and asking again on every enable is noise.
|
||||
scopeUnsupported bool
|
||||
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
|
||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
|
||||
// bands and a satellite mode that pairs them; no other Icom in this table
|
||||
// does, and asking one that does not is a rejected frame per Doppler step.
|
||||
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
|
||||
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
||||
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
||||
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
||||
|
||||
@@ -34,6 +34,12 @@ func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
|
||||
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
|
||||
// calls applyAutoCall, which re-reads them and clears the target.
|
||||
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
|
||||
// The elements and the frequency plan are FILES, shared by every
|
||||
// profile — there is one sky. What is per profile (the favourites, the
|
||||
// minimum elevation, the locator) is read live on every call, so a
|
||||
// switch is already reflected without rebuilding anything. The tracker,
|
||||
// which does transmit, IS stopped by reloadAfterProfileSwitch.
|
||||
"startSatellites": "one sky: the elements and the plan are shared files, and the per-profile settings are read live",
|
||||
}
|
||||
|
||||
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
||||
|
||||
Reference in New Issue
Block a user