What internal/sat could not know: where the antenna is, which birds the operator cares about, and where the files live. Startup reads the cached elements and the frequency plan from disk and nothing else — one file and a few hundred parses, so the tab is full the moment it is opened, on a shack PC with no internet as much as on one with. Fetching is the slow, optional half and never blocks a launch; it happens on its own only when the set is stale and the operator asked for it. Elements pasted in by hand go in their own file. The feed cache is replaced wholesale on every refresh, so a freshly launched satellite — whose elements circulate on a mailing list days before any feed carries it, which is exactly the week everybody wants to hear it — would otherwise be wiped by the first automatic update. The list joins both halves and shows what is missing on either side. A bird with elements and no plan is one the operator can still track; a bird with a plan and no elements is the visible symptom of an element set that is too old. Dropping either turns a fixable configuration problem into a satellite that "does not exist". GetSatelliteTuning is the working answer, and everything that will later drive a radio is built on top of it rather than beside it, so the display and the rig can never disagree. It keeps the operator's frequency nominal and applies Doppler only on the way out: on a linear pass the station being answered stays put on the dial while both radios chase the shift. A geostationary bird is corrected by nothing at all.
286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
package sat
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// The frequency side of a satellite: what to listen on, what to transmit on,
|
|
// and how the two are tied together.
|
|
//
|
|
// The elements say where a bird is; this says what to do with the radio when it
|
|
// is there. They are separate on purpose — the elements change every few days
|
|
// and come from a feed, while a transponder plan changes when a satellite is
|
|
// commanded into another mode, which is a matter for the operator and AMSAT's
|
|
// published chart.
|
|
//
|
|
// The shipped list is a STARTING POINT, not an authority: satellites are
|
|
// switched between modes, transponders are turned off for a season, and new
|
|
// ones fly. It is copied to the data directory on first use and read from there
|
|
// afterwards, so an operator can correct a frequency without waiting for a
|
|
// release — and keep the correction across updates.
|
|
|
|
//go:embed birds.json
|
|
var shippedBirds []byte
|
|
|
|
// BirdsName is the editable copy in the data directory.
|
|
const BirdsName = "satellites.json"
|
|
|
|
// Transponder is one usable path through a satellite.
|
|
type Transponder struct {
|
|
Label string `json:"label"`
|
|
Mode string `json:"mode"` // ADIF: FM, SSB, CW, DATA
|
|
|
|
// The downlink and uplink passbands, in Hz. A single frequency (an FM
|
|
// repeater, a beacon) sets only the "lo" of each side.
|
|
DownLo int64 `json:"down_lo"`
|
|
DownHi int64 `json:"down_hi,omitempty"`
|
|
UpLo int64 `json:"up_lo,omitempty"`
|
|
UpHi int64 `json:"up_hi,omitempty"`
|
|
|
|
// Inverting: the transponder turns the passband over, so tuning UP the
|
|
// downlink means going DOWN the uplink. Getting this backwards puts the
|
|
// operator's transmission at the far end of the passband from the station
|
|
// they can hear — which is the classic first evening on a linear bird.
|
|
Inverting bool `json:"inverting,omitempty"`
|
|
|
|
// CTCSS is the subaudible tone an FM uplink needs, in Hz. Zero = none.
|
|
CTCSS float64 `json:"ctcss,omitempty"`
|
|
}
|
|
|
|
// Linear reports a transponder with a passband rather than a single channel.
|
|
func (t Transponder) Linear() bool { return t.DownHi > t.DownLo && t.UpHi > t.UpLo }
|
|
|
|
// UplinkFor is where to transmit in order to be heard at downHz on the
|
|
// downlink.
|
|
//
|
|
// On a channel (FM) the answer is the uplink frequency, whatever the operator
|
|
// is tuned to. On a linear transponder it is a position in the passband — the
|
|
// same distance in from the edge, and from the OTHER edge when the transponder
|
|
// inverts.
|
|
func (t Transponder) UplinkFor(downHz int64) int64 {
|
|
if t.UpLo <= 0 {
|
|
return 0 // receive-only: a beacon, or a downlink we have no way to answer
|
|
}
|
|
if !t.Linear() {
|
|
return t.UpLo
|
|
}
|
|
if downHz < t.DownLo {
|
|
downHz = t.DownLo
|
|
}
|
|
if downHz > t.DownHi {
|
|
downHz = t.DownHi
|
|
}
|
|
offset := downHz - t.DownLo
|
|
if t.Inverting {
|
|
return t.UpHi - offset
|
|
}
|
|
return t.UpLo + offset
|
|
}
|
|
|
|
// DownlinkFor is the inverse: where a station transmitting at upHz comes out.
|
|
// It exists for the operator who tunes the uplink first — rarer, but the split
|
|
// has to be consistent whichever end they take hold of.
|
|
func (t Transponder) DownlinkFor(upHz int64) int64 {
|
|
if !t.Linear() {
|
|
return t.DownLo
|
|
}
|
|
if upHz < t.UpLo {
|
|
upHz = t.UpLo
|
|
}
|
|
if upHz > t.UpHi {
|
|
upHz = t.UpHi
|
|
}
|
|
if t.Inverting {
|
|
return t.DownLo + (t.UpHi - upHz)
|
|
}
|
|
return t.DownLo + (upHz - t.UpLo)
|
|
}
|
|
|
|
// Centre is the middle of the downlink passband — where to park when the
|
|
// operator picks a satellite and has not yet chosen a frequency in it.
|
|
func (t Transponder) Centre() int64 {
|
|
if !t.Linear() {
|
|
return t.DownLo
|
|
}
|
|
return t.DownLo + (t.DownHi-t.DownLo)/2
|
|
}
|
|
|
|
// Bird is one satellite's frequency plan.
|
|
type Bird struct {
|
|
Name string `json:"name"`
|
|
Aliases []string `json:"aliases,omitempty"`
|
|
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
|
// QO-100 is the reason the flag exists, and it changes what the whole
|
|
// tracking side does — there is nothing to predict and nothing to follow.
|
|
Geostationary bool `json:"geostationary,omitempty"`
|
|
Transponders []Transponder `json:"transponders"`
|
|
}
|
|
|
|
// Matches reports whether a name from an element feed is this satellite.
|
|
//
|
|
// The same rules Find uses, exposed for the other direction: the caller holds a
|
|
// bird and is scanning an element set spelled by somebody else.
|
|
func (b Bird) Matches(feedName string) bool {
|
|
cands := []string{feedName}
|
|
if i := strings.IndexByte(feedName, '('); i > 0 {
|
|
cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()"))
|
|
}
|
|
names := append([]string{b.Name}, b.Aliases...)
|
|
if i := strings.IndexByte(b.Name, '('); i > 0 {
|
|
names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()"))
|
|
}
|
|
for _, n := range names {
|
|
ln := loose(n)
|
|
if ln == "" {
|
|
continue
|
|
}
|
|
for _, c := range cands {
|
|
if ln == loose(c) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Birds is the frequency plan for every satellite the station knows.
|
|
type Birds struct {
|
|
mu sync.RWMutex
|
|
list []Bird
|
|
byKey map[string]int // name and aliases, loosely normalised → index in list
|
|
}
|
|
|
|
// loose is the matching form of a satellite name: upper case, letters and
|
|
// digits only.
|
|
//
|
|
// Feeds, AMSAT and operators all spell the same bird differently — "ES'HAIL 2",
|
|
// "ESHAIL-2", "Es'hail 2" — and none of them is wrong. Comparing the letters and
|
|
// digits alone is what lets the frequency plan meet the element set without a
|
|
// dozen aliases per satellite.
|
|
func loose(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(name) {
|
|
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// LoadBirds reads the plan from the data directory, writing the shipped copy
|
|
// there first if there is none.
|
|
//
|
|
// A file the operator has broken is NOT overwritten: it is reported and the
|
|
// shipped list is used for this session, so a stray comma costs a correction
|
|
// rather than the corrections of the last two years.
|
|
func LoadBirds(dir string) (*Birds, error) {
|
|
b := &Birds{}
|
|
path := filepath.Join(dir, BirdsName)
|
|
data, err := os.ReadFile(path)
|
|
switch {
|
|
case err == nil:
|
|
if perr := b.parse(data); perr != nil {
|
|
_ = b.parse(shippedBirds)
|
|
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
|
}
|
|
return b, nil
|
|
case os.IsNotExist(err):
|
|
if perr := b.parse(shippedBirds); perr != nil {
|
|
return nil, perr
|
|
}
|
|
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
|
_ = os.WriteFile(path, shippedBirds, 0o644)
|
|
}
|
|
return b, nil
|
|
default:
|
|
_ = b.parse(shippedBirds)
|
|
return b, err
|
|
}
|
|
}
|
|
|
|
func (b *Birds) parse(data []byte) error {
|
|
var list []Bird
|
|
if err := json.Unmarshal(data, &list); err != nil {
|
|
return err
|
|
}
|
|
byKey := make(map[string]int, len(list)*3)
|
|
put := func(name string, i int) {
|
|
if k := loose(name); k != "" {
|
|
// First writer wins: a satellite's own name must never be displaced by
|
|
// another bird's alias.
|
|
if _, seen := byKey[k]; !seen {
|
|
byKey[k] = i
|
|
}
|
|
}
|
|
}
|
|
for i, bird := range list {
|
|
put(bird.Name, i)
|
|
}
|
|
for i, bird := range list {
|
|
for _, a := range bird.Aliases {
|
|
put(a, i)
|
|
}
|
|
// "RADFXSAT (FOX-1B)" is one string in the feed and two names to an
|
|
// operator; index both halves so either spelling finds the bird.
|
|
if j := strings.IndexByte(bird.Name, '('); j > 0 {
|
|
put(bird.Name[:j], i)
|
|
put(strings.Trim(bird.Name[j:], "()"), i)
|
|
}
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.list, b.byKey = list, byKey
|
|
return nil
|
|
}
|
|
|
|
// Find looks a satellite up by name or alias.
|
|
//
|
|
// Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, so the
|
|
// bracketed halves are tried on their own before giving up — that is how most
|
|
// feed names differ from the name on the chart.
|
|
func (b *Birds) Find(name string) (Bird, bool) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
try := func(s string) (Bird, bool) {
|
|
if i, ok := b.byKey[loose(s)]; ok {
|
|
return b.list[i], true
|
|
}
|
|
return Bird{}, false
|
|
}
|
|
if bird, ok := try(name); ok {
|
|
return bird, true
|
|
}
|
|
if i := strings.IndexByte(name, '('); i > 0 {
|
|
if bird, ok := try(name[:i]); ok {
|
|
return bird, true
|
|
}
|
|
if bird, ok := try(strings.Trim(name[i:], "()")); ok {
|
|
return bird, true
|
|
}
|
|
}
|
|
return Bird{}, false
|
|
}
|
|
|
|
// All lists the plan, in name order.
|
|
func (b *Birds) All() []Bird {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
out := append([]Bird(nil), b.list...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// Len is how many satellites carry a frequency plan.
|
|
func (b *Birds) Len() int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return len(b.list)
|
|
}
|