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 } // A bare "SSB" still means upper sideband 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. An explicit USB or LSB from the caller is left // alone: on an INVERTING transponder the two sides are different sidebands, // and only the caller knows which way round this bird runs. 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 } // SatAntennas selects the antenna each satellite slice uses. // // The two slices are on two different bands — a V/U bird receives on 70 cm and // transmits on 2 m, a U/V one does the reverse — so they cannot share one // antenna setting. On a station with transverters they are not even the same // port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF // antenna hears nothing at all. // // Per SLICE, not through sendSlice, which addresses whichever slice is active. // During a pass the active slice is the downlink, so the uplink's antenna would // never have been set. // // Empty strings are left alone: an operator who has configured 2 m and not // 70 cm should keep whatever the radio already had on the other side rather // than have it cleared. func (f *Flex) SatAntennas(rxAnt, txAnt string) error { f.mu.Lock() rx, tx := f.satRX, f.satTX connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } // The downlink slice is the one being listened to, so it takes the receive // antenna; the uplink slice is the one keyed, so it takes the transmit one. if rx >= 0 && strings.TrimSpace(rxAnt) != "" { f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt)) applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt) } if tx >= 0 && strings.TrimSpace(txAnt) != "" { f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt)) // A transmit slice also has to HEAR its own band on some radios, and a // transverter port is the only thing connected to it. Setting the // receive antenna to match costs nothing when it is already right. f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt)) applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt) } return nil } // SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it // off. // // On the UPLINK slice, because that is the one that keys: a tone is something // transmitted, and the repeater on the satellite will not open without it. This // is the whole difference between an operator hearing a pass and hearing // nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it // is exactly the setting that cannot be made by hand mid-pass. func (f *Flex) SatTone(hz float64) error { f.mu.Lock() tx := f.satTX connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } if tx < 0 { return nil // the slice has not come back yet; the next arming will set it } if hz <= 0 { f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx)) applog.Printf("flex: satellite uplink tone off") return nil } // Value before mode: a radio that is told CTCSS_TX while still holding the // previous tone transmits the previous tone for as long as it takes the // second command to arrive. f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz)) f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx)) applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx) return nil }