Files
OpsLog/app_sat.go
T
rouggy 7f03b046ab fix(sat): the locator was already set, and the map was on top of everything
Two things reported together on the new tab.

The locator: the station's is on the ACTIVE PROFILE, not in a settings
key. keyStationMyGrid is a legacy key that EnsureDefault migrated into
the profile long ago and nothing writes any more, so reading it told an
operator with a perfectly good locator on screen that he had not set one
— and refused every pass prediction on the strength of it.

The map: Leaflet stacks its panes and controls up to z-index 1000, which
without a stacking context of their own float over the whole application
— Preferences opened behind the map, its Save and Close buttons under it.
The other three maps in OpsLog each carry `isolate` for exactly this
reason; this one was missing it.
2026-09-07 12:07:12 +02:00

783 lines
25 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
// The az/el rotator. Its own settings rather than the HF rotator's: a
// satellite station's elevation rotator is a different machine on a
// different port, and an operator who has both must not have to choose.
keySatRotOn = "sat.rot_enabled"
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
keySatRotHost = "sat.rot_host"
keySatRotPort = "sat.rot_port"
keySatRotCOM = "sat.rot_com"
keySatRotBaud = "sat.rot_baud"
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
)
// 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"`
// The az/el rotator.
RotOn bool `json:"rot_on"`
RotTransport string `json:"rot_transport"`
RotHost string `json:"rot_host"`
RotPort int `json:"rot_port"`
RotCOM string `json:"rot_com"`
RotBaud int `json:"rot_baud"`
RotMaxAz int `json:"rot_max_az"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
}
// 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 {
// The rotator defaults are the common case, not a blank form: EasyComm over
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
// beam with any gain at all is well inside the beamwidth and keeps a pass
// from being a command a second.
out := SatSettings{
MinEl: 10, WindowH: 24, AutoTLE: true,
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
}
if a.settings == nil {
return out
}
m, err := a.settings.GetMany(a.ctx,
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
keySatRotOn, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
if err != nil {
return out
}
out.RotOn = m[keySatRotOn] == "1"
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
out.RotTransport = tr
}
out.RotHost = strings.TrimSpace(m[keySatRotHost])
if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
out.RotPort = v
}
out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
out.RotBaud = v
}
if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
out.RotMaxAz = 450
}
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
out.RotMinEl = v
}
if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 {
out.RotStep = v
}
out.RotPark = m[keySatRotPark] == "1"
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)
}
if s.RotTransport != "tcp" {
s.RotTransport = "serial"
}
if s.RotMaxAz != 450 {
s.RotMaxAz = 360
}
if s.RotStep < 1 || s.RotStep > 30 {
s.RotStep = 5
}
if s.RotPort <= 0 || s.RotPort > 65535 {
s.RotPort = 4533
}
if s.RotBaud < 1200 || s.RotBaud > 115200 {
s.RotBaud = 9600
}
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),
keySatRotOn: boolStr(s.RotOn),
keySatRotTransport: s.RotTransport,
keySatRotHost: strings.TrimSpace(s.RotHost),
keySatRotPort: strconv.Itoa(s.RotPort),
keySatRotCOM: strings.TrimSpace(s.RotCOM),
keySatRotBaud: strconv.Itoa(s.RotBaud),
keySatRotMaxAz: strconv.Itoa(s.RotMaxAz),
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
keySatRotStep: strconv.Itoa(s.RotStep),
keySatRotPark: boolStr(s.RotPark),
} {
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.profiles != nil {
// The station locator lives on the ACTIVE PROFILE, not in a settings key.
// keyStationMyGrid is a legacy key that EnsureDefault migrated into the
// profile years ago and nothing writes any more — reading it told an
// operator with a perfectly good locator on screen that he had not set
// one.
if p, err := a.profiles.Active(a.ctx); err == nil {
grid = p.MyGrid
}
}
grid = strings.TrimSpace(grid)
lat, lon, ok := gridToLatLon(grid)
if !ok {
return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
}
return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
}
// GetSatelliteObserver reports the ground station the predictions are made for,
// so the UI can show it — and say plainly when there is none.
func (a *App) GetSatelliteObserver() (map[string]any, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
}
// ── Elements ────────────────────────────────────────────────────────────────
func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }
// loadCustomElements merges the hand-entered file over the feed's set. Last
// writer wins in the store, so an operator's own elements for a satellite
// override the feed's — which is the whole point of having typed them.
func (a *App) loadCustomElements() int {
f, err := os.Open(a.customTLEPath())
if err != nil {
return 0
}
defer f.Close()
els, skipped, err := sat.ParseTLESet(f)
if err != nil {
applog.Printf("sat: %s could not be read: %v", customTLEName, err)
return 0
}
if skipped > 0 {
applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
}
store, _, _ := a.satParts()
for _, e := range els {
store.Put(e)
}
return len(els)
}
func (a *App) customElementCount() int {
f, err := os.Open(a.customTLEPath())
if err != nil {
return 0
}
defer f.Close()
els, _, err := sat.ParseTLESet(f)
if err != nil {
return 0
}
return len(els)
}
func (a *App) satTLEInfo() SatTLEInfo {
store, _, _ := a.satParts()
at := store.FetchedAt()
info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
if !at.IsZero() {
info.AgeH = time.Since(at).Hours()
info.Stale = time.Since(at) > sat.StaleAfter
} else {
info.Stale = true // nothing on disk yet: the operator has to be told to fetch
}
return info
}
// GetSatelliteTLEInfo describes the element set, including how old it is.
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }
// RefreshSatelliteTLE downloads a fresh element set.
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
store, _, fetch := a.satParts()
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
els, err := fetch.Fetch(ctx)
if err != nil {
return a.satTLEInfo(), err
}
store.Replace(els, time.Now())
a.loadCustomElements() // the operator's own elements go back on top
info := a.satTLEInfo()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:tle", info)
}
return info, nil
}
// AddSatelliteElements takes elements pasted in by hand — two or three lines
// per satellite — and keeps them across feed refreshes.
func (a *App) AddSatelliteElements(text string) (int, error) {
els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
if err != nil {
return 0, fmt.Errorf("those are not usable elements: %w", err)
}
existing := map[string]bool{}
var keep []sat.Element
if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
old, _, _ := sat.ParseTLESet(f)
f.Close()
keep = old
}
// The new set wins for a satellite already in the file: pasting elements is
// how an operator UPDATES a bird the feeds do not carry.
for _, e := range els {
existing[strings.ToUpper(e.Name)] = true
}
var out []sat.Element
for _, e := range keep {
if !existing[strings.ToUpper(e.Name)] {
out = append(out, e)
}
}
out = append(out, els...)
var b strings.Builder
for _, e := range out {
if e.Name != "" {
b.WriteString(e.Name + "\n")
}
b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
}
if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
return 0, err
}
n := a.loadCustomElements()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
}
if skipped > 0 {
applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
}
return n, nil
}
// ── The list ────────────────────────────────────────────────────────────────
// GetSatelliteBirds joins the frequency plan to the elements.
//
// Both halves are listed, not just their intersection: a bird with elements and
// no plan is one the operator can still track and add frequencies for, and a
// bird with a plan and no elements is the one visible symptom of an element set
// that is too old or too narrow — silently dropping either turns a fixable
// configuration problem into a satellite that "does not exist".
func (a *App) GetSatelliteBirds() []SatBird {
store, birds, _ := a.satParts()
set := a.satSettings()
fav := map[string]bool{}
for _, n := range set.Favorites {
fav[strings.ToUpper(n)] = true
}
out := make([]SatBird, 0, birds.Len())
planned := map[string]bool{}
for _, b := range birds.All() {
item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
for _, t := range b.Transponders {
item.Transponders = append(item.Transponders, SatTransponder{
Label: t.Label, Mode: t.Mode,
DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
})
}
if e, ok := satElement(store, b); ok {
item.HasElements = true
item.NORAD = e.NORAD
item.EpochAgeH = e.Age().Hours()
planned[strings.ToUpper(e.Name)] = true
if !strings.EqualFold(e.Name, b.Name) {
item.ElementName = e.Name
}
}
out = append(out, item)
}
// The rest of the element set, so nothing the station holds is invisible.
for _, n := range store.Names() {
if planned[strings.ToUpper(n)] {
continue
}
e, ok := store.Get(n)
if !ok {
continue
}
out = append(out, SatBird{
Name: e.Name, NORAD: e.NORAD, HasElements: true,
EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
})
}
sort.Slice(out, func(i, j int) bool {
// Favourites first, then the birds we can actually use, then by name.
if out[i].Favorite != out[j].Favorite {
return out[i].Favorite
}
iu := len(out[i].Transponders) > 0 && out[i].HasElements
ju := len(out[j].Transponders) > 0 && out[j].HasElements
if iu != ju {
return iu
}
return out[i].Name < out[j].Name
})
return out
}
// satElement finds the elements for a bird, trying its aliases.
//
// The feed's name and the operator's name for the same satellite are routinely
// different, and the element set is keyed by the feed's.
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
if e, ok := store.Get(b.Name); ok {
return e, true
}
for _, alias := range b.Aliases {
if e, ok := store.Get(alias); ok {
return e, true
}
}
// Last resort: scan, matching on letters and digits alone — that is how
// "RADFXSAT (FOX-1B)" and "AO-91" meet.
for _, n := range store.Names() {
if b.Matches(n) {
if e, ok := store.Get(n); ok {
return e, true
}
}
}
return sat.Element{}, false
}
// ── Tracking ────────────────────────────────────────────────────────────────
// satNames resolves the names the UI asked for, falling back to the favourites
// and then to every planned bird we hold elements for.
func (a *App) satNames(names []string) []string {
if len(names) > 0 {
return names
}
set := a.satSettings()
if len(set.Favorites) > 0 {
return set.Favorites
}
var out []string
for _, b := range a.GetSatelliteBirds() {
if b.HasElements && len(b.Transponders) > 0 {
out = append(out, b.Name)
}
}
return out
}
// satResolve maps an operator-facing name onto the element set's own spelling.
func (a *App) satResolve(name string) (string, bool) {
store, birds, _ := a.satParts()
if _, ok := store.Get(name); ok {
return name, true
}
if b, ok := birds.Find(name); ok {
if e, ok2 := satElement(store, b); ok2 {
return e.Name, true
}
}
return "", false
}
// GetSatellitePositions is where the given satellites are right now — the map's
// question, and the rotator's.
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
store, _, _ := a.satParts()
now := time.Now().UTC()
var out []sat.Position
for _, n := range a.satNames(names) {
real, ok := a.satResolve(n)
if !ok {
continue
}
p, err := store.Track(real, obs, now)
if err != nil {
continue
}
p.Name = n // answer in the operator's vocabulary, not the feed's
out = append(out, p)
}
return out, nil
}
// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
// the map: one point a minute, forward from now.
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
if minutes <= 0 || minutes > 360 {
minutes = 120
}
obs, err := a.satObserver()
if err != nil {
return nil, err
}
real, ok := a.satResolve(name)
if !ok {
return nil, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
now := time.Now().UTC()
out := make([]sat.Position, 0, minutes+1)
for i := 0; i <= minutes; i++ {
p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
if err != nil {
return nil, err
}
p.Name = name
out = append(out, p)
}
return out, nil
}
// GetSatellitePasses lists what is coming, in time order.
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
set := a.satSettings()
if hours <= 0 {
hours = set.WindowH
}
if hours > 168 {
hours = 168
}
store, _, _ := a.satParts()
want := a.satNames(names)
// The store is keyed by the feed's names; remember which operator name each
// answer belongs to so the table reads the way the operator thinks.
real := make([]string, 0, len(want))
back := map[string]string{}
for _, n := range want {
r, ok := a.satResolve(n)
if !ok {
continue
}
real = append(real, r)
back[r] = n
}
passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
for i := range passes {
if n, ok := back[passes[i].Name]; ok {
passes[i].Name = n
}
}
return passes, nil
}
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
// and where the bird is, for one satellite and one transponder.
//
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
// applying Doppler only on the way out to the radio, is what makes a linear
// pass workable: the station being answered stays put on the dial while both
// radios chase the shift.
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok {
return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
if len(b.Transponders) == 0 {
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
}
t := b.Transponders[transponder]
if downHz <= 0 {
// While the tracker is running it owns the nominal frequency — it moves
// as the operator tunes. Reading the centre of the passband instead would
// show a frequency nobody is on the moment they hunt for a station.
downHz = a.satTrackedNominal(b.Name, transponder)
}
if downHz <= 0 {
downHz = t.Centre()
}
out := SatTuning{
Name: b.Name,
Transponder: t.Label,
Mode: t.Mode,
NominalDown: downHz,
NominalUp: t.UplinkFor(downHz),
CTCSS: t.CTCSS,
Inverting: t.Inverting,
At: time.Now().UTC(),
}
// Geostationary: it does not move, so there is nothing to correct and no
// look angle worth recomputing every second. QO-100 is simply pointed at
// once and left alone.
if b.Geostationary {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
out.Visible = true
return out, nil
}
obs, err := a.satObserver()
if err != nil {
// No locator: the frequencies are still worth having, uncorrected.
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, nil
}
real, ok := a.satResolve(name)
if !ok {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, out.At)
if err != nil {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, err
}
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
out.Visible = p.Visible()
return out, nil
}