Two things the tracker cannot work without, both kept apart from the orbital maths on purpose. The elements come from Celestrak's amateur group, with PE0SAT as the fallback for the hour when Celestrak is rate-limiting a hundred trackers at once. A malformed satellite is skipped rather than fatal — a feed of two hundred birds with one bad checksum must still give the operator the other hundred and ninety-nine — and the count is returned so the app can say so. The cache is plain TLE text in the data directory, written beside and renamed, and only replaced once a feed has produced usable elements: a captive portal must not take away the set the station already had. Loading it first is what makes the satellite tab full on a shack PC with no internet. The frequency plan is separate because it changes for different reasons: elements every few days from a feed, a transponder when the satellite is commanded into another mode. The shipped list is a starting point, 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 — and a file they have broken is reported, not overwritten. UplinkFor is the part that matters on the air. On an inverting linear transponder, tuning up the downlink means going down the uplink; get it backwards and you transmit at the far end of the passband from the station you can hear, which is the classic first evening on a linear bird. Names are matched on letters and digits alone. Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, and nobody spells Es'hail the same way twice.
259 lines
7.7 KiB
Go
259 lines
7.7 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"`
|
|
}
|
|
|
|
// 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)
|
|
}
|