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
|
satStore *sat.Store // orbital elements, by satellite name
|
||||||
satBirds *sat.Birds // uplink/downlink plan
|
satBirds *sat.Birds // uplink/downlink plan
|
||||||
satFetch *sat.Fetcher // element feeds + the on-disk cache
|
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
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
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")
|
applog.Printf("shutdown: closing autostart programs")
|
||||||
a.CloseAutostartPrograms()
|
a.CloseAutostartPrograms()
|
||||||
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
|
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")
|
applog.Printf("shutdown: stopping UDP")
|
||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
@@ -3086,6 +3094,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q, true)
|
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)
|
fillRXDefaults(&q)
|
||||||
fillDistance(&q)
|
fillDistance(&q)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
@@ -16789,6 +16801,9 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
// of the process.
|
// of the process.
|
||||||
a.disarmAutoCall("profile switch")
|
a.disarmAutoCall("profile switch")
|
||||||
a.autoCallEngine().Reset()
|
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
|
// 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)
|
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
|
||||||
}
|
}
|
||||||
t := b.Transponders[transponder]
|
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 {
|
if downHz <= 0 {
|
||||||
downHz = t.Centre()
|
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",
|
"version": "0.27.17",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"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": [
|
"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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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 {
|
import {
|
||||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
|
||||||
GetSatSettings, SaveSatSettings,
|
GetSatSettings, SaveSatSettings,
|
||||||
|
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -45,6 +46,13 @@ type Tuning = {
|
|||||||
ctcss: number; inverting: boolean;
|
ctcss: number; inverting: boolean;
|
||||||
az: number; el: number; range_km: number; range_rate: number; visible: 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';
|
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||||
|
|
||||||
@@ -76,6 +84,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
const [passes, setPasses] = useState<Pass[]>([]);
|
const [passes, setPasses] = useState<Pass[]>([]);
|
||||||
const [tuning, setTuning] = useState<Tuning | null>(null);
|
const [tuning, setTuning] = useState<Tuning | null>(null);
|
||||||
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | 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 [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [favs, setFavs] = useState<string[]>([]);
|
const [favs, setFavs] = useState<string[]>([]);
|
||||||
@@ -159,6 +168,32 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [loadPasses]);
|
}, [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 () => {
|
const refreshTle = async () => {
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
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')} />
|
<Star className={cn('size-3.5', favs.includes(bird.name) && 'fill-warning text-warning')} />
|
||||||
</Button>
|
</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" />
|
<div className="flex-1" />
|
||||||
<span className={cn('text-[11px] tabular-nums', tle?.stale ? 'text-warning' : 'text-muted-foreground')}>
|
<span className={cn('text-[11px] tabular-nums', tle?.stale ? 'text-warning' : 'text-muted-foreground')}>
|
||||||
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''}
|
{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.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.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.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 = {
|
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.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.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.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 };
|
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 GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
|
||||||
|
|
||||||
|
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
|
||||||
|
|
||||||
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
|
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
|
||||||
|
|
||||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
@@ -1379,10 +1381,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():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 StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StopSatelliteTracking():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncFolderNow():Promise<number>;
|
export function SyncFolderNow():Promise<number>;
|
||||||
|
|||||||
@@ -1142,6 +1142,10 @@ export function GetSatelliteTLEInfo() {
|
|||||||
return window['go']['main']['App']['GetSatelliteTLEInfo']();
|
return window['go']['main']['App']['GetSatelliteTLEInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSatelliteTuning(arg1, arg2, arg3) {
|
export function GetSatelliteTuning(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['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']();
|
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) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['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']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StopSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['StopSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4136,6 +4136,42 @@ export namespace main {
|
|||||||
return a;
|
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 {
|
export class SatTuning {
|
||||||
name: string;
|
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
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
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)
|
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
|
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
|
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)
|
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)
|
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||||
SubSwMN = 0x48 // manual notch on/off
|
SubSwMN = 0x48 // manual notch on/off
|
||||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
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).
|
// 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
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
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)
|
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)
|
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)
|
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{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
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{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if splitSeq {
|
if splitSeq {
|
||||||
delete(f.pendingSplit, seq)
|
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()
|
f.mu.Unlock()
|
||||||
if splitSeq && ok && len(parts) >= 3 {
|
if splitSeq && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
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.
|
// 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
|
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||||
// via ScopeData from the binding goroutine).
|
// via ScopeData from the binding goroutine).
|
||||||
dualScope bool
|
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
|
// 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.
|
// no stream to give, and asking again on every enable is noise.
|
||||||
scopeUnsupported bool
|
scopeUnsupported bool
|
||||||
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
|
|||||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
// 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.
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
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
|
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
||||||
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
// 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
|
// 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
|
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
|
||||||
// calls applyAutoCall, which re-reads them and clears the target.
|
// calls applyAutoCall, which re-reads them and clears the target.
|
||||||
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
|
"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) {")
|
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
||||||
|
|||||||
Reference in New Issue
Block a user