feat(sat): element feeds and the frequency plan
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user