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 }