package sat import ( "bytes" _ "embed" "encoding/json" "fmt" "log" "os" "path/filepath" "sort" "strconv" "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"` // NORAD is the catalog number, and the only exact way to find this // satellite's elements: the feed, AMSAT and the operator all spell the NAME // differently, while the number is carried inside the TLE itself. Aliases // remain for the entries that predate it and for a hand-written plan. NORAD int `json:"norad,omitempty"` 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"` } // Matches reports whether a name from an element feed is this satellite. // // The same rules Find uses, exposed for the other direction: the caller holds a // bird and is scanning an element set spelled by somebody else. func (b Bird) Matches(feedName string) bool { cands := []string{feedName} if i := strings.IndexByte(feedName, '('); i > 0 { cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()")) } names := append([]string{b.Name}, b.Aliases...) if i := strings.IndexByte(b.Name, '('); i > 0 { names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()")) } for _, n := range names { ln := loose(n) if ln == "" { continue } for _, c := range cands { if ln == loose(c) { return true } } } return false } // 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) } // New satellites AND corrections reach an existing station. // // The operator's copy is written on the first run and was then theirs // for ever, which broke both ways: a release that added nine Tevel-2 // satellites reached nobody who had opened the tab, and a frequency we // had shipped WRONG could never be mended — LilacSat-2 went out with an // APRS digipeater and no FM transponder, and the wrong data had become // the operator's own file. // // So the merge adds what is missing, replaces what they never touched, // and leaves alone what they edited. See mergeShipped for how the three // are told apart. added, updated, kept, replaced := b.mergeShipped(dir) if added > 0 || updated > 0 { // A one-time copy before the first run that can overwrite an entry // we have no baseline for. Cheap insurance on a file an operator may // have spent an evening correcting. if len(replaced) > 0 { if err := os.WriteFile(path+".bak", data, 0o644); err == nil { log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName) } } if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil { _ = os.WriteFile(path, append(out, '\n'), 0o644) } log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated) } if len(replaced) > 0 { log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+ "If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName) } if len(kept) > 0 { // Named, not silent: an operator who corrected a frequency should be // able to see that OpsLog noticed and stood down. log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead", strings.Join(kept, ", "), BirdsName) } writeBaseline(dir) 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) writeBaseline(dir) } 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) } // BaselineName records the shipped plan as it was last handed to this station. // // It exists so a CORRECTION can reach an operator who already has the file. // Without it the merge could only add satellites, never mend one: LilacSat-2 // shipped with an APRS digipeater and no FM transponder, and every station that // had already opened the satellite tab was stuck with it for ever — the wrong // frequency was the operator's file now, and their file was sacred. const BaselineName = "satellites.shipped.json" // mergeShipped brings the shipped plan into the operator's list. // // Three cases, and the middle one is the point: // // - A satellite they do not have is ADDED. That is how new birds arrive. // - A satellite they have, UNCHANGED from the plan they were given, is // REPLACED by the current one. They never edited it, so it is not theirs to // keep — it is our data, and ours was wrong. // - A satellite they have EDITED is left exactly as it is, and said so in the // log. A frequency somebody corrected by hand outranks anything shipped: // they were on the air and we were not. // // "Unchanged" is decided against the baseline, so the comparison is with the // plan THEY were given rather than with whatever ships today. Their edits // therefore survive every future release, not just the next one. func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) { var list []Bird if err := json.Unmarshal(shippedBirds, &list); err != nil { return 0, 0, nil, nil } baseline := readBaseline(dir) b.mu.Lock() byNORAD := map[int]int{} // catalog number → index in b.list byName := map[string]int{} for i, x := range b.list { if x.NORAD != 0 { byNORAD[x.NORAD] = i } for _, n := range append([]string{x.Name}, x.Aliases...) { if k := loose(n); k != "" { if _, seen := byName[k]; !seen { byName[k] = i } } } } find := func(c Bird) int { if c.NORAD != 0 { if i, ok := byNORAD[c.NORAD]; ok { return i } } for _, n := range append([]string{c.Name}, c.Aliases...) { if i, ok := byName[loose(n)]; ok { return i } } return -1 } for _, cand := range list { i := find(cand) if i < 0 { b.list = append(b.list, cand) added++ continue } if sameBird(b.list[i], cand) { continue // already current } was, hadBaseline := baseline[birdKey(cand)] switch { case !hadBaseline: // FIRST run after baselines existed, and there is no record of what // this station was given — so an edit of theirs and a mistake of // ours are indistinguishable here. // // The shipped plan wins, ONCE, and the whole file is backed up // first. Standing down instead would have been the safe-looking // choice and the wrong one: the baseline written at the end of this // run would then record their entry as "edited" and freeze a // frequency we know to be wrong for the life of the install. A // backup and a log line are recoverable; that is not. replaced = append(replaced, b.list[i].Name) b.list[i] = cand updated++ case sameBird(b.list[i], was): b.list[i] = cand updated++ default: kept = append(kept, b.list[i].Name) } } b.reindexLocked() b.mu.Unlock() return added, updated, kept, replaced } // birdKey identifies a satellite across versions: the catalog number when there // is one, the loose name otherwise. func birdKey(x Bird) string { if x.NORAD != 0 { return "n:" + strconv.Itoa(x.NORAD) } return "s:" + loose(x.Name) } // sameBird compares two plans for one satellite by VALUE — the frequencies, the // modes, the tone, the labels. Field by field through JSON rather than one // comparison per field, so a transponder field added later cannot silently drop // out of the test and start reporting equal plans as different. func sameBird(a, c Bird) bool { ja, ea := json.Marshal(a) jc, ec := json.Marshal(c) if ea != nil || ec != nil { return false } return bytes.Equal(ja, jc) } // readBaseline loads the shipped plan this station was last given. func readBaseline(dir string) map[string]Bird { out := map[string]Bird{} raw, err := os.ReadFile(filepath.Join(dir, BaselineName)) if err != nil { return out } var list []Bird if json.Unmarshal(raw, &list) != nil { return out } for _, x := range list { out[birdKey(x)] = x } return out } // writeBaseline records what was shipped, so the NEXT release can tell an // operator's correction from one of ours. func writeBaseline(dir string) { if err := os.MkdirAll(dir, 0o755); err != nil { return } _ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644) } // reindexLocked rebuilds the name lookup after the list has changed. func (b *Birds) reindexLocked() { byKey := make(map[string]int, len(b.list)*3) put := func(name string, i int) { if k := loose(name); k != "" { if _, seen := byKey[k]; !seen { byKey[k] = i } } } for i, bird := range b.list { put(bird.Name, i) } for i, bird := range b.list { for _, a := range bird.Aliases { put(a, i) } } b.byKey = byKey }