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"` } // 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) }