From 1009d06a4ccff11120e8e4b609530c82ef045f52 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 7 Sep 2026 10:51:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(sat):=20the=20station=20side=20=E2=80=94?= =?UTF-8?q?=20elements,=20plan,=20passes=20and=20tuning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What internal/sat could not know: where the antenna is, which birds the operator cares about, and where the files live. Startup reads the cached elements and the frequency plan from disk and nothing else — one file and a few hundred parses, so the tab is full the moment it is opened, on a shack PC with no internet as much as on one with. Fetching is the slow, optional half and never blocks a launch; it happens on its own only when the set is stale and the operator asked for it. Elements pasted in by hand go in their own file. The feed cache is replaced wholesale on every refresh, so a freshly launched satellite — whose elements circulate on a mailing list days before any feed carries it, which is exactly the week everybody wants to hear it — would otherwise be wiped by the first automatic update. The list joins both halves and shows what is missing on either side. A bird with elements and no plan is one the operator can still track; a bird with a plan and no elements is the visible symptom of an element set that is too old. Dropping either turns a fixable configuration problem into a satellite that "does not exist". GetSatelliteTuning is the working answer, and everything that will later drive a radio is built on top of it rather than beside it, so the display and the rig can never disagree. It keeps the operator's frequency nominal and applies Doppler only on the way out: on a linear pass the station being answered stays put on the dial while both radios chase the shift. A geostationary bird is corrected by nothing at all. --- app.go | 14 + app_sat.go | 685 ++++++++++++++++++++++++++++++ frontend/wailsjs/go/main/App.d.ts | 23 + frontend/wailsjs/go/main/App.js | 44 ++ frontend/wailsjs/go/models.ts | 296 +++++++++++++ go.mod | 2 +- internal/sat/birds.go | 27 ++ internal/sat/birds_test.go | 20 + 8 files changed, 1110 insertions(+), 1 deletion(-) create mode 100644 app_sat.go diff --git a/app.go b/app.go index 1eff3b4..069cbcb 100644 --- a/app.go +++ b/app.go @@ -67,6 +67,7 @@ import ( "hamlog/internal/rotator/pst" "hamlog/internal/rotator/spid" "hamlog/internal/rotgenius" + "hamlog/internal/sat" "hamlog/internal/scp" "hamlog/internal/settings" "hamlog/internal/solar" @@ -884,6 +885,15 @@ type App struct { alertStore *alerts.Store // DX-cluster spot alert rules (global JSON) + // Satellites. The elements (where the birds are) and the frequency plan + // (what to do with the radio) are held apart because they come from + // different places and change for different reasons — a feed every few + // days, an operator's correction when a transponder is switched. + satMu sync.Mutex + satStore *sat.Store // orbital elements, by satellite name + satBirds *sat.Birds // uplink/downlink plan + satFetch *sat.Fetcher // element feeds + the on-disk cache + cwMu sync.Mutex // guards the CW decoder lifecycle cwStop chan struct{} // stops the CW decoder capture loop; nil when off cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch) @@ -1643,6 +1653,10 @@ func (a *App) startup(ctx context.Context) { a.alertStore = as } + // Satellites: the cached elements and the frequency plan. Local files only — + // any element fetch it decides to make goes to the network on its own. + a.startSatellites() + // Ultrabeam antenna: connect in the background if enabled. a.startUltrabeam() // Antenna Genius switch: connect in the background if enabled. diff --git a/app_sat.go b/app_sat.go new file mode 100644 index 0000000..aebf6e8 --- /dev/null +++ b/app_sat.go @@ -0,0 +1,685 @@ +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 { + 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 +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index fea8d0c..29efad2 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -19,6 +19,7 @@ import {pskrtgt} from '../models'; import {pskr} from '../models'; import {psu} from '../models'; import {spe} from '../models'; +import {sat} from '../models'; import {solar} from '../models'; import {tunergenius} from '../models'; import {webpub} from '../models'; @@ -52,6 +53,8 @@ export function ActiveRadioMyRig():Promise; export function AddQSO(arg1:qso.QSO):Promise; +export function AddSatelliteElements(arg1:string):Promise; + export function AmpFanMode(arg1:string,arg2:string):Promise; export function AmpOperate(arg1:string,arg2:boolean):Promise; @@ -588,6 +591,22 @@ export function GetRowColors():Promise; export function GetSPEStatus():Promise; +export function GetSatSettings():Promise; + +export function GetSatelliteBirds():Promise>; + +export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise>; + +export function GetSatelliteObserver():Promise>; + +export function GetSatellitePasses(arg1:Array,arg2:number):Promise>; + +export function GetSatellitePositions(arg1:Array):Promise>; + +export function GetSatelliteTLEInfo():Promise; + +export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise; + export function GetScpStatus():Promise; export function GetSecretStatus():Promise; @@ -974,6 +993,8 @@ export function RefreshDXpeditions():Promise; export function RefreshKenwood():Promise; +export function RefreshSatelliteTLE():Promise; + export function RefreshSolar():Promise; export function RefreshYaesuPanel():Promise; @@ -1116,6 +1137,8 @@ export function SaveRotorPresets(arg1:Array):Promise; export function SaveRowColors(arg1:main.RowColorSettings):Promise; +export function SaveSatSettings(arg1:main.SatSettings):Promise; + export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise; export function SaveSpotColors(arg1:main.SpotColors):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 60fded2..72ae4a8 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -38,6 +38,10 @@ export function AddQSO(arg1) { return window['go']['main']['App']['AddQSO'](arg1); } +export function AddSatelliteElements(arg1) { + return window['go']['main']['App']['AddSatelliteElements'](arg1); +} + export function AmpFanMode(arg1, arg2) { return window['go']['main']['App']['AmpFanMode'](arg1, arg2); } @@ -1110,6 +1114,38 @@ export function GetSPEStatus() { return window['go']['main']['App']['GetSPEStatus'](); } +export function GetSatSettings() { + return window['go']['main']['App']['GetSatSettings'](); +} + +export function GetSatelliteBirds() { + return window['go']['main']['App']['GetSatelliteBirds'](); +} + +export function GetSatelliteGroundTrack(arg1, arg2) { + return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2); +} + +export function GetSatelliteObserver() { + return window['go']['main']['App']['GetSatelliteObserver'](); +} + +export function GetSatellitePasses(arg1, arg2) { + return window['go']['main']['App']['GetSatellitePasses'](arg1, arg2); +} + +export function GetSatellitePositions(arg1) { + return window['go']['main']['App']['GetSatellitePositions'](arg1); +} + +export function GetSatelliteTLEInfo() { + return window['go']['main']['App']['GetSatelliteTLEInfo'](); +} + +export function GetSatelliteTuning(arg1, arg2, arg3) { + return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3); +} + export function GetScpStatus() { return window['go']['main']['App']['GetScpStatus'](); } @@ -1882,6 +1918,10 @@ export function RefreshKenwood() { return window['go']['main']['App']['RefreshKenwood'](); } +export function RefreshSatelliteTLE() { + return window['go']['main']['App']['RefreshSatelliteTLE'](); +} + export function RefreshSolar() { return window['go']['main']['App']['RefreshSolar'](); } @@ -2166,6 +2206,10 @@ export function SaveRowColors(arg1) { return window['go']['main']['App']['SaveRowColors'](arg1); } +export function SaveSatSettings(arg1) { + return window['go']['main']['App']['SaveSatSettings'](arg1); +} + export function SaveSelfSpotSettings(arg1) { return window['go']['main']['App']['SaveSelfSpotSettings'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 41f3fbb..64f3025 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -4003,6 +4003,199 @@ export namespace main { return a; } } + export class SatTransponder { + label: string; + mode: string; + down_lo: number; + down_hi: number; + up_lo: number; + up_hi: number; + inverting: boolean; + ctcss: number; + linear: boolean; + + static createFrom(source: any = {}) { + return new SatTransponder(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.label = source["label"]; + this.mode = source["mode"]; + this.down_lo = source["down_lo"]; + this.down_hi = source["down_hi"]; + this.up_lo = source["up_lo"]; + this.up_hi = source["up_hi"]; + this.inverting = source["inverting"]; + this.ctcss = source["ctcss"]; + this.linear = source["linear"]; + } + } + export class SatBird { + name: string; + norad: number; + geostationary: boolean; + favorite: boolean; + has_elements: boolean; + element_name: string; + epoch_age_h: number; + transponders: SatTransponder[]; + + static createFrom(source: any = {}) { + return new SatBird(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.norad = source["norad"]; + this.geostationary = source["geostationary"]; + this.favorite = source["favorite"]; + this.has_elements = source["has_elements"]; + this.element_name = source["element_name"]; + this.epoch_age_h = source["epoch_age_h"]; + this.transponders = this.convertValues(source["transponders"], SatTransponder); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class SatSettings { + favorites: string[]; + min_el: number; + window_h: number; + auto_tle: boolean; + grid: string; + alt_m: number; + + static createFrom(source: any = {}) { + return new SatSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.favorites = source["favorites"]; + this.min_el = source["min_el"]; + this.window_h = source["window_h"]; + this.auto_tle = source["auto_tle"]; + this.grid = source["grid"]; + this.alt_m = source["alt_m"]; + } + } + export class SatTLEInfo { + count: number; + // Go type: time + fetched_at: any; + age_h: number; + stale: boolean; + custom: number; + + static createFrom(source: any = {}) { + return new SatTLEInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.count = source["count"]; + this.fetched_at = this.convertValues(source["fetched_at"], null); + this.age_h = source["age_h"]; + this.stale = source["stale"]; + this.custom = source["custom"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class SatTuning { + name: string; + transponder: string; + mode: string; + nominal_down: number; + nominal_up: number; + down_hz: number; + up_hz: number; + ctcss: number; + inverting: boolean; + az: number; + el: number; + range_km: number; + range_rate: number; + visible: boolean; + // Go type: time + at: any; + + static createFrom(source: any = {}) { + return new SatTuning(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.transponder = source["transponder"]; + this.mode = source["mode"]; + this.nominal_down = source["nominal_down"]; + this.nominal_up = source["nominal_up"]; + this.down_hz = source["down_hz"]; + this.up_hz = source["up_hz"]; + this.ctcss = source["ctcss"]; + this.inverting = source["inverting"]; + this.az = source["az"]; + this.el = source["el"]; + this.range_km = source["range_km"]; + this.range_rate = source["range_rate"]; + this.visible = source["visible"]; + this.at = this.convertValues(source["at"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class ScpStatus { enabled: boolean; count: number; @@ -6073,6 +6266,109 @@ export namespace qso { } +export namespace sat { + + export class Pass { + name: string; + // Go type: time + aos: any; + // Go type: time + los: any; + aos_az: number; + los_az: number; + max_el: number; + max_el_az: number; + // Go type: time + max_el_at: any; + duration_s: number; + + static createFrom(source: any = {}) { + return new Pass(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.aos = this.convertValues(source["aos"], null); + this.los = this.convertValues(source["los"], null); + this.aos_az = source["aos_az"]; + this.los_az = source["los_az"]; + this.max_el = source["max_el"]; + this.max_el_az = source["max_el_az"]; + this.max_el_at = this.convertValues(source["max_el_at"], null); + this.duration_s = source["duration_s"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Position { + name: string; + // Go type: time + at: any; + lat: number; + lon: number; + alt_km: number; + footprint_km: number; + az: number; + el: number; + range_km: number; + range_rate: number; + + static createFrom(source: any = {}) { + return new Position(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.at = this.convertValues(source["at"], null); + this.lat = source["lat"]; + this.lon = source["lon"]; + this.alt_km = source["alt_km"]; + this.footprint_km = source["footprint_km"]; + this.az = source["az"]; + this.el = source["el"]; + this.range_km = source["range_km"]; + this.range_rate = source["range_rate"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace scp { export class Result { diff --git a/go.mod b/go.mod index e860a78..d70b9e2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module hamlog go 1.25.0 require ( + github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb github.com/braheezy/shine-mp3 v0.1.0 github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/go-ole/go-ole v1.3.0 @@ -21,7 +22,6 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb // indirect github.com/bep/debounce v1.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect diff --git a/internal/sat/birds.go b/internal/sat/birds.go index bdfafc0..bbf6cd2 100644 --- a/internal/sat/birds.go +++ b/internal/sat/birds.go @@ -123,6 +123,33 @@ type Bird struct { 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 diff --git a/internal/sat/birds_test.go b/internal/sat/birds_test.go index f451d49..2ee4a2d 100644 --- a/internal/sat/birds_test.go +++ b/internal/sat/birds_test.go @@ -72,6 +72,26 @@ func TestFindByAlias(t *testing.T) { } } +// Matches is the other direction: a bird in hand, scanning a feed's names. +func TestBirdMatches(t *testing.T) { + b := Bird{Name: "AO-91", Aliases: []string{"RADFXSAT", "FOX-1B"}} + for _, feed := range []string{"AO-91", "RADFXSAT (FOX-1B)", "radfxsat", "FOX 1B"} { + if !b.Matches(feed) { + t.Errorf("%q was not recognised as AO-91", feed) + } + } + for _, feed := range []string{"AO-92", "NOAA 15", "FOX-1A"} { + if b.Matches(feed) { + t.Errorf("%q was wrongly taken for AO-91", feed) + } + } + // A bracketed catalogue name matched from the other side. + iss := Bird{Name: "ISS (ZARYA)"} + if !iss.Matches("ISS") || !iss.Matches("ZARYA") { + t.Error("the ISS was not recognised by either half of its catalogue name") + } +} + // 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) {