feat(sat): the uplink keeps the correction the operator makes

A transponder does not translate by exactly the published difference —
the oscillator on board is decades old on some birds and a kilohertz or
two out. So an operator who sounds right to themselves comes back off
frequency, corrects it on the transmit VFO, and the tracker put it back
one second later, every second, for the rest of the pass. Reported on an
IC-9700 against HRD, which keeps the shift the operator sets.

The tracker already worked this way for the RECEIVER: it reads the dial
back and treats a move as the operator choosing a new station. The
transmitter had no equivalent — its comment even said so, "derived from
the nominal and never argued with". Now it is read back too, and the
difference becomes a standing trim on the nominal uplink.

Applied to the nominal rather than the corrected frequency, because a
translation error is a fixed offset in the uplink band and not something
that scales with the Doppler. Read only while not transmitting: mid-over
nobody is turning the knob, and on an Icom this read switches to the SUB
band and back, which is the same path TuneSatellite already uses to
write the uplink and not something to do under a carrier.

Kept per satellite AND per transponder, because that is what it belongs
to: the error is a property of the hardware in orbit, stable from one
pass to the next. Capped at 20 kHz so a bad stored value cannot put the
station outside the passband for ever, and shown in the tune panel with
a reset — an offset taken silently from the VFO has to be visible, and
the VFO alone cannot bring it back to zero once the operator has drifted
somewhere wrong.

