diff --git a/internal/sat/birds.go b/internal/sat/birds.go new file mode 100644 index 0000000..bdfafc0 --- /dev/null +++ b/internal/sat/birds.go @@ -0,0 +1,258 @@ +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) +} diff --git a/internal/sat/birds.json b/internal/sat/birds.json new file mode 100644 index 0000000..ad2ad9a --- /dev/null +++ b/internal/sat/birds.json @@ -0,0 +1,176 @@ +[ + { + "name": "ISS (ZARYA)", + "aliases": ["ISS", "ZARYA", "ARISS"], + "transponders": [ + { + "label": "FM voice repeater", + "mode": "FM", + "down_lo": 437800000, + "up_lo": 145990000, + "ctcss": 67.0 + }, + { + "label": "APRS digipeater", + "mode": "DATA", + "down_lo": 145825000, + "up_lo": 145825000 + }, + { + "label": "SSTV", + "mode": "FM", + "down_lo": 145800000 + } + ] + }, + { + "name": "SO-50", + "aliases": ["SAUDISAT 1C", "SAUDISAT 1C (SO-50)"], + "transponders": [ + { + "label": "FM voice repeater", + "mode": "FM", + "down_lo": 436795000, + "up_lo": 145850000, + "ctcss": 67.0 + } + ] + }, + { + "name": "AO-91", + "aliases": ["RADFXSAT", "FOX-1B", "RADFXSAT (FOX-1B)"], + "transponders": [ + { + "label": "FM voice repeater", + "mode": "FM", + "down_lo": 145960000, + "up_lo": 435250000, + "ctcss": 67.0 + } + ] + }, + { + "name": "IO-86", + "aliases": ["LAPAN-A2", "LAPAN-ORARI"], + "transponders": [ + { + "label": "FM voice repeater", + "mode": "FM", + "down_lo": 435880000, + "up_lo": 145880000, + "ctcss": 88.5 + } + ] + }, + { + "name": "PO-101", + "aliases": ["DIWATA-2", "DIWATA-2B"], + "transponders": [ + { + "label": "FM voice repeater (scheduled)", + "mode": "FM", + "down_lo": 145900000, + "up_lo": 437500000, + "ctcss": 141.3 + } + ] + }, + { + "name": "AO-7", + "aliases": ["AMSAT-OSCAR 7", "OSCAR 7"], + "transponders": [ + { + "label": "Mode B linear (inverting)", + "mode": "SSB", + "down_lo": 145925000, + "down_hi": 145975000, + "up_lo": 432125000, + "up_hi": 432175000, + "inverting": true + }, + { + "label": "Mode A linear", + "mode": "SSB", + "down_lo": 29400000, + "down_hi": 29500000, + "up_lo": 145850000, + "up_hi": 145950000 + } + ] + }, + { + "name": "FO-29", + "aliases": ["JAS-2", "FUJI-OSCAR 29"], + "transponders": [ + { + "label": "Linear (inverting)", + "mode": "SSB", + "down_lo": 435800000, + "down_hi": 435900000, + "up_lo": 145900000, + "up_hi": 146000000, + "inverting": true + } + ] + }, + { + "name": "AO-73", + "aliases": ["FUNCUBE-1", "FUNCUBE 1"], + "transponders": [ + { + "label": "Linear (inverting)", + "mode": "SSB", + "down_lo": 145950000, + "down_hi": 145970000, + "up_lo": 435130000, + "up_hi": 435150000, + "inverting": true + } + ] + }, + { + "name": "JO-97", + "aliases": ["JY1SAT", "JY1-SAT"], + "transponders": [ + { + "label": "Linear (inverting)", + "mode": "SSB", + "down_lo": 145855000, + "down_hi": 145875000, + "up_lo": 435100000, + "up_hi": 435120000, + "inverting": true + } + ] + }, + { + "name": "RS-44", + "aliases": ["DOSAAF-85"], + "transponders": [ + { + "label": "Linear (inverting)", + "mode": "SSB", + "down_lo": 435640000, + "down_hi": 435680000, + "up_lo": 145965000, + "up_hi": 146005000, + "inverting": true + } + ] + }, + { + "name": "QO-100", + "aliases": ["ES'HAIL 2", "ESHAIL 2", "ES'HAIL-2"], + "geostationary": true, + "transponders": [ + { + "label": "Narrowband linear", + "mode": "SSB", + "down_lo": 10489550000, + "down_hi": 10489800000, + "up_lo": 2400050000, + "up_hi": 2400300000 + } + ] + } +] diff --git a/internal/sat/birds_test.go b/internal/sat/birds_test.go new file mode 100644 index 0000000..f451d49 --- /dev/null +++ b/internal/sat/birds_test.go @@ -0,0 +1,168 @@ +package sat + +import ( + "os" + "path/filepath" + "testing" +) + +// The shipped list has to be readable and consistent — it is embedded, so a +// mistake in it is a mistake in every build. +func TestShippedBirds(t *testing.T) { + b := &Birds{} + if err := b.parse(shippedBirds); err != nil { + t.Fatalf("birds.json does not parse: %v", err) + } + if b.Len() < 5 { + t.Fatalf("only %d satellites shipped", b.Len()) + } + for _, bird := range b.All() { + if len(bird.Transponders) == 0 { + t.Errorf("%s has no transponder", bird.Name) + } + for _, tr := range bird.Transponders { + if tr.DownLo <= 0 { + t.Errorf("%s / %s: no downlink", bird.Name, tr.Label) + } + if tr.DownHi != 0 && tr.DownHi <= tr.DownLo { + t.Errorf("%s / %s: downlink passband runs backwards", bird.Name, tr.Label) + } + if tr.UpHi != 0 && tr.UpHi <= tr.UpLo { + t.Errorf("%s / %s: uplink passband runs backwards", bird.Name, tr.Label) + } + // A linear transponder whose two passbands are different widths cannot + // map one onto the other, and the split would drift across the pass. + if tr.Linear() && (tr.DownHi-tr.DownLo) != (tr.UpHi-tr.UpLo) { + t.Errorf("%s / %s: passbands are %d and %d Hz wide", + bird.Name, tr.Label, tr.DownHi-tr.DownLo, tr.UpHi-tr.UpLo) + } + } + } +} + +func TestFindByAlias(t *testing.T) { + b := &Birds{} + if err := b.parse(shippedBirds); err != nil { + t.Fatal(err) + } + // Every spelling on the left is one an operator or a feed actually uses. + for _, tc := range []struct{ query, want string }{ + {"AO-91", "AO-91"}, + {"RADFXSAT (FOX-1B)", "AO-91"}, + {"radfxsat", "AO-91"}, + {"ISS (ZARYA)", "ISS (ZARYA)"}, + {"ISS", "ISS (ZARYA)"}, + {"SAUDISAT 1C (SO-50)", "SO-50"}, + {"so 50", "SO-50"}, + {"QO-100", "QO-100"}, + {"ESHAIL-2", "QO-100"}, + {"Es'hail 2", "QO-100"}, + } { + got, ok := b.Find(tc.query) + if !ok { + t.Errorf("%q was not found", tc.query) + continue + } + if got.Name != tc.want { + t.Errorf("%q found %q, wanted %q", tc.query, got.Name, tc.want) + } + } + if _, ok := b.Find("NOAA 15"); ok { + t.Error("a weather satellite should not carry an amateur frequency plan") + } +} + +// The uplink maths is the part that matters on the air: a station worked at one +// end of an inverting transponder has to be answered at the other. +func TestUplinkFor(t *testing.T) { + inv := Transponder{ + DownLo: 435800000, DownHi: 435900000, + UpLo: 145900000, UpHi: 146000000, + Inverting: true, + } + straight := Transponder{ + DownLo: 29400000, DownHi: 29500000, + UpLo: 145850000, UpHi: 145950000, + } + fm := Transponder{DownLo: 436795000, UpLo: 145850000} + + for _, tc := range []struct { + name string + tr Transponder + down int64 + want int64 + }{ + {"inverting, bottom of the downlink", inv, 435800000, 146000000}, + {"inverting, top of the downlink", inv, 435900000, 145900000}, + {"inverting, 30 kHz up", inv, 435830000, 145970000}, + {"straight, bottom", straight, 29400000, 145850000}, + {"straight, 25 kHz up", straight, 29425000, 145875000}, + {"FM channel ignores the tuned downlink", fm, 436798000, 145850000}, + {"below the passband is clamped", inv, 435700000, 146000000}, + {"above the passband is clamped", inv, 436000000, 145900000}, + } { + if got := tc.tr.UplinkFor(tc.down); got != tc.want { + t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want) + } + } + + // Receive-only: a beacon has nothing to answer on. + if got := (Transponder{DownLo: 145800000}).UplinkFor(145800000); got != 0 { + t.Errorf("a receive-only transponder gave an uplink of %d", got) + } +} + +// Whichever end the operator takes hold of, the pair has to agree. +func TestDownlinkForRoundTrip(t *testing.T) { + for _, tr := range []Transponder{ + {DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true}, + {DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000}, + } { + for _, down := range []int64{tr.DownLo, tr.Centre(), tr.DownHi} { + if got := tr.DownlinkFor(tr.UplinkFor(down)); got != down { + t.Errorf("inverting=%v: %d → uplink → %d", tr.Inverting, down, got) + } + } + } +} + +func TestLoadBirdsWritesTheEditableCopy(t *testing.T) { + dir := t.TempDir() + b, err := LoadBirds(dir) + if err != nil { + t.Fatal(err) + } + shipped := b.Len() + path := filepath.Join(dir, BirdsName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("the editable copy was not written: %v", err) + } + + // An operator's own list is what gets used from then on. + mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]` + if err := os.WriteFile(path, []byte(mine), 0o644); err != nil { + t.Fatal(err) + } + b, err = LoadBirds(dir) + if err != nil { + t.Fatal(err) + } + if b.Len() != 1 { + t.Fatalf("the operator's list was not used: %d satellites", b.Len()) + } + + // And a broken one falls back without destroying what they wrote. + if err := os.WriteFile(path, []byte("[{oops"), 0o644); err != nil { + t.Fatal(err) + } + b, err = LoadBirds(dir) + if err == nil { + t.Error("a broken list was accepted silently") + } + if b.Len() != shipped { + t.Errorf("the shipped list did not take over: %d satellites", b.Len()) + } + if data, _ := os.ReadFile(path); string(data) != "[{oops" { + t.Error("the operator's broken file was overwritten") + } +} diff --git a/internal/sat/tle.go b/internal/sat/tle.go new file mode 100644 index 0000000..6b0782a --- /dev/null +++ b/internal/sat/tle.go @@ -0,0 +1,230 @@ +package sat + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Where the elements come from, and where they are kept. +// +// Celestrak's amateur group is the list every tracker in this hobby uses; the +// PE0SAT mirror is there for the day Celestrak is down or rate-limiting, which +// it does when a hundred trackers all wake up at the top of the hour. +const ( + FeedCelestrak = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle" + FeedPE0SAT = "http://tle.pe0sat.nl/kepler/amateur.txt" + // CacheName is the file kept in the data directory. Plain TLE text, so an + // operator can open it, read it, and paste a line into a tracker that is not + // this one. + CacheName = "satellites.tle" + // StaleAfter is when elements stop being worth trusting silently. SGP4 drifts + // a few hundred metres a day for a low orbit, which is nothing for a pass + // prediction and everything for a rotator at high elevation — so the age is + // SHOWN rather than enforced, and this is only the point at which OpsLog + // offers to fetch again. + StaleAfter = 3 * 24 * time.Hour +) + +// ParseTLESet reads a whole feed or cache file: three lines per satellite — +// name, then the two element lines — or two where the name is absent. +// +// A malformed satellite is SKIPPED, not 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 of what was dropped is returned so the app can say +// so instead of quietly holding a shorter list. +func ParseTLESet(r io.Reader) (els []Element, skipped int, err error) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + var pending []string + flush := func() { + defer func() { pending = nil }() + var name, l1, l2 string + switch len(pending) { + case 3: + name, l1, l2 = pending[0], pending[1], pending[2] + case 2: + l1, l2 = pending[0], pending[1] + default: + if len(pending) > 0 { + skipped++ + } + return + } + e, perr := ParseElement(name, l1, l2) + if perr != nil { + skipped++ + return + } + els = append(els, e) + } + for sc.Scan() { + line := strings.TrimRight(sc.Text(), " \t\r") + if strings.TrimSpace(line) == "" { + flush() + continue + } + // A "1 " or "2 " line is an element line; anything else starts a new + // satellite. That rule reads both the three-line and the two-line form + // without the file having to say which it is. + isElement := len(line) > 2 && (line[0] == '1' || line[0] == '2') && line[1] == ' ' + if !isElement && len(pending) > 0 { + flush() + } + pending = append(pending, line) + if len(pending) == 3 || (len(pending) == 2 && strings.HasPrefix(pending[0], "1 ")) { + flush() + } + } + flush() + if err := sc.Err(); err != nil { + return els, skipped, fmt.Errorf("sat: reading the element set: %w", err) + } + if len(els) == 0 { + return nil, skipped, fmt.Errorf("sat: no usable elements in that set (%d entries refused)", skipped) + } + return els, skipped, nil +} + +// Fetcher loads element sets from the feeds and keeps a copy on disk. +type Fetcher struct { + Dir string // where the cache file lives — the app's data directory + Feeds []string // tried in order; the first that answers wins + Timeout time.Duration // per feed + Logf func(string, ...any) +} + +// NewFetcher builds one with the usual feeds. +func NewFetcher(dir string) *Fetcher { + return &Fetcher{ + Dir: dir, + Feeds: []string{FeedCelestrak, FeedPE0SAT}, + Timeout: 20 * time.Second, + Logf: func(string, ...any) {}, + } +} + +func (f *Fetcher) cachePath() string { return filepath.Join(f.Dir, CacheName) } + +// LoadCache reads the elements kept from last time, with the file's own +// modification time as the fetch time. +// +// This is what makes the first screen after a launch a full one: an operator who +// opens the satellite tab on a train, or on a shack PC with no internet, still +// gets last week's elements — which are perfectly good for knowing what passes +// tonight — instead of an empty list and a spinner. +func (f *Fetcher) LoadCache() ([]Element, time.Time, error) { + p := f.cachePath() + fh, err := os.Open(p) + if err != nil { + return nil, time.Time{}, err + } + defer fh.Close() + els, skipped, err := ParseTLESet(fh) + if err != nil { + return nil, time.Time{}, err + } + at := time.Time{} + if st, serr := os.Stat(p); serr == nil { + at = st.ModTime() + } + if skipped > 0 { + f.Logf("sat: %d cached entries were unusable and were skipped", skipped) + } + return els, at, nil +} + +// Fetch downloads a fresh set and writes the cache. +// +// The cache is only replaced once a feed has produced usable elements: a feed +// that answers with an error page, a captive-portal login or an empty file must +// not take away the set the station already had. +func (f *Fetcher) Fetch(ctx context.Context) ([]Element, error) { + var lastErr error + for _, url := range f.Feeds { + els, body, err := f.fetchOne(ctx, url) + if err != nil { + f.Logf("sat: %s: %v", shortHost(url), err) + lastErr = err + continue + } + if err := f.writeCache(body); err != nil { + // Not fatal: the elements are in hand and the station can track + // tonight. Only the next cold start loses by it, and it says so. + f.Logf("sat: could not write the element cache: %v", err) + } + f.Logf("sat: %d satellites from %s", len(els), shortHost(url)) + return els, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no feed configured") + } + return nil, fmt.Errorf("sat: could not fetch the element set: %w", lastErr) +} + +func (f *Fetcher) fetchOne(ctx context.Context, url string) ([]Element, []byte, error) { + to := f.Timeout + if to <= 0 { + to = 20 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, to) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, nil, err + } + // Named, because Celestrak asks that clients identify themselves and answers + // an anonymous flood with a rate limit. + req.Header.Set("User-Agent", "OpsLog satellite tracker") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("HTTP %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, nil, err + } + els, skipped, err := ParseTLESet(strings.NewReader(string(body))) + if err != nil { + return nil, nil, err + } + if skipped > 0 { + f.Logf("sat: %s: %d entries were unusable and were skipped", shortHost(url), skipped) + } + return els, body, nil +} + +func (f *Fetcher) writeCache(body []byte) error { + if strings.TrimSpace(f.Dir) == "" { + return fmt.Errorf("no data directory") + } + if err := os.MkdirAll(f.Dir, 0o755); err != nil { + return err + } + // Written beside and renamed: a power cut mid-write must not leave a + // half-file that parses as twenty satellites instead of two hundred. + tmp := f.cachePath() + ".tmp" + if err := os.WriteFile(tmp, body, 0o644); err != nil { + return err + } + return os.Rename(tmp, f.cachePath()) +} + +// shortHost is a feed's host, for a log line that fits. +func shortHost(url string) string { + s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://") + if i := strings.IndexAny(s, "/?"); i > 0 { + s = s[:i] + } + return s +} diff --git a/internal/sat/tle_test.go b/internal/sat/tle_test.go new file mode 100644 index 0000000..cc3e5b4 --- /dev/null +++ b/internal/sat/tle_test.go @@ -0,0 +1,87 @@ +package sat + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Both shapes of the same file: three lines per satellite, and the two-line +// form some feeds still serve. A reader that only understood one of them would +// come back empty from a mirror and look like a network fault. +func TestParseTLESetReadsBothShapes(t *testing.T) { + three := issName + "\n" + issLine1 + "\n" + issLine2 + "\n" + els, skipped, err := ParseTLESet(strings.NewReader(three)) + if err != nil || len(els) != 1 || skipped != 0 { + t.Fatalf("three-line: %d sats, %d skipped, err %v", len(els), skipped, err) + } + if els[0].Name != issName { + t.Errorf("name %q", els[0].Name) + } + + two := issLine1 + "\n" + issLine2 + "\n" + els, _, err = ParseTLESet(strings.NewReader(two)) + if err != nil || len(els) != 1 { + t.Fatalf("two-line: %d sats, err %v", len(els), err) + } + if els[0].NORAD != 25544 { + t.Errorf("a nameless entry lost its identity: %+v", els[0]) + } +} + +// One bad satellite must not cost the operator the other hundred and +// ninety-nine — but the count of what was dropped has to come back, or a +// silently shorter list reads as a complete one. +func TestParseTLESetSkipsWhatItCannotRead(t *testing.T) { + feed := strings.Join([]string{ + "JUNK SATELLITE", + "1 99999U 00000A 24298.00000000 .00000000 00000+0 00000+0 0 0000", // bad checksum + "2 99999 00.0000 000.0000 0000000 000.0000 000.0000 00.00000000000000", + "", + issName, issLine1, issLine2, + }, "\n") + els, skipped, err := ParseTLESet(strings.NewReader(feed)) + if err != nil { + t.Fatalf("the whole feed was refused for one bad entry: %v", err) + } + if len(els) != 1 || els[0].Name != issName { + t.Errorf("kept %d satellites: %+v", len(els), els) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1 — a silently shorter list reads as a complete one", skipped) + } + // Nothing usable at all IS an error: an error page or a captive-portal login + // parses as zero satellites, and that must never replace a good set. + if _, _, err := ParseTLESet(strings.NewReader("login required")); err == nil { + t.Error("an HTML error page was accepted as an element set") + } +} + +// The cache is what makes the first screen after a launch a full one — on a +// train, or on a shack PC with no internet. +func TestCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + f := NewFetcher(dir) + f.Logf = func(string, ...any) {} + + body := issName + "\n" + issLine1 + "\n" + issLine2 + "\n" + if err := f.writeCache([]byte(body)); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, CacheName)); err != nil { + t.Fatalf("the cache file is not where an operator would look for it: %v", err) + } + // And no leftovers: the temp file is renamed, not copied. + if _, err := os.Stat(filepath.Join(dir, CacheName+".tmp")); err == nil { + t.Error("the half-written file was left behind") + } + + els, at, err := f.LoadCache() + if err != nil || len(els) != 1 { + t.Fatalf("load: %d sats, err %v", len(els), err) + } + if at.IsZero() { + t.Error("the cache has no age, so nothing can say whether to trust it") + } +}