EasyComm is what satellite rotator controllers agreed on, so a box that works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its own settings rather than the HF rotator's: an az/el pair is a different machine on a different port, and an operator who has both must not have to choose. A great many EasyComm controllers — the Arduino trackers above all — accept commands and never say a word back. That is legal and common, so a silent controller is not treated as a broken one: it is still driven, and the last commanded position is reported in its place, marked as commanded rather than read. A stuck rotator must not be able to hide behind an order it never carried out, which is why the panel shows the antenna's position beside the satellite's. The 450° overlap is the reason a satellite rotator is worth having, so it is used: a pass crossing north continues past 360 instead of unwinding three quarters of a turn with the antenna sweeping the ground. Below the configured elevation the mast is left alone — the numbers are right all the way round the orbit, but a rotator that chases a satellite through the far side of the earth spends the night turning, and a mast has a finite number of turns in it.
776 lines
25 KiB
Go
776 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.settings != nil {
|
|
grid, _ = a.settings.Get(a.ctx, keyStationMyGrid)
|
|
}
|
|
grid = strings.TrimSpace(grid)
|
|
lat, lon, ok := gridToLatLon(grid)
|
|
if !ok {
|
|
return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
|
|
}
|
|
return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
|
|
}
|
|
|
|
// GetSatelliteObserver reports the ground station the predictions are made for,
|
|
// so the UI can show it — and say plainly when there is none.
|
|
func (a *App) GetSatelliteObserver() (map[string]any, error) {
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
|
|
}
|
|
|
|
// ── Elements ────────────────────────────────────────────────────────────────
|
|
|
|
func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }
|
|
|
|
// loadCustomElements merges the hand-entered file over the feed's set. Last
|
|
// writer wins in the store, so an operator's own elements for a satellite
|
|
// override the feed's — which is the whole point of having typed them.
|
|
func (a *App) loadCustomElements() int {
|
|
f, err := os.Open(a.customTLEPath())
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer f.Close()
|
|
els, skipped, err := sat.ParseTLESet(f)
|
|
if err != nil {
|
|
applog.Printf("sat: %s could not be read: %v", customTLEName, err)
|
|
return 0
|
|
}
|
|
if skipped > 0 {
|
|
applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
|
|
}
|
|
store, _, _ := a.satParts()
|
|
for _, e := range els {
|
|
store.Put(e)
|
|
}
|
|
return len(els)
|
|
}
|
|
|
|
func (a *App) customElementCount() int {
|
|
f, err := os.Open(a.customTLEPath())
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer f.Close()
|
|
els, _, err := sat.ParseTLESet(f)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(els)
|
|
}
|
|
|
|
func (a *App) satTLEInfo() SatTLEInfo {
|
|
store, _, _ := a.satParts()
|
|
at := store.FetchedAt()
|
|
info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
|
|
if !at.IsZero() {
|
|
info.AgeH = time.Since(at).Hours()
|
|
info.Stale = time.Since(at) > sat.StaleAfter
|
|
} else {
|
|
info.Stale = true // nothing on disk yet: the operator has to be told to fetch
|
|
}
|
|
return info
|
|
}
|
|
|
|
// GetSatelliteTLEInfo describes the element set, including how old it is.
|
|
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }
|
|
|
|
// RefreshSatelliteTLE downloads a fresh element set.
|
|
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
|
|
store, _, fetch := a.satParts()
|
|
ctx := a.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
els, err := fetch.Fetch(ctx)
|
|
if err != nil {
|
|
return a.satTLEInfo(), err
|
|
}
|
|
store.Replace(els, time.Now())
|
|
a.loadCustomElements() // the operator's own elements go back on top
|
|
info := a.satTLEInfo()
|
|
if a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "sat:tle", info)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// AddSatelliteElements takes elements pasted in by hand — two or three lines
|
|
// per satellite — and keeps them across feed refreshes.
|
|
func (a *App) AddSatelliteElements(text string) (int, error) {
|
|
els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("those are not usable elements: %w", err)
|
|
}
|
|
existing := map[string]bool{}
|
|
var keep []sat.Element
|
|
if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
|
|
old, _, _ := sat.ParseTLESet(f)
|
|
f.Close()
|
|
keep = old
|
|
}
|
|
// The new set wins for a satellite already in the file: pasting elements is
|
|
// how an operator UPDATES a bird the feeds do not carry.
|
|
for _, e := range els {
|
|
existing[strings.ToUpper(e.Name)] = true
|
|
}
|
|
var out []sat.Element
|
|
for _, e := range keep {
|
|
if !existing[strings.ToUpper(e.Name)] {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
out = append(out, els...)
|
|
|
|
var b strings.Builder
|
|
for _, e := range out {
|
|
if e.Name != "" {
|
|
b.WriteString(e.Name + "\n")
|
|
}
|
|
b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
|
|
}
|
|
if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
|
|
return 0, err
|
|
}
|
|
n := a.loadCustomElements()
|
|
if a.ctx != nil {
|
|
wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
|
|
}
|
|
if skipped > 0 {
|
|
applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ── The list ────────────────────────────────────────────────────────────────
|
|
|
|
// GetSatelliteBirds joins the frequency plan to the elements.
|
|
//
|
|
// Both halves are listed, not just their intersection: a bird with elements and
|
|
// no plan is one the operator can still track and add frequencies for, and a
|
|
// bird with a plan and no elements is the one visible symptom of an element set
|
|
// that is too old or too narrow — silently dropping either turns a fixable
|
|
// configuration problem into a satellite that "does not exist".
|
|
func (a *App) GetSatelliteBirds() []SatBird {
|
|
store, birds, _ := a.satParts()
|
|
set := a.satSettings()
|
|
fav := map[string]bool{}
|
|
for _, n := range set.Favorites {
|
|
fav[strings.ToUpper(n)] = true
|
|
}
|
|
|
|
out := make([]SatBird, 0, birds.Len())
|
|
planned := map[string]bool{}
|
|
for _, b := range birds.All() {
|
|
item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
|
|
for _, t := range b.Transponders {
|
|
item.Transponders = append(item.Transponders, SatTransponder{
|
|
Label: t.Label, Mode: t.Mode,
|
|
DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
|
|
Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
|
|
})
|
|
}
|
|
if e, ok := satElement(store, b); ok {
|
|
item.HasElements = true
|
|
item.NORAD = e.NORAD
|
|
item.EpochAgeH = e.Age().Hours()
|
|
planned[strings.ToUpper(e.Name)] = true
|
|
if !strings.EqualFold(e.Name, b.Name) {
|
|
item.ElementName = e.Name
|
|
}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
// The rest of the element set, so nothing the station holds is invisible.
|
|
for _, n := range store.Names() {
|
|
if planned[strings.ToUpper(n)] {
|
|
continue
|
|
}
|
|
e, ok := store.Get(n)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, SatBird{
|
|
Name: e.Name, NORAD: e.NORAD, HasElements: true,
|
|
EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
// Favourites first, then the birds we can actually use, then by name.
|
|
if out[i].Favorite != out[j].Favorite {
|
|
return out[i].Favorite
|
|
}
|
|
iu := len(out[i].Transponders) > 0 && out[i].HasElements
|
|
ju := len(out[j].Transponders) > 0 && out[j].HasElements
|
|
if iu != ju {
|
|
return iu
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out
|
|
}
|
|
|
|
// satElement finds the elements for a bird, trying its aliases.
|
|
//
|
|
// The feed's name and the operator's name for the same satellite are routinely
|
|
// different, and the element set is keyed by the feed's.
|
|
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
|
if e, ok := store.Get(b.Name); ok {
|
|
return e, true
|
|
}
|
|
for _, alias := range b.Aliases {
|
|
if e, ok := store.Get(alias); ok {
|
|
return e, true
|
|
}
|
|
}
|
|
// Last resort: scan, matching on letters and digits alone — that is how
|
|
// "RADFXSAT (FOX-1B)" and "AO-91" meet.
|
|
for _, n := range store.Names() {
|
|
if b.Matches(n) {
|
|
if e, ok := store.Get(n); ok {
|
|
return e, true
|
|
}
|
|
}
|
|
}
|
|
return sat.Element{}, false
|
|
}
|
|
|
|
// ── Tracking ────────────────────────────────────────────────────────────────
|
|
|
|
// satNames resolves the names the UI asked for, falling back to the favourites
|
|
// and then to every planned bird we hold elements for.
|
|
func (a *App) satNames(names []string) []string {
|
|
if len(names) > 0 {
|
|
return names
|
|
}
|
|
set := a.satSettings()
|
|
if len(set.Favorites) > 0 {
|
|
return set.Favorites
|
|
}
|
|
var out []string
|
|
for _, b := range a.GetSatelliteBirds() {
|
|
if b.HasElements && len(b.Transponders) > 0 {
|
|
out = append(out, b.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// satResolve maps an operator-facing name onto the element set's own spelling.
|
|
func (a *App) satResolve(name string) (string, bool) {
|
|
store, birds, _ := a.satParts()
|
|
if _, ok := store.Get(name); ok {
|
|
return name, true
|
|
}
|
|
if b, ok := birds.Find(name); ok {
|
|
if e, ok2 := satElement(store, b); ok2 {
|
|
return e.Name, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// GetSatellitePositions is where the given satellites are right now — the map's
|
|
// question, and the rotator's.
|
|
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
store, _, _ := a.satParts()
|
|
now := time.Now().UTC()
|
|
var out []sat.Position
|
|
for _, n := range a.satNames(names) {
|
|
real, ok := a.satResolve(n)
|
|
if !ok {
|
|
continue
|
|
}
|
|
p, err := store.Track(real, obs, now)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
p.Name = n // answer in the operator's vocabulary, not the feed's
|
|
out = append(out, p)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
|
|
// the map: one point a minute, forward from now.
|
|
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
|
|
if minutes <= 0 || minutes > 360 {
|
|
minutes = 120
|
|
}
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
real, ok := a.satResolve(name)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s is not in the element set", name)
|
|
}
|
|
store, _, _ := a.satParts()
|
|
now := time.Now().UTC()
|
|
out := make([]sat.Position, 0, minutes+1)
|
|
for i := 0; i <= minutes; i++ {
|
|
p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Name = name
|
|
out = append(out, p)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSatellitePasses lists what is coming, in time order.
|
|
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
set := a.satSettings()
|
|
if hours <= 0 {
|
|
hours = set.WindowH
|
|
}
|
|
if hours > 168 {
|
|
hours = 168
|
|
}
|
|
store, _, _ := a.satParts()
|
|
want := a.satNames(names)
|
|
// The store is keyed by the feed's names; remember which operator name each
|
|
// answer belongs to so the table reads the way the operator thinks.
|
|
real := make([]string, 0, len(want))
|
|
back := map[string]string{}
|
|
for _, n := range want {
|
|
r, ok := a.satResolve(n)
|
|
if !ok {
|
|
continue
|
|
}
|
|
real = append(real, r)
|
|
back[r] = n
|
|
}
|
|
passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
|
|
for i := range passes {
|
|
if n, ok := back[passes[i].Name]; ok {
|
|
passes[i].Name = n
|
|
}
|
|
}
|
|
return passes, nil
|
|
}
|
|
|
|
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
|
|
// and where the bird is, for one satellite and one transponder.
|
|
//
|
|
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
|
|
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
|
|
// applying Doppler only on the way out to the radio, is what makes a linear
|
|
// pass workable: the station being answered stays put on the dial while both
|
|
// radios chase the shift.
|
|
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
|
|
_, birds, _ := a.satParts()
|
|
b, ok := birds.Find(name)
|
|
if !ok {
|
|
return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
|
|
}
|
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
|
transponder = 0
|
|
}
|
|
if len(b.Transponders) == 0 {
|
|
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
|
|
}
|
|
t := b.Transponders[transponder]
|
|
if downHz <= 0 {
|
|
// While the tracker is running it owns the nominal frequency — it moves
|
|
// as the operator tunes. Reading the centre of the passband instead would
|
|
// show a frequency nobody is on the moment they hunt for a station.
|
|
downHz = a.satTrackedNominal(b.Name, transponder)
|
|
}
|
|
if downHz <= 0 {
|
|
downHz = t.Centre()
|
|
}
|
|
out := SatTuning{
|
|
Name: b.Name,
|
|
Transponder: t.Label,
|
|
Mode: t.Mode,
|
|
NominalDown: downHz,
|
|
NominalUp: t.UplinkFor(downHz),
|
|
CTCSS: t.CTCSS,
|
|
Inverting: t.Inverting,
|
|
At: time.Now().UTC(),
|
|
}
|
|
// Geostationary: it does not move, so there is nothing to correct and no
|
|
// look angle worth recomputing every second. QO-100 is simply pointed at
|
|
// once and left alone.
|
|
if b.Geostationary {
|
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
|
out.Visible = true
|
|
return out, nil
|
|
}
|
|
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
// No locator: the frequencies are still worth having, uncorrected.
|
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
|
return out, nil
|
|
}
|
|
real, ok := a.satResolve(name)
|
|
if !ok {
|
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
|
return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
|
|
}
|
|
store, _, _ := a.satParts()
|
|
p, err := store.Track(real, obs, out.At)
|
|
if err != nil {
|
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
|
return out, err
|
|
}
|
|
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
|
|
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
|
|
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
|
|
out.Visible = p.Visible()
|
|
return out, nil
|
|
}
|