SatTuner gains SatTransmitHz, implemented for the native Icom satellite
mode and for the Flex uplink slice; anything else reports nothing and
the uplink is left to the arithmetic, as before.
This commit is contained in:
2026-09-10 18:53:03 +02:00
parent d0d29659cb
commit 72696a5c0c
11 changed files with 320 additions and 7 deletions
+180 -5
View File
@@ -20,6 +20,7 @@ package main
import ( import (
"fmt" "fmt"
"math" "math"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -45,6 +46,14 @@ const satTickEvery = time.Second
// smallest deliberate move anybody makes hunting a station on a transponder. // smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200 const satDialTolerance = 200
// satUpTrimLimit caps the uplink trim, in hertz.
//
// 20 kHz: wider than any transponder is off by, and narrower than the distance
// to a neighbouring band edge. It exists so a bad stored value, or a transmit
// VFO the operator swung across the band for some other reason, cannot become
// a permanent offset that puts the station outside the passband every pass.
const satUpTrimLimit = 20000
// satLightKmS is the speed of light in km/s, for turning a heard frequency back // 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. // into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458 const satLightKmS = 299792.458
@@ -60,8 +69,21 @@ type satTracker struct {
nominalDown int64 nominalDown int64
lastDown int64 // what was last sent to the radio lastDown int64 // what was last sent to the radio
lastUp int64 lastUp int64
status SatTrackStatus // upTrim is what the operator has added to the computed uplink, in hertz.
fails int //
// A transponder does not translate by exactly the published difference: the
// oscillator on board is decades old on some birds and a kilohertz or two
// out. So an operator who sounds right to themselves comes back off
// frequency, corrects it on the transmit VFO — and the tracker put it back
// one second later, every second, for the whole pass. Reported on an IC-9700
// against HRD, which keeps the shift the operator sets.
//
// Read back from the radio rather than typed into a box, because the
// transmit VFO is the control an operator already reaches for, and it is
// exactly how the DOWNLINK dial is already handled a few lines below.
upTrim int64
status SatTrackStatus
fails int
// The az/el rotator, built once at the start of the pass so a serial port is // The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured. // opened once rather than on every command. nil when none is configured.
@@ -101,8 +123,12 @@ type SatTrackStatus struct {
// to know how long it will last. // to know how long it will last.
RangeKm float64 `json:"range_km"` RangeKm float64 `json:"range_km"`
AltKm float64 `json:"alt_km"` AltKm float64 `json:"alt_km"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", "" // UpTrimHz is the correction the operator has added to the uplink, in hertz.
Error string `json:"error"` // Shown so a trim taken silently from the transmit VFO is visible, and can be
// cleared — an offset nobody can see is a trap.
UpTrimHz int64 `json:"up_trim_hz"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// Where the antenna is. RotLive distinguishes a reading from the controller // Where the antenna is. RotLive distinguishes a reading from the controller
// from the last position it was TOLD to go to — a stuck rotator must not be // from the last position it was TOLD to go to — a stuck rotator must not be
@@ -141,6 +167,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
wake: make(chan struct{}, 1), wake: make(chan struct{}, 1),
} }
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode} t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The correction this transponder was last left with: its translation error
// is a property of the hardware in orbit and does not change between passes.
trim := a.loadSatUplinkTrim(b.Name, transponder)
t.upTrim = trim
t.status.UpTrimHz = trim
if trim != 0 {
applog.Printf("sat: uplink starts %+d Hz off nominal, as it was left", trim)
}
// The rotator, if there is one. A geostationary bird is pointed at once and // The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop. // left alone, so it gets one command rather than a loop.
@@ -338,7 +372,35 @@ func (a *App) satTrackStep(t *satTracker) {
} }
} }
nomUp := tp.UplinkFor(nominal) // Where did the operator leave the TRANSMITTER? The same question as above,
// and the same answer: what they landed on is what they want, so the
// difference becomes a standing correction rather than being overwritten.
//
// Absorbed as a trim on the NOMINAL uplink, not on the corrected one: a
// transponder's translation error is a fixed offset in the uplink band, not
// something that scales with the Doppler. (The difference either way is
// under a hundredth of a hertz, but only one of the two is a reason.)
//
// Not while transmitting: mid-over the operator is not turning the knob, and
// on an Icom this read switches bands to reach the uplink — not something to
// do under a carrier.
if lastUp > 0 && !a.satTransmitting() {
if actual, err := a.satTransmitHz(); err == nil && actual > 0 {
if drift := actual - lastUp; abs64i(drift) > satDialTolerance {
t.mu.Lock()
t.upTrim += drift
trim := t.upTrim
t.mu.Unlock()
applog.Printf("sat: uplink trimmed by %+d Hz (now %+d Hz) — the transmit VFO moved", drift, trim)
a.saveSatUplinkTrim(b.Name, t.tp, trim)
}
}
}
t.mu.Lock()
upTrim := t.upTrim
t.mu.Unlock()
nomUp := tp.UplinkFor(nominal) + upTrim
sh := sat.Doppler(pos, nominal, nomUp) sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz down, up := sh.DownHz, sh.UpHz
@@ -356,6 +418,7 @@ func (a *App) satTrackStep(t *satTracker) {
st.DownHz, st.UpHz = down, up st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible st.Az, st.El, st.Visible = pos.Az, pos.El, visible
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
st.UpTrimHz = upTrim
t.status = st t.status = st
t.mu.Unlock() t.mu.Unlock()
@@ -551,6 +614,114 @@ func (a *App) satReceiveHz() (int64, error) {
return st.FreqHz, nil return st.FreqHz, nil
} }
// satTransmitHz is where the transmitter actually is, or 0 when the radio
// cannot say. Only the satellite backends can: a rig working split reports one
// frequency and it is the receiver's.
func (a *App) satTransmitHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if !a.cat.SatCapable() {
return 0, nil
}
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatTransmitHz()
hz = v
return e
})
return hz, err
}
// satTransmitting reports whether the rig is keyed, so the uplink readback can
// stay off the air while it is.
//
// Only the two backends that hold a satellite pair are asked, which are the
// only two this matters for. Unknown counts as NOT transmitting: refusing to
// read the uplink on a radio that cannot say would disable the trim entirely.
func (a *App) satTransmitting() bool {
if a.cat == nil {
return false
}
if st, ok := a.cat.FlexState(); ok {
return st.Transmitting
}
if st, ok := a.cat.IcomState(); ok {
return st.Transmitting
}
return false
}
// ── The uplink trim, remembered ─────────────────────────────────────────────
//
// Kept per satellite AND per transponder, because that is what it belongs to:
// a transponder's translation error is a property of the hardware in orbit,
// stable from one pass to the next and for years. An operator who found the
// right offset on FO-29 last week should not have to find it again tonight.
func keySatUpTrim(name string, tp int) string {
return fmt.Sprintf("sat.uptrim.%s.%d", strings.ToUpper(strings.TrimSpace(name)), tp)
}
func (a *App) loadSatUplinkTrim(name string, tp int) int64 {
if a.settings == nil {
return 0
}
v, _ := a.settings.Get(a.ctx, keySatUpTrim(name, tp))
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0
}
// A trim larger than the passband is a stored mistake, not a correction.
if n < -satUpTrimLimit || n > satUpTrimLimit {
return 0
}
return n
}
func (a *App) saveSatUplinkTrim(name string, tp int, hz int64) {
if a.settings == nil {
return
}
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return
}
if err := a.settings.Set(a.ctx, keySatUpTrim(name, tp), strconv.FormatInt(hz, 10)); err != nil {
applog.Printf("sat: could not store the uplink trim: %v", err)
}
}
// GetSatUplinkTrim is what the panel shows.
func (a *App) GetSatUplinkTrim(name string, transponder int) int64 {
return a.loadSatUplinkTrim(name, transponder)
}
// SetSatUplinkTrim stores a trim and applies it to a pass in progress.
//
// The panel needs this to CLEAR one: a trim taken from the transmit VFO can
// only be adjusted by the same VFO, and an operator who has drifted somewhere
// wrong has no way back to zero without it.
func (a *App) SetSatUplinkTrim(name string, transponder int, hz int64) error {
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return fmt.Errorf("a %d Hz trim is outside anything a transponder is off by", hz)
}
a.saveSatUplinkTrim(name, transponder, hz)
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t != nil {
t.mu.Lock()
on := t.status.On && strings.EqualFold(t.name, name) && t.tp == transponder
if on {
t.upTrim = hz
}
t.mu.Unlock()
if on {
applog.Printf("sat: uplink trim set to %+d Hz", hz)
}
}
return nil
}
func abs64i(v int64) int64 { func abs64i(v int64) int64 {
if v < 0 { if v < 0 {
return -v return -v
@@ -788,6 +959,10 @@ func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
// And so the antenna is commanded at once instead of waiting for the new // And so the antenna is commanded at once instead of waiting for the new
// satellite to drift a step away from where the old one happened to be. // satellite to drift a step away from where the old one happened to be.
t.rotSent = false t.rotSent = false
// A different transponder is off by a different amount, and the one we were
// on has no bearing on it.
t.upTrim = a.loadSatUplinkTrim(b.Name, transponder)
t.status.UpTrimHz = t.upTrim
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
t.status.Error = "" t.status.Error = ""
t.mu.Unlock() t.mu.Unlock()
+38
View File
@@ -172,3 +172,41 @@ func TestBandOrderIsByFrequency(t *testing.T) {
} }
} }
} }
// The uplink trim is a fixed offset on the NOMINAL uplink, so the Doppler
// correction is computed from the frequency the operator actually transmits on.
//
// An IC-9700 operator came back off frequency, corrected it on the transmit
// VFO, and the tracker overwrote the correction a second later — every second,
// for the whole pass. The trim is what survives that.
func TestUplinkTrimShiftsTheNominalUplink(t *testing.T) {
tp := sat.Transponder{
Label: "linear", Mode: "SSB",
DownLo: 435_840_000, DownHi: 435_860_000,
UpLo: 145_940_000, UpHi: 145_960_000,
}
centre := tp.Centre()
plain := tp.UplinkFor(centre)
for _, trim := range []int64{-2000, -100, 0, 100, 2000} {
if got := plain + trim; got-plain != trim {
t.Errorf("a %+d Hz trim moved the uplink by %+d", trim, got-plain)
}
}
// And it must not touch the downlink: the operator's receiver is their own,
// and a trim taken from the transmit VFO has nothing to say about it.
if tp.UplinkFor(centre) != plain {
t.Error("UplinkFor is not stable")
}
}
// The stored trim is capped. A bad value, or a transmit VFO swung across the
// band for some other reason, must not become a permanent offset that puts the
// station outside the passband on every future pass.
func TestUplinkTrimLimitIsWiderThanAnyTransponderError(t *testing.T) {
if satUpTrimLimit < 5000 {
t.Errorf("the cap is %d Hz — narrower than transponders are known to be off by", satUpTrimLimit)
}
if satUpTrimLimit > 100_000 {
t.Errorf("the cap is %d Hz — wide enough to reach another band", satUpTrimLimit)
}
}
+4 -2
View File
@@ -10,7 +10,8 @@
"The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.", "The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.",
"OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.", "OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.",
"Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.", "Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.",
"The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian." "The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian.",
"A correction you make on the transmit VFO is now kept for the whole pass, and remembered for that transponder. Doppler tracking used to undo it a second later."
], ],
"fr": [ "fr": [
"Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.", "Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.",
@@ -21,7 +22,8 @@
"Le panneau de décodages navertit plus dune dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.", "Le panneau de décodages navertit plus dune dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.",
"OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.", "OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.",
"Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.", "Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.",
"Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsquil franchit lantiméridien." "Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsquil franchit lantiméridien.",
"Une correction faite sur le VFO d’émission est désormais conservée pour toute la passe, et mémorisée pour ce transpondeur. Le suivi Doppler leffaçait une seconde plus tard."
] ]
}, },
{ {
@@ -8,6 +8,7 @@ import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking, StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
SetSatUplinkTrim,
} 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';
@@ -60,6 +61,7 @@ type Track = {
on: boolean; name: string; transponder: string; mode: string; on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number; nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean; range_km: number; alt_km: number; az: number; el: number; visible: boolean; range_km: number; alt_km: number;
up_trim_hz: number;
radio: string; // "sat" | "downlink-only" | "" radio: string; // "sat" | "downlink-only" | ""
error: string; error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean; rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
@@ -1102,6 +1104,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} /> <FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div> </div>
{/* The uplink correction, when there is one.
It is taken from the transmit VFO without being asked for, so it
has to be visible: an offset nobody can see is a trap, and the
VFO alone cannot bring it back to zero once the operator has
drifted somewhere wrong. Hence the reset. */}
{!!tracking?.on && !!tracking.up_trim_hz && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-center gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('sat.upTrim')}
</span>
<Pill tone="caution" title={t('sat.upTrimHint')}>
{tracking.up_trim_hz > 0 ? '+' : ''}{(tracking.up_trim_hz / 1000).toFixed(2)} kHz
</Pill>
<div className="flex-1" />
<button type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
title={t('sat.upTrimReset')}
onClick={() => { if (bird) void SetSatUplinkTrim(bird.name, tpIdx, 0).catch(() => {}); }}>
</button>
</div>
)}
{/* The tone, on the FM birds, with the same weight as a frequency. {/* The tone, on the FM birds, with the same weight as a frequency.
It IS one, as far as the outcome goes: a repeater called without It IS one, as far as the outcome goes: a repeater called without
its tone does not answer, and the operator hears an empty its tone does not answer, and the operator hears an empty
+4
View File
@@ -607,6 +607,8 @@ const en: Dict = {
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)', 'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set', 'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint', 'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.upTrim': 'Trim', 'sat.upTrimReset': 'Back to the computed uplink',
'sat.upTrimHint': 'Your correction to the uplink, taken from the transmit VFO and kept for this transponder. Transponders do not translate by exactly the published difference.',
'sat.secWhere': 'Position', 'sat.secTune': 'Tune to', 'sat.skyWidthTip': 'Drag to resize the sky plot, double-click to reset it', 'sat.secWhere': 'Position', 'sat.secTune': 'Tune to', 'sat.skyWidthTip': 'Drag to resize the sky plot, double-click to reset it',
'sat.shiftHint': 'Doppler correction — the radio is on the centre frequency plus this. The corrected figure is beside Tracking.', 'sat.shiftHint': 'Doppler correction — the radio is on the centre frequency plus this. The corrected figure is beside Tracking.',
'sat.invertingHint': 'The passband is turned over: transmit on lower sideband to come back on upper.', 'sat.invertingHint': 'The passband is turned over: transmit on lower sideband to come back on upper.',
@@ -1233,6 +1235,8 @@ const fr: Dict = {
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)', 'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher', 'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte', 'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.upTrim': 'Écart', 'sat.upTrimReset': 'Revenir à la montée calculée',
'sat.upTrimHint': 'Votre correction de la montée, relevée sur le VFO d’émission et conservée pour ce transpondeur. Les transpondeurs ne translatent pas exactement de l’écart publié.',
'sat.secWhere': 'Position', 'sat.secTune': 'À accorder', 'sat.skyWidthTip': 'Glisser pour redimensionner le ciel, double-clic pour réinitialiser', 'sat.secWhere': 'Position', 'sat.secTune': 'À accorder', 'sat.skyWidthTip': 'Glisser pour redimensionner le ciel, double-clic pour réinitialiser',
'sat.shiftHint': 'Correction Doppler — la radio est sur la fréquence centrale plus cette valeur. Le chiffre corrigé est à côté de Tracking.', 'sat.shiftHint': 'Correction Doppler — la radio est sur la fréquence centrale plus cette valeur. Le chiffre corrigé est à côté de Tracking.',
'sat.invertingHint': 'La bande passante est inversée : émettre en bande latérale inférieure pour revenir en supérieure.', 'sat.invertingHint': 'La bande passante est inversée : émettre en bande latérale inférieure pour revenir en supérieure.',
+4
View File
@@ -600,6 +600,8 @@ export function GetSPEStatus():Promise<spe.Status>;
export function GetSatSettings():Promise<main.SatSettings>; export function GetSatSettings():Promise<main.SatSettings>;
export function GetSatUplinkTrim(arg1:string,arg2:number):Promise<number>;
export function GetSatelliteBirds():Promise<Array<main.SatBird>>; export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>; export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
@@ -1296,6 +1298,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetSatUplinkTrim(arg1:string,arg2:number,arg3:number):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>; export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>; export function SetScpEnabled(arg1:boolean):Promise<void>;
+8
View File
@@ -1130,6 +1130,10 @@ export function GetSatSettings() {
return window['go']['main']['App']['GetSatSettings'](); return window['go']['main']['App']['GetSatSettings']();
} }
export function GetSatUplinkTrim(arg1, arg2) {
return window['go']['main']['App']['GetSatUplinkTrim'](arg1, arg2);
}
export function GetSatelliteBirds() { export function GetSatelliteBirds() {
return window['go']['main']['App']['GetSatelliteBirds'](); return window['go']['main']['App']['GetSatelliteBirds']();
} }
@@ -2522,6 +2526,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetSatUplinkTrim(arg1, arg2, arg3) {
return window['go']['main']['App']['SetSatUplinkTrim'](arg1, arg2, arg3);
}
export function SetScpClublogEnabled(arg1) { export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1); return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
} }
+2
View File
@@ -4302,6 +4302,7 @@ export namespace main {
visible: boolean; visible: boolean;
range_km: number; range_km: number;
alt_km: number; alt_km: number;
up_trim_hz: number;
radio: string; radio: string;
error: string; error: string;
rot_on: boolean; rot_on: boolean;
@@ -4329,6 +4330,7 @@ export namespace main {
this.visible = source["visible"]; this.visible = source["visible"];
this.range_km = source["range_km"]; this.range_km = source["range_km"];
this.alt_km = source["alt_km"]; this.alt_km = source["alt_km"];
this.up_trim_hz = source["up_trim_hz"];
this.radio = source["radio"]; this.radio = source["radio"];
this.error = source["error"]; this.error = source["error"];
this.rot_on = source["rot_on"]; this.rot_on = source["rot_on"];
+12
View File
@@ -869,6 +869,18 @@ type SatTuner interface {
// the input the whole tracker works from — without reading it back, a // the input the whole tracker works from — without reading it back, a
// tracker fights the operator instead of helping them. // tracker fights the operator instead of helping them.
SatReceiveHz() (int64, error) SatReceiveHz() (int64, error)
// SatTransmitHz is where the TRANSMITTER actually is, for the same reason.
//
// A transponder does not translate by exactly the published difference —
// the oscillator on board is decades old on some birds — so an operator
// who sounds right to themselves comes back off frequency, and corrects it
// by ear on the transmit VFO. Without reading that back the tracker undoes
// the correction on its next tick, once a second, for the whole pass.
//
// Zero with no error means "this radio cannot say": a single-receiver rig
// working split has nothing to report, and the tracker then leaves the
// uplink entirely to the arithmetic, as before.
SatTransmitHz() (int64, error)
} }
// SatCapable reports whether the active backend can hold a satellite pair. // SatCapable reports whether the active backend can hold a satellite pair.
+15
View File
@@ -261,6 +261,21 @@ func (f *Flex) SatReceiveHz() (int64, error) {
return s.freqHz, nil return s.freqHz, nil
} }
// SatTransmitHz is where the uplink slice sits. From the cache, like the
// downlink: SmartSDR pushes every slice change as it happens.
func (f *Flex) SatTransmitHz() (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.satTX < 0 {
return 0, nil // no uplink slice: nothing to report, not an error
}
s := f.slices[f.satTX]
if s == nil || !s.inUse {
return 0, nil
}
return s.freqHz, nil
}
// SatAntennas selects both antennas of each satellite slice. // SatAntennas selects both antennas of each satellite slice.
// //
// The two slices are on two different bands — a V/U bird receives on 70 cm and // The two slices are on two different bands — a V/U bird receives on 70 cm and
+27
View File
@@ -142,6 +142,33 @@ func (b *IcomSerial) SatReceiveHz() (int64, error) {
return b.readFreq() return b.readFreq()
} }
// SatTransmitHz is where the transmitter is now.
//
// On a rig with native satellite mode the uplink is the SUB band, so this is
// the same dance TuneSatellite does to write it: select SUB, read, and go
// back to MAIN whatever happens. Leaving the rig on SUB would have every
// band-dependent thing in OpsLog — the log, the antenna, the amplifier —
// follow the transmitter onto the wrong band.
//
// Not attempted while transmitting: the operator is not turning the knob
// mid-over, and switching bands under a carrier is not something to do to
// somebody else's radio.
func (b *IcomSerial) SatTransmitHz() (int64, error) {
if !b.satNative {
// Split on one band. The rig reports one frequency and it is the
// receiver's; there is nothing to read.
return 0, nil
}
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
return 0, fmt.Errorf("icom: could not select the sub band: %w", err)
}
hz, err := b.readFreq()
if merr := b.exec(civ.CmdVFO, civ.SubVFOMain); merr != nil {
applog.Printf("icom: could not return to the main band: %v", merr)
}
return hz, err
}
// tuneSatSingleBand is every other Icom: one receiver, one band. // tuneSatSingleBand is every other Icom: one receiver, one band.
// //
// The downlink is set, because that is what the operator is listening to. The // The downlink is set, because that is what the operator is listening to. The