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:
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SatTuner is a backend that can be put on a satellite: a receiver on one band
|
||||
// and a transmitter on another, both moving under Doppler, at the same time.
|
||||
//
|
||||
// It is a separate interface from the per-manufacturer ones because what a
|
||||
// satellite needs is not a manufacturer's feature — it is a shape of operating
|
||||
// that a FlexRadio and an IC-9700 both provide and reach in completely
|
||||
// different ways. A backend that cannot do it simply does not implement this,
|
||||
// and the caller falls back to tuning the downlink alone rather than pretending.
|
||||
type SatTuner interface {
|
||||
// SetSatellite arms or disarms satellite operation: the rig's own satellite
|
||||
// mode where it has one, two slices where it has those. Disarming must leave
|
||||
// the radio somewhere an operator can work from, not half-configured.
|
||||
SetSatellite(on bool) error
|
||||
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
|
||||
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
|
||||
// an empty one leaves that side's mode alone.
|
||||
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
||||
// SatReceiveHz is where the receiver actually is. The operator tunes it to
|
||||
// follow a station across a linear transponder, and that dial movement is
|
||||
// the input the whole tracker works from — without reading it back, a
|
||||
// tracker fights the operator instead of helping them.
|
||||
SatReceiveHz() (int64, error)
|
||||
}
|
||||
|
||||
// SatCapable reports whether the active backend can hold a satellite pair.
|
||||
func (m *Manager) SatCapable() bool {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
_, ok := b.(SatTuner)
|
||||
return ok
|
||||
}
|
||||
|
||||
// SatDo dispatches a satellite control onto the CAT goroutine.
|
||||
func (m *Manager) SatDo(fn func(SatTuner) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
st, ok := b.(SatTuner)
|
||||
if !ok {
|
||||
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
|
||||
}
|
||||
return fn(st)
|
||||
})
|
||||
}
|
||||
|
||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||
func (m *Manager) exec(fn func(Backend) error) error {
|
||||
|
||||
@@ -49,6 +49,11 @@ const (
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||
// CmdVFO selects which receiver subsequent commands address. On the two-band
|
||||
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
|
||||
// band the uplink, so every satellite frequency set is "point at a band, then
|
||||
// tune it".
|
||||
CmdVFO = 0x07
|
||||
|
||||
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||
|
||||
@@ -112,6 +117,17 @@ const (
|
||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||
SubSwMN = 0x48 // manual notch on/off
|
||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
|
||||
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
|
||||
// two dials linked the way the radio's designers meant. Asking it to do that
|
||||
// is always better than building the same thing out of split.
|
||||
SubSwSatellite = 0x5A
|
||||
|
||||
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
|
||||
// addresses.
|
||||
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
|
||||
SubVFOSub = 0xD1 // SUB band — the uplink
|
||||
SubVFOExchange = 0xB0 // swap main and sub
|
||||
)
|
||||
|
||||
// CW break-in modes (CmdSwitch 0x47).
|
||||
|
||||
+20
-1
@@ -67,6 +67,14 @@ type Flex struct {
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
|
||||
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
|
||||
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
|
||||
// one it will close again.
|
||||
satOn bool
|
||||
satRX int
|
||||
satTX int
|
||||
satCreatedTX bool
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||
@@ -227,7 +235,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if splitSeq {
|
||||
delete(f.pendingSplit, seq)
|
||||
}
|
||||
// The same reply carries the index of a slice created for a satellite
|
||||
// pair; which of the two it is was recorded when it was asked for.
|
||||
satRole := f.pendingSat[seq]
|
||||
if satRole != "" {
|
||||
delete(f.pendingSat, seq)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if splitSeq && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||
}
|
||||
}
|
||||
if satRole != "" && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.adoptSatSlice(satRole, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connection ended.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// Satellite operation on a FlexRadio.
|
||||
//
|
||||
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
|
||||
// is the downlink and slice B the uplink — the arrangement every Flex satellite
|
||||
// operator already uses by hand — with the transmitter on B and full duplex on,
|
||||
// so the operator hears their own signal come back through the transponder.
|
||||
// The transverters that put 145 and 435 MHz within the radio's reach are
|
||||
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
|
||||
// sends the real satellite frequency and SmartSDR does the arithmetic.
|
||||
//
|
||||
// The two slices are CREATED when they are missing, because "slice B does not
|
||||
// exist" is not a thing to make the operator fix at the start of a ten-minute
|
||||
// pass. Only what OpsLog created is taken away again on disarming: a slice the
|
||||
// operator opened is theirs.
|
||||
|
||||
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
|
||||
func (f *Flex) SetSatellite(on bool) error {
|
||||
f.mu.Lock()
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if !on {
|
||||
return f.satDisarm()
|
||||
}
|
||||
|
||||
// The downlink slice is the one the operator is already on: taking the
|
||||
// active slice rather than insisting on index 0 means arming the satellite
|
||||
// does not move them off the receiver they were listening to.
|
||||
f.mu.Lock()
|
||||
rxIdx, _ := f.mainSliceLocked()
|
||||
var txIdx = -1
|
||||
for _, idx := range f.sortedSliceIdxLocked() {
|
||||
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
|
||||
txIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
f.satRX, f.satTX = rxIdx, txIdx
|
||||
f.satOn = true
|
||||
f.mu.Unlock()
|
||||
|
||||
// Full duplex before anything else: without it the radio mutes the receiver
|
||||
// on transmit, and an operator who cannot hear their own downlink has no way
|
||||
// to know they are in the passband at all.
|
||||
f.send("radio set full_duplex_enabled=1")
|
||||
|
||||
if rxIdx < 0 {
|
||||
// A radio with no slice at all. One is created; the status that comes
|
||||
// back adopts it as the downlink.
|
||||
f.satCreate("rx", 145.900, "USB")
|
||||
}
|
||||
if txIdx < 0 {
|
||||
f.satCreate("tx", 435.100, "USB")
|
||||
} else {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||
}
|
||||
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) satDisarm() error {
|
||||
f.mu.Lock()
|
||||
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
|
||||
f.mu.Unlock()
|
||||
|
||||
f.send("radio set full_duplex_enabled=0")
|
||||
if created && tx >= 0 {
|
||||
f.send(fmt.Sprintf("slice remove %d", tx))
|
||||
}
|
||||
// Transmit goes back where the operator is listening. A radio left
|
||||
// transmitting on a slice that no longer exists — or on the uplink band with
|
||||
// the satellite gone — is not somewhere anyone should be handed back.
|
||||
if rx >= 0 {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", rx))
|
||||
}
|
||||
applog.Printf("flex: satellite disarmed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// satCreate asks for a slice and remembers what it is for; the index arrives in
|
||||
// the reply (see the R-line handler), which is where the role is applied.
|
||||
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
|
||||
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
|
||||
if seq <= 0 {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.pendingSat == nil {
|
||||
f.pendingSat = map[int]string{}
|
||||
}
|
||||
f.pendingSat[seq] = role
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// adoptSatSlice records a freshly created slice in its role. Called from the
|
||||
// reply handler with the index the radio assigned.
|
||||
func (f *Flex) adoptSatSlice(role string, idx int) {
|
||||
f.mu.Lock()
|
||||
switch role {
|
||||
case "rx":
|
||||
f.satRX = idx
|
||||
case "tx":
|
||||
f.satTX = idx
|
||||
f.satCreatedTX = true
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if role == "tx" {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||
}
|
||||
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||
}
|
||||
|
||||
// TuneSatellite moves the two slices.
|
||||
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||
f.mu.Lock()
|
||||
rx, tx := f.satRX, f.satTX
|
||||
connected := f.conn != nil
|
||||
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
|
||||
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
|
||||
}
|
||||
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
|
||||
f.slices[tx].freqHz = upHz
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if rx < 0 {
|
||||
// The slice was asked for and its index has not come back yet. Nothing is
|
||||
// wrong — the next Doppler step, a second later, will find it.
|
||||
return nil
|
||||
}
|
||||
if downHz > 0 {
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||
f.satMode(rx, downMode, downHz)
|
||||
}
|
||||
if tx >= 0 && upHz > 0 {
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||
f.satMode(tx, upMode, upHz)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// satMode sets a slice's mode only when it is not already there. A mode command
|
||||
// on every Doppler step is a command a second per slice for a whole pass, and
|
||||
// SmartSDR redraws the filter each time.
|
||||
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
||||
// including the parts of a passband that would be an LSB band down on HF.
|
||||
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||
mode = "USB"
|
||||
}
|
||||
fm := adifModeToFlex(mode, freqHz)
|
||||
if fm == "" {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
s := f.slices[idx]
|
||||
same := s != nil && strings.EqualFold(s.mode, fm)
|
||||
if s != nil {
|
||||
s.mode = fm
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if same {
|
||||
return
|
||||
}
|
||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||
}
|
||||
|
||||
// SatReceiveHz is where the downlink slice sits.
|
||||
//
|
||||
// From the cache, not from a read: SmartSDR pushes every slice change as it
|
||||
// happens, so the cached value is what the radio said, and there is no round
|
||||
// trip to pay for once a second.
|
||||
func (f *Flex) SatReceiveHz() (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.satRX < 0 {
|
||||
return 0, fmt.Errorf("flex: no downlink slice")
|
||||
}
|
||||
s := f.slices[f.satRX]
|
||||
if s == nil || !s.inUse {
|
||||
return 0, fmt.Errorf("flex: the downlink slice has gone")
|
||||
}
|
||||
return s.freqHz, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// Satellite operation on an Icom.
|
||||
//
|
||||
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
|
||||
// the IC-9100 — and on those the right thing to do is ask the radio for it
|
||||
// rather than build an imitation out of split. Their satellite mode pairs the
|
||||
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
|
||||
// and keeps the two dials linked the way the designers meant. Every other Icom
|
||||
// has one receiver on one band: it can be tuned to the downlink, and that is
|
||||
// the whole truth about what it can do on a cross-band satellite.
|
||||
//
|
||||
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
|
||||
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
|
||||
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
|
||||
// misbehaving, the log lines below name every frame sent.
|
||||
|
||||
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
|
||||
// because the radio has no second receiver and the two are on different bands.
|
||||
//
|
||||
// A distinct error rather than a silent half-success: a tracker that quietly
|
||||
// stops transmitting where the operator expects it to is worse than one that
|
||||
// says it cannot. The caller reports it once, not once per Doppler step.
|
||||
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
|
||||
|
||||
// SetSatellite arms the rig's own satellite mode.
|
||||
func (b *IcomSerial) SetSatellite(on bool) error {
|
||||
if !b.satNative {
|
||||
// Nothing to arm and nothing to break: the tuning path below does what
|
||||
// this radio can do without any mode change. Refusing here would deny an
|
||||
// operator the downlink, which is most of the value on a receive-heavy
|
||||
// pass.
|
||||
b.satOn = on
|
||||
return nil
|
||||
}
|
||||
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
|
||||
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
|
||||
}
|
||||
b.satOn = on
|
||||
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
|
||||
if on {
|
||||
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
|
||||
// loop, the logged frequency, the operator's dial — reads the selected
|
||||
// band, and on a satellite the band worth reading is the one carrying the
|
||||
// downlink.
|
||||
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
|
||||
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||
if downHz <= 0 {
|
||||
return fmt.Errorf("icom: no downlink frequency")
|
||||
}
|
||||
if !b.satNative {
|
||||
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
|
||||
}
|
||||
// MAIN — the downlink.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
return fmt.Errorf("icom: could not select the main band: %w", err)
|
||||
}
|
||||
if err := b.SetFrequency(downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
// SUB — the uplink.
|
||||
if upHz > 0 {
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
|
||||
return fmt.Errorf("icom: could not select the sub band: %w", err)
|
||||
}
|
||||
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
|
||||
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
|
||||
merr := b.satSetMode(upMode, upHz)
|
||||
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
|
||||
// uplink as its frequency, and every band-dependent thing in OpsLog —
|
||||
// the log, the antenna, the amplifier — would follow the transmitter
|
||||
// onto the wrong band.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
applog.Printf("icom: could not return to the main band: %v", err)
|
||||
}
|
||||
if uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
if merr != nil {
|
||||
return merr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// satSetMode sets the mode of whichever band is currently selected. An empty
|
||||
// mode leaves it alone — a linear transponder is worked in one mode for a whole
|
||||
// pass, and re-sending it every second is traffic for nothing.
|
||||
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return nil
|
||||
}
|
||||
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
|
||||
// is wrong here twice over: the dial may still be on the other band, and on
|
||||
// satellites USB is the convention on both sides whatever the frequency.
|
||||
code, data, err := b.modeCode(satSideband(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.setModeBytes(mode, code, data)
|
||||
}
|
||||
|
||||
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
|
||||
// and the downlink, including the parts of a linear transponder that fall in
|
||||
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
|
||||
// 29 MHz among them — are still USB by convention, so there is no exception to
|
||||
// make.
|
||||
func satSideband(mode string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
|
||||
return "USB"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// SatReceiveHz is where the receiver is now.
|
||||
func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
||||
if b.satNative {
|
||||
// The selected band is MAIN — see TuneSatellite, which always returns to
|
||||
// it — so the ordinary frequency read is the downlink.
|
||||
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||
applog.Printf("icom: sat readback could not select main: %v", err)
|
||||
}
|
||||
}
|
||||
return b.readFreq()
|
||||
}
|
||||
|
||||
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
||||
//
|
||||
// The downlink is set, because that is what the operator is listening to. The
|
||||
// uplink is set through split only when it is close enough to be on the same
|
||||
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
|
||||
// out of reach rather than quietly skipped.
|
||||
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
|
||||
if err := b.SetFrequency(downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if upHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
// One megahertz apart is the working definition of "the same band" here: it
|
||||
// covers a transponder's own passband and any sensible transverter pairing,
|
||||
// and excludes every real cross-band satellite (145 / 435 MHz).
|
||||
if abs64(upHz-downHz) > 1_000_000 {
|
||||
return ErrSatUplinkUnreachable
|
||||
}
|
||||
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.satOn {
|
||||
return nil
|
||||
}
|
||||
return b.exec(civ.CmdSplit, boolByte(true))
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -94,6 +94,13 @@ type IcomSerial struct {
|
||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||
// via ScopeData from the binding goroutine).
|
||||
dualScope bool
|
||||
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
|
||||
// which have a real satellite mode of their own. Everything else gets the
|
||||
// downlink and, where the uplink is reachable, split.
|
||||
satNative bool
|
||||
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
|
||||
// once rather than on every Doppler step.
|
||||
satOn bool
|
||||
// Set when the rig rejects the waveform-output command in both shapes: it has
|
||||
// no stream to give, and asking again on every enable is noise.
|
||||
scopeUnsupported bool
|
||||
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
|
||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
|
||||
// bands and a satellite mode that pairs them; no other Icom in this table
|
||||
// does, and asking one that does not is a rejected frame per Doppler step.
|
||||
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
|
||||
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
||||
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
||||
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
||||
|
||||
Reference in New Issue
Block a user