// Command satgen refreshes internal/sat/birds.json from the public databases. // // A one-shot generator, run by hand, NOT part of the build — the same // arrangement as cmd/cntygen. Satellites are switched between modes and new // ones fly, and the shipped frequency plan should be re-cut every few releases // rather than typed from memory. // // go run ./cmd/satgen // // It reads three sources and joins them on the NORAD catalog number: // // - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites // OpsLog can get elements for. There is no point shipping a frequency plan // for a bird whose TLE never arrives. // - SatNOGS DB, for the transmitters. It is the maintained, machine-readable // transponder database; AMSAT's chart is authoritative but is a web page. // // It NEVER destroys a curated entry. The hand-written plans carry things // SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100 // passband as operators actually describe it — so an existing bird is kept // verbatim and only has its NORAD number filled in. New satellites are appended. // Read the diff before committing it: this is a starting point for an operator, // and a wrong uplink is worse than a missing one. package main import ( "encoding/json" "fmt" "io" "net/http" "os" "regexp" "sort" "strconv" "strings" "time" "hamlog/internal/sat" ) const ( birdsPath = "internal/sat/birds.json" satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json" satnogsSats = "https://db.satnogs.org/api/satellites/?format=json" ) // satellite is the subset of a SatNOGS satellite record we use. Its whole // purpose is the decay date: a frequency plan for a spacecraft that burned up // two years ago is a row in the operator's list that will never do anything. type satellite struct { NORAD int `json:"norad_cat_id"` Name string `json:"name"` Names string `json:"names"` // other designations, comma or newline separated Status string `json:"status"` Decayed string `json:"decayed"` } // tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go). var tleFeeds = []string{ "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle", "http://tle.pe0sat.nl/kepler/amateur.txt", } // transmitter is the subset of a SatNOGS DB record we use. type transmitter struct { Description string `json:"description"` Alive bool `json:"alive"` Type string `json:"type"` // Transmitter | Transponder | Transceiver UplinkLow int64 `json:"uplink_low"` UplinkHigh int64 `json:"uplink_high"` DownlinkLow int64 `json:"downlink_low"` DownlinkHigh int64 `json:"downlink_high"` Mode string `json:"mode"` Invert bool `json:"invert"` NORAD int `json:"norad_cat_id"` Status string `json:"status"` } func main() { feed, err := loadFeeds() if err != nil { die(err) } fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds)) txs, err := loadTransmitters() if err != nil { die(err) } fmt.Printf("satnogs: %d transmitters\n", len(txs)) cat, err := loadSatellites() if err != nil { die(err) } fmt.Printf("satnogs: %d catalogued satellites\n", len(cat)) birds, err := loadBirds() if err != nil { die(err) } fmt.Printf("existing plan: %d satellites\n", len(birds)) // 0. Drop what has come down. SatNOGS carries the re-entry date, so this is // a documented fact rather than a judgement about which of the missing // satellites are missing for good — the first-generation Tevel // constellation alone had left eight rows that could never do anything. kept := birds[:0] for _, b := range birds { if s, ok := decayed(b, cat); ok { fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z")) continue } kept = append(kept, b) } birds = kept // 1. Give every curated entry its catalog number, so the join stops // depending on how three different parties spell the same satellite. covered := map[int]bool{} for i := range birds { if birds[i].NORAD == 0 { if n, ok := noradFor(birds[i], feed); ok { birds[i].NORAD = n fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name) } else { fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name) } } if birds[i].NORAD != 0 { covered[birds[i].NORAD] = true } } // 2. Append the satellites we can track and have a usable uplink for. byNORAD := map[int][]transmitter{} for _, t := range txs { if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] { continue } byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t) } added := 0 for n, list := range byNORAD { b := sat.Bird{Name: displayName(feed[n]), NORAD: n} if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name { b.Aliases = []string{alias} } for _, t := range list { b.Transponders = append(b.Transponders, toTransponder(t)) } sort.Slice(b.Transponders, func(i, j int) bool { return b.Transponders[i].DownLo < b.Transponders[j].DownLo }) birds = append(birds, b) added++ fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders)) } sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name }) out, err := json.MarshalIndent(birds, "", " ") if err != nil { die(err) } if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil { die(err) } fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added) } // usable decides whether a SatNOGS transmitter is something an operator can // work through. // // The database holds every emission a satellite makes, and most of them are not // a contact: a telemetry beacon with a command uplink is listed exactly like an // FM repeater, and shipping the command channel as a transponder would invite // somebody to transmit on it. So both ends must exist, and anything that // describes itself as telemetry or control is refused unless it also calls // itself a repeater, a transponder or a digipeater. func usable(t transmitter) bool { if !t.Alive || t.Status != "active" { return false } if t.UplinkLow <= 0 || t.DownlinkLow <= 0 { return false } d := strings.ToLower(t.Description) isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") || strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") || strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar") if isWorkable { return true } for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} { if strings.Contains(d, bad) { return false } } // An ANALOG emission with both ends is a contact by construction: nobody // puts an FM or SSB uplink on a satellite for housekeeping. This is what // catches the plainly-described repeaters — AO-27 says only "Mode V/U FM", // and rejecting it for not using the word "repeater" would have dropped one // of the best-known FM birds there is. if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" { return true } // A digital emission has to say what it is. A GMSK uplink is a command // channel far more often than it is a digipeater, and shipping the wrong one // invites an operator to transmit on a control frequency. return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow) } // ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not // optional: an FM uplink without the right tone opens nothing at all. var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`) func toTransponder(t transmitter) sat.Transponder { tp := sat.Transponder{ Label: cleanLabel(t.Description), Mode: adifMode(t.Mode), DownLo: t.DownlinkLow, DownHi: t.DownlinkHigh, UpLo: t.UplinkLow, UpHi: t.UplinkHigh, Inverting: t.Invert, } // A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz // passband; Transponder.Linear() must not be fooled into interpolating. if tp.DownHi <= tp.DownLo { tp.DownHi = 0 } if tp.UpHi <= tp.UpLo { tp.UpHi = 0 } // Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM // channels too, where it means nothing — the code ignores it there, but a // data file that says an FM repeater inverts is a data file that will // mislead the next person to read it. if tp.DownHi == 0 || tp.UpHi == 0 { tp.Inverting = false } if m := ctcssRe.FindStringSubmatch(t.Description); m != nil { v := m[1] if v == "" { v = m[2] } if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 { tp.CTCSS = f } } return tp } // adifMode maps SatNOGS' modulation names onto the four modes a log knows. func adifMode(m string) string { switch u := strings.ToUpper(strings.TrimSpace(m)); { case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM": return "FM" case u == "USB", u == "LSB", u == "SSB": return "SSB" case u == "CW": return "CW" default: return "DATA" } } func cleanLabel(s string) string { s = strings.TrimSpace(s) if s == "" { return "Transponder" } return s } // displayName prefers the OSCAR designation an operator says out loud. // "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog. func displayName(feedName string) string { s := strings.TrimSpace(feedName) if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") { inner := strings.TrimSpace(s[i+1 : len(s)-1]) if oscarRe.MatchString(inner) { return inner } } // "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name. if i := strings.Index(s, " & "); i > 0 { return strings.TrimSpace(s[:i]) } return s } var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`) // noradFor finds a curated entry's catalog number by the name matching the // package already does. // // Deterministic on purpose. One satellite can hold TWO catalog entries — a // deployment catalogued before the objects were told apart, GreenCube being // 53106 and 53109 in the two feeds — and iterating the map picked a different // one each run, so the generated file changed for no reason and the diff was // unreadable. Candidates are therefore scored and tied on the lower number: // a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for // IO-117) beats one that only matches through an alias. func noradFor(b sat.Bird, feed map[int]string) (int, bool) { nums := make([]int, 0, len(feed)) for n := range feed { nums = append(nums, n) } sort.Ints(nums) best, bestScore := 0, -1 for _, n := range nums { name := feed[n] if !b.Matches(name) { continue } score := 0 if strings.EqualFold(displayName(name), b.Name) { score = 2 } else if strings.EqualFold(strings.TrimSpace(name), b.Name) { score = 1 } if score > bestScore { best, bestScore = n, score } } return best, best != 0 } func loadFeeds() (map[int]string, error) { out := map[int]string{} for _, url := range tleFeeds { body, err := get(url) if err != nil { fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err) continue } lines := []string{} for _, l := range strings.Split(string(body), "\n") { if s := strings.TrimSpace(l); s != "" { lines = append(lines, s) } } for i := 0; i+2 < len(lines); i += 3 { if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 { continue } n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7])) if err != nil || n <= 0 { continue } // First feed wins: Celestrak's spelling is the one the operator sees. if _, had := out[n]; !had { out[n] = lines[i] } } } if len(out) == 0 { return nil, fmt.Errorf("no elements from any feed") } return out, nil } // decayed reports whether this bird's spacecraft has re-entered, matching on // the catalog number when we have one and on the designations SatNOGS lists // otherwise — "NAYIF-1" carries "EO-88" only in its alternative names. func decayed(b sat.Bird, cat []satellite) (satellite, bool) { for _, s := range cat { if s.Status != "re-entered" && s.Decayed == "" { continue } if b.NORAD != 0 { if s.NORAD == b.NORAD { return s, true } continue } names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name) for _, n := range names { if strings.TrimSpace(n) == "" { continue } if b.Matches(strings.TrimSpace(n)) { return s, true } } } return satellite{}, false } func loadSatellites() ([]satellite, error) { body, err := get(satnogsSats) if err != nil { return nil, err } var out []satellite if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("satnogs satellites: %w", err) } return out, nil } func loadTransmitters() ([]transmitter, error) { body, err := get(satnogsTX) if err != nil { return nil, err } var out []transmitter if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("satnogs: %w", err) } return out, nil } func loadBirds() ([]sat.Bird, error) { b, err := os.ReadFile(birdsPath) if err != nil { return nil, err } var out []sat.Bird if err := json.Unmarshal(b, &out); err != nil { return nil, fmt.Errorf("%s: %w", birdsPath, err) } return out, nil } func get(url string) ([]byte, error) { c := &http.Client{Timeout: 90 * time.Second} resp, err := c.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: %s", url, resp.Status) } return io.ReadAll(io.LimitReader(resp.Body, 32<<20)) } func die(err error) { fmt.Fprintln(os.Stderr, "satgen:", err) os.Exit(1) }