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) {