package main // Satellites — the wiring around internal/sat. // // The package knows orbits and frequency plans; this file is what the station // knows: where the antenna is, which birds the operator cares about, and where // the elements are kept. Nothing here talks to a radio or a rotator yet — that // is the next layer, and it is deliberately built on top of GetSatelliteTuning // rather than beside it, so what the operator reads on screen and what gets // sent to the rig can never disagree. import ( "context" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "hamlog/internal/applog" "hamlog/internal/sat" ) const ( keySatFavorites = "sat.favorites" // comma-separated satellite names keySatMinEl = "sat.min_el" // degrees; passes lower than this are not listed keySatWindowH = "sat.window_h" // hours of pass predictions keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale keySatGrid = "sat.grid" // locator override ("" = the station's own) keySatAltM = "sat.alt_m" // antenna height above sea level, metres ) // customTLEName holds elements the operator pasted in by hand. // // Kept apart from the feed cache because the cache is REPLACED wholesale on // every refresh: a freshly launched satellite, whose elements arrive on a // mailing list days before any feed carries it, would be wiped by the first // automatic update — which is precisely the week everybody wants to hear it. const customTLEName = "satellites.custom.tle" // SatSettings is the station's side of satellite work. type SatSettings struct { Favorites []string `json:"favorites"` MinEl int `json:"min_el"` WindowH int `json:"window_h"` AutoTLE bool `json:"auto_tle"` Grid string `json:"grid"` AltM int `json:"alt_m"` } // SatTransponder is one path through a satellite, as the UI needs it. type SatTransponder struct { Label string `json:"label"` Mode string `json:"mode"` DownLo int64 `json:"down_lo"` DownHi int64 `json:"down_hi"` UpLo int64 `json:"up_lo"` UpHi int64 `json:"up_hi"` Inverting bool `json:"inverting"` CTCSS float64 `json:"ctcss"` Linear bool `json:"linear"` } // SatBird is a satellite as the operator sees it: the frequency plan joined to // whatever elements we hold for it. type SatBird struct { Name string `json:"name"` NORAD int `json:"norad"` Geostationary bool `json:"geostationary"` Favorite bool `json:"favorite"` HasElements bool `json:"has_elements"` ElementName string `json:"element_name"` // the feed's spelling, when it differs EpochAgeH float64 `json:"epoch_age_h"` Transponders []SatTransponder `json:"transponders"` } // SatTLEInfo describes the element set the station is working from. type SatTLEInfo struct { Count int `json:"count"` FetchedAt time.Time `json:"fetched_at"` AgeH float64 `json:"age_h"` Stale bool `json:"stale"` Custom int `json:"custom"` // hand-entered satellites among the count } // SatTuning is where to listen and where to transmit, right now. // // Both the nominal and the corrected pair are returned on purpose: the nominal // is what goes in the log (see the ADIF note on SAT_NAME) and the corrected is // what goes to the radio. An operator staring at a display that shows only one // of them cannot tell a Doppler correction from a mistuned transponder. type SatTuning struct { Name string `json:"name"` Transponder string `json:"transponder"` Mode string `json:"mode"` NominalDown int64 `json:"nominal_down"` NominalUp int64 `json:"nominal_up"` DownHz int64 `json:"down_hz"` UpHz int64 `json:"up_hz"` CTCSS float64 `json:"ctcss"` Inverting bool `json:"inverting"` Az float64 `json:"az"` El float64 `json:"el"` RangeKm float64 `json:"range_km"` RangeRate float64 `json:"range_rate"` Visible bool `json:"visible"` At time.Time `json:"at"` } // ── Lifecycle ─────────────────────────────────────────────────────────────── // startSatellites loads what is already on disk and, only if asked, goes to the // network. // // Cache first and synchronously: it is one file and a few hundred parses, and // it means the satellite tab is populated the instant it is opened, on a shack // PC with no internet as much as on one with. The fetch is the slow, optional // half and never blocks a launch. func (a *App) startSatellites() { dir := a.dataDir birds, err := sat.LoadBirds(dir) if err != nil { // LoadBirds always returns a usable list; the error says the operator's // own file was refused, which they need to be told about. applog.Printf("sat: %v", err) } store := sat.NewStore() fetch := sat.NewFetcher(dir) fetch.Logf = applog.Printf if els, at, err := fetch.LoadCache(); err == nil { store.Replace(els, at) applog.Printf("sat: %d satellites from the cached element set (%s old)", len(els), time.Since(at).Round(time.Minute)) } else if !os.IsNotExist(err) { applog.Printf("sat: the cached element set could not be read: %v", err) } a.satMu.Lock() a.satStore, a.satBirds, a.satFetch = store, birds, fetch a.satMu.Unlock() a.loadCustomElements() set := a.satSettings() if set.AutoTLE && a.satTLEInfo().Stale { go func() { if _, err := a.RefreshSatelliteTLE(); err != nil { applog.Printf("sat: %v", err) } }() } } // satParts hands back the three pieces under the lock, building them if the // startup path has not run — a binding called from a tab the operator opened // before startup finished must not answer "no satellites". func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) { a.satMu.Lock() if a.satStore == nil { a.satMu.Unlock() a.startSatellites() a.satMu.Lock() } s, b, f := a.satStore, a.satBirds, a.satFetch a.satMu.Unlock() return s, b, f } // ── Settings ──────────────────────────────────────────────────────────────── func (a *App) satSettings() SatSettings { out := SatSettings{MinEl: 10, WindowH: 24, AutoTLE: true} if a.settings == nil { return out } m, err := a.settings.GetMany(a.ctx, keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM) if err != nil { return out } for _, n := range strings.Split(m[keySatFavorites], ",") { if n = strings.TrimSpace(n); n != "" { out.Favorites = append(out.Favorites, n) } } if v, err := strconv.Atoi(m[keySatMinEl]); err == nil && v >= 0 && v <= 60 { out.MinEl = v } if v, err := strconv.Atoi(m[keySatWindowH]); err == nil && v >= 1 && v <= 168 { out.WindowH = v } if v, ok := m[keySatAutoTLE]; ok && v != "" { out.AutoTLE = v == "1" } out.Grid = strings.TrimSpace(m[keySatGrid]) if v, err := strconv.Atoi(m[keySatAltM]); err == nil && v > -500 && v < 9000 { out.AltM = v } return out } // GetSatSettings returns the satellite preferences. func (a *App) GetSatSettings() (SatSettings, error) { if a.settings == nil { return SatSettings{}, fmt.Errorf("db not initialized") } return a.satSettings(), nil } // SaveSatSettings stores them. func (a *App) SaveSatSettings(s SatSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } if s.MinEl < 0 || s.MinEl > 60 { s.MinEl = 10 } if s.WindowH < 1 || s.WindowH > 168 { s.WindowH = 24 } var favs []string seen := map[string]bool{} for _, n := range s.Favorites { n = strings.TrimSpace(n) if n == "" || seen[strings.ToUpper(n)] { continue } seen[strings.ToUpper(n)] = true favs = append(favs, n) } for k, v := range map[string]string{ keySatFavorites: strings.Join(favs, ","), keySatMinEl: strconv.Itoa(s.MinEl), keySatWindowH: strconv.Itoa(s.WindowH), keySatAutoTLE: boolStr(s.AutoTLE), keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)), keySatAltM: strconv.Itoa(s.AltM), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err } } return nil } // satObserver is the ground station: the satellite grid if the operator set one, // otherwise the station's own. // // A locator, not a latitude and longitude: it is what every logbook already // holds, and its six-character precision is a couple of kilometres — three // hundredths of a degree of azimuth at the worst possible geometry, far below // any rotator's backlash. func (a *App) satObserver() (sat.Observer, error) { set := a.satSettings() grid := set.Grid if grid == "" && a.settings != nil { grid, _ = a.settings.Get(a.ctx, keyStationMyGrid) } grid = strings.TrimSpace(grid) lat, lon, ok := gridToLatLon(grid) if !ok { return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site") } return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil } // GetSatelliteObserver reports the ground station the predictions are made for, // so the UI can show it — and say plainly when there is none. func (a *App) GetSatelliteObserver() (map[string]any, error) { obs, err := a.satObserver() if err != nil { return nil, err } return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil } // ── Elements ──────────────────────────────────────────────────────────────── func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) } // loadCustomElements merges the hand-entered file over the feed's set. Last // writer wins in the store, so an operator's own elements for a satellite // override the feed's — which is the whole point of having typed them. func (a *App) loadCustomElements() int { f, err := os.Open(a.customTLEPath()) if err != nil { return 0 } defer f.Close() els, skipped, err := sat.ParseTLESet(f) if err != nil { applog.Printf("sat: %s could not be read: %v", customTLEName, err) return 0 } if skipped > 0 { applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName) } store, _, _ := a.satParts() for _, e := range els { store.Put(e) } return len(els) } func (a *App) customElementCount() int { f, err := os.Open(a.customTLEPath()) if err != nil { return 0 } defer f.Close() els, _, err := sat.ParseTLESet(f) if err != nil { return 0 } return len(els) } func (a *App) satTLEInfo() SatTLEInfo { store, _, _ := a.satParts() at := store.FetchedAt() info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()} if !at.IsZero() { info.AgeH = time.Since(at).Hours() info.Stale = time.Since(at) > sat.StaleAfter } else { info.Stale = true // nothing on disk yet: the operator has to be told to fetch } return info } // GetSatelliteTLEInfo describes the element set, including how old it is. func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() } // RefreshSatelliteTLE downloads a fresh element set. func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) { store, _, fetch := a.satParts() ctx := a.ctx if ctx == nil { ctx = context.Background() } els, err := fetch.Fetch(ctx) if err != nil { return a.satTLEInfo(), err } store.Replace(els, time.Now()) a.loadCustomElements() // the operator's own elements go back on top info := a.satTLEInfo() if a.ctx != nil { wruntime.EventsEmit(a.ctx, "sat:tle", info) } return info, nil } // AddSatelliteElements takes elements pasted in by hand — two or three lines // per satellite — and keeps them across feed refreshes. func (a *App) AddSatelliteElements(text string) (int, error) { els, skipped, err := sat.ParseTLESet(strings.NewReader(text)) if err != nil { return 0, fmt.Errorf("those are not usable elements: %w", err) } existing := map[string]bool{} var keep []sat.Element if f, ferr := os.Open(a.customTLEPath()); ferr == nil { old, _, _ := sat.ParseTLESet(f) f.Close() keep = old } // The new set wins for a satellite already in the file: pasting elements is // how an operator UPDATES a bird the feeds do not carry. for _, e := range els { existing[strings.ToUpper(e.Name)] = true } var out []sat.Element for _, e := range keep { if !existing[strings.ToUpper(e.Name)] { out = append(out, e) } } out = append(out, els...) var b strings.Builder for _, e := range out { if e.Name != "" { b.WriteString(e.Name + "\n") } b.WriteString(e.Line1 + "\n" + e.Line2 + "\n") } if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil { return 0, err } n := a.loadCustomElements() if a.ctx != nil { wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo()) } if skipped > 0 { applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped) } return n, nil } // ── The list ──────────────────────────────────────────────────────────────── // GetSatelliteBirds joins the frequency plan to the elements. // // Both halves are listed, not just their intersection: a bird with elements and // no plan is one the operator can still track and add frequencies for, and a // bird with a plan and no elements is the one visible symptom of an element set // that is too old or too narrow — silently dropping either turns a fixable // configuration problem into a satellite that "does not exist". func (a *App) GetSatelliteBirds() []SatBird { store, birds, _ := a.satParts() set := a.satSettings() fav := map[string]bool{} for _, n := range set.Favorites { fav[strings.ToUpper(n)] = true } out := make([]SatBird, 0, birds.Len()) planned := map[string]bool{} for _, b := range birds.All() { item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]} for _, t := range b.Transponders { item.Transponders = append(item.Transponders, SatTransponder{ Label: t.Label, Mode: t.Mode, DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi, Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(), }) } if e, ok := satElement(store, b); ok { item.HasElements = true item.NORAD = e.NORAD item.EpochAgeH = e.Age().Hours() planned[strings.ToUpper(e.Name)] = true if !strings.EqualFold(e.Name, b.Name) { item.ElementName = e.Name } } out = append(out, item) } // The rest of the element set, so nothing the station holds is invisible. for _, n := range store.Names() { if planned[strings.ToUpper(n)] { continue } e, ok := store.Get(n) if !ok { continue } out = append(out, SatBird{ Name: e.Name, NORAD: e.NORAD, HasElements: true, EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)], }) } sort.Slice(out, func(i, j int) bool { // Favourites first, then the birds we can actually use, then by name. if out[i].Favorite != out[j].Favorite { return out[i].Favorite } iu := len(out[i].Transponders) > 0 && out[i].HasElements ju := len(out[j].Transponders) > 0 && out[j].HasElements if iu != ju { return iu } return out[i].Name < out[j].Name }) return out } // satElement finds the elements for a bird, trying its aliases. // // The feed's name and the operator's name for the same satellite are routinely // different, and the element set is keyed by the feed's. func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) { if e, ok := store.Get(b.Name); ok { return e, true } for _, alias := range b.Aliases { if e, ok := store.Get(alias); ok { return e, true } } // Last resort: scan, matching on letters and digits alone — that is how // "RADFXSAT (FOX-1B)" and "AO-91" meet. for _, n := range store.Names() { if b.Matches(n) { if e, ok := store.Get(n); ok { return e, true } } } return sat.Element{}, false } // ── Tracking ──────────────────────────────────────────────────────────────── // satNames resolves the names the UI asked for, falling back to the favourites // and then to every planned bird we hold elements for. func (a *App) satNames(names []string) []string { if len(names) > 0 { return names } set := a.satSettings() if len(set.Favorites) > 0 { return set.Favorites } var out []string for _, b := range a.GetSatelliteBirds() { if b.HasElements && len(b.Transponders) > 0 { out = append(out, b.Name) } } return out } // satResolve maps an operator-facing name onto the element set's own spelling. func (a *App) satResolve(name string) (string, bool) { store, birds, _ := a.satParts() if _, ok := store.Get(name); ok { return name, true } if b, ok := birds.Find(name); ok { if e, ok2 := satElement(store, b); ok2 { return e.Name, true } } return "", false } // GetSatellitePositions is where the given satellites are right now — the map's // question, and the rotator's. func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) { obs, err := a.satObserver() if err != nil { return nil, err } store, _, _ := a.satParts() now := time.Now().UTC() var out []sat.Position for _, n := range a.satNames(names) { real, ok := a.satResolve(n) if !ok { continue } p, err := store.Track(real, obs, now) if err != nil { continue } p.Name = n // answer in the operator's vocabulary, not the feed's out = append(out, p) } return out, nil } // GetSatelliteGroundTrack is the path a satellite draws over the ground, for // the map: one point a minute, forward from now. func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) { if minutes <= 0 || minutes > 360 { minutes = 120 } obs, err := a.satObserver() if err != nil { return nil, err } real, ok := a.satResolve(name) if !ok { return nil, fmt.Errorf("%s is not in the element set", name) } store, _, _ := a.satParts() now := time.Now().UTC() out := make([]sat.Position, 0, minutes+1) for i := 0; i <= minutes; i++ { p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute)) if err != nil { return nil, err } p.Name = name out = append(out, p) } return out, nil } // GetSatellitePasses lists what is coming, in time order. func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) { obs, err := a.satObserver() if err != nil { return nil, err } set := a.satSettings() if hours <= 0 { hours = set.WindowH } if hours > 168 { hours = 168 } store, _, _ := a.satParts() want := a.satNames(names) // The store is keyed by the feed's names; remember which operator name each // answer belongs to so the table reads the way the operator thinks. real := make([]string, 0, len(want)) back := map[string]string{} for _, n := range want { r, ok := a.satResolve(n) if !ok { continue } real = append(real, r) back[r] = n } passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl) for i := range passes { if n, ok := back[passes[i].Name]; ok { passes[i].Name = n } } return passes, nil } // GetSatelliteTuning is the working answer: where to listen, where to transmit, // and where the bird is, for one satellite and one transponder. // // downHz is where the operator has tuned inside the passband, in NOMINAL terms // — 0 means the middle of it. Keeping the operator's frequency nominal, and // applying Doppler only on the way out to the radio, is what makes a linear // pass workable: the station being answered stays put on the dial while both // radios chase the shift. func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) { _, birds, _ := a.satParts() b, ok := birds.Find(name) if !ok { return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName) } if transponder < 0 || transponder >= len(b.Transponders) { transponder = 0 } if len(b.Transponders) == 0 { return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name) } t := b.Transponders[transponder] if downHz <= 0 { // While the tracker is running it owns the nominal frequency — it moves // as the operator tunes. Reading the centre of the passband instead would // show a frequency nobody is on the moment they hunt for a station. downHz = a.satTrackedNominal(b.Name, transponder) } if downHz <= 0 { downHz = t.Centre() } out := SatTuning{ Name: b.Name, Transponder: t.Label, Mode: t.Mode, NominalDown: downHz, NominalUp: t.UplinkFor(downHz), CTCSS: t.CTCSS, Inverting: t.Inverting, At: time.Now().UTC(), } // Geostationary: it does not move, so there is nothing to correct and no // look angle worth recomputing every second. QO-100 is simply pointed at // once and left alone. if b.Geostationary { out.DownHz, out.UpHz = out.NominalDown, out.NominalUp out.Visible = true return out, nil } obs, err := a.satObserver() if err != nil { // No locator: the frequencies are still worth having, uncorrected. out.DownHz, out.UpHz = out.NominalDown, out.NominalUp return out, nil } real, ok := a.satResolve(name) if !ok { out.DownHz, out.UpHz = out.NominalDown, out.NominalUp return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name) } store, _, _ := a.satParts() p, err := store.Track(real, obs, out.At) if err != nil { out.DownHz, out.UpHz = out.NominalDown, out.NominalUp return out, err } sh := sat.Doppler(p, out.NominalDown, out.NominalUp) out.DownHz, out.UpHz = sh.DownHz, sh.UpHz out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate out.Visible = p.Visible() return out, nil }