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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("<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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user