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.
88 lines
3.0 KiB
Go
88 lines
3.0 KiB
Go
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("<html>login required</html>")); 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")
|
|
}
|
|
}
|