From 465481f8f10798b04bccd3334464e862ebb89958 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 7 Sep 2026 11:27:06 +0200 Subject: [PATCH] feat(sat): Doppler tracking on the radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 15 + app_sat.go | 6 + app_sat_track.go | 475 +++++++++++++++++++++ app_sat_track_test.go | 47 ++ changelog.json | 6 +- frontend/src/components/SatellitePanel.tsx | 56 ++- frontend/src/lib/i18n.tsx | 10 + frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 + frontend/wailsjs/go/models.ts | 36 ++ internal/cat/cat.go | 44 ++ internal/cat/civ/civ.go | 16 + internal/cat/flex.go | 21 +- internal/cat/flexsat.go | 202 +++++++++ internal/cat/icomsat.go | 181 ++++++++ internal/cat/icomserial.go | 11 + profilereload_test.go | 6 + 17 files changed, 1146 insertions(+), 4 deletions(-) create mode 100644 app_sat_track.go create mode 100644 app_sat_track_test.go create mode 100644 internal/cat/flexsat.go create mode 100644 internal/cat/icomsat.go diff --git a/app.go b/app.go index 069cbcb..79c596e 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/app_sat.go b/app_sat.go index aebf6e8..5b4be16 100644 --- a/app_sat.go +++ b/app_sat.go @@ -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() } diff --git a/app_sat_track.go b/app_sat_track.go new file mode 100644 index 0000000..db4f933 --- /dev/null +++ b/app_sat_track.go @@ -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 +} diff --git a/app_sat_track_test.go b/app_sat_track_test.go new file mode 100644 index 0000000..d254554 --- /dev/null +++ b/app_sat_track_test.go @@ -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) + } + } +} diff --git a/changelog.json b/changelog.json index dfcb77c..bcd1ed9 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx index 2bd9f1c..fac7e49 100644 --- a/frontend/src/components/SatellitePanel.tsx +++ b/frontend/src/components/SatellitePanel.tsx @@ -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([]); const [tuning, setTuning] = useState(null); const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null); + const [tracking, setTracking] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [favs, setFavs] = useState([]); @@ -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 }) { )} + {/* 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. */} + + {tracking?.on && tracking.radio === 'downlink-only' && ( + {t('sat.downlinkOnly')} + )}
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 3cf19e8..63fd17a 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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 = { en, fr }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 29efad2..d39edfe 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -605,6 +605,8 @@ export function GetSatellitePositions(arg1:Array):Promise; +export function GetSatelliteTracking():Promise; + export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise; export function GetScpStatus():Promise; @@ -1379,10 +1381,14 @@ export function SetYaesuVOX(arg1:boolean):Promise; export function StartCWDecoder():Promise; +export function StartSatelliteTracking(arg1:string,arg2:number):Promise; + export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise; export function StopCWDecoder():Promise; +export function StopSatelliteTracking():Promise; + export function SwitchCATRig(arg1:number):Promise; export function SyncFolderNow():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 72ae4a8..2412c02 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 64f3025..c896ef8 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index b63ee9b..009871a 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -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 { diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index c7d400c..86ba2a4 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -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). diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 6880ec9..1d4d037 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -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. diff --git a/internal/cat/flexsat.go b/internal/cat/flexsat.go new file mode 100644 index 0000000..737e611 --- /dev/null +++ b/internal/cat/flexsat.go @@ -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 +} diff --git a/internal/cat/icomsat.go b/internal/cat/icomsat.go new file mode 100644 index 0000000..d0341de --- /dev/null +++ b/internal/cat/icomsat.go @@ -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 +} diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 67fd54d..e598375 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -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 diff --git a/profilereload_test.go b/profilereload_test.go index f6c4654..c1c49be 100644 --- a/profilereload_test.go +++ b/profilereload_test.go @@ -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) {")