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.
686 lines
22 KiB
Go
686 lines
22 KiB
Go
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
|
|
}
|