The map answers "where is the satellite over the earth". This answers "where do I look", which during a pass is the question that matters. The projection is the one every tracker uses and every operator already reads: the centre is the zenith, the rim is the horizon, north is up. So the radius is (90 − elevation), not the elevation — a bird overhead is a dot in the middle, and a pass that hugs the rim is one that never rises. Whether it comes over the roof or along the treeline is something no amount of azimuth and elevation digits conveys, and one glance settles. The whole pass is drawn: a dashed track with arrowheads for the direction of travel, a hollow circle where it rises, a filled one where it sets, and a cross where the satellite is now — green above the horizon, grey below, because the numbers are still right down there and nothing can be worked through the earth. The track is fetched once a minute, not once a second: the SHAPE of a pass does not change while it happens. Only the marker moves, and that rides on the tuning poll that was already running.
981 lines
32 KiB
Go
981 lines
32 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"
|
|
"encoding/json"
|
|
"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"
|
|
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
|
|
// which many stations already run in front of their controller. Its own port
|
|
// key because it is a different program on a different port from an EasyComm
|
|
// controller, and an operator who tries both must not lose the first setting
|
|
// to the second.
|
|
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
|
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
|
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"`
|
|
RotType string `json:"rot_type"`
|
|
RotPstPort int `json:"rot_pst_port"`
|
|
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"`
|
|
|
|
// Where the satellite is over the earth. Carried with the tuning because
|
|
// they are read together and change together — the panel would otherwise ask
|
|
// twice a second for two halves of one instant.
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
AltKm float64 `json:"alt_km"`
|
|
Footprint float64 `json:"footprint_km"`
|
|
}
|
|
|
|
// SatPassInfo is the pass in progress, or the next one.
|
|
//
|
|
// Separate from the tuning and polled far more slowly: predicting a pass steps
|
|
// the orbit thirty seconds at a time across hours, which is not something to do
|
|
// once a second for a countdown a browser can run itself from two timestamps.
|
|
type SatPassInfo struct {
|
|
Name string `json:"name"`
|
|
HasPass bool `json:"has_pass"`
|
|
// InPass distinguishes "it is up now" from "it rises at". The pass in
|
|
// progress is reported whatever its maximum elevation: an operator watching
|
|
// a satellite go over does not want it hidden because it fell below the
|
|
// threshold that filters the TABLE of what is worth waiting for.
|
|
InPass bool `json:"in_pass"`
|
|
AOS time.Time `json:"aos"`
|
|
LOS time.Time `json:"los"`
|
|
AOSAz float64 `json:"aos_az"`
|
|
LOSAz float64 `json:"los_az"`
|
|
MaxEl float64 `json:"max_el"`
|
|
MaxElAz float64 `json:"max_el_az"`
|
|
MaxElAt time.Time `json:"max_el_at"`
|
|
Duration float64 `json:"duration_s"`
|
|
}
|
|
|
|
// ── 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,
|
|
RotType: satRotEasycomm, RotPstPort: 12000,
|
|
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, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
|
|
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
out.RotOn = m[keySatRotOn] == "1"
|
|
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
|
|
out.RotType = ty
|
|
}
|
|
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
|
|
out.RotPstPort = v
|
|
}
|
|
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.RotType != satRotPst {
|
|
s.RotType = satRotEasycomm
|
|
}
|
|
if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
|
|
s.RotPstPort = 12000
|
|
}
|
|
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),
|
|
keySatRotType: s.RotType,
|
|
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
|
|
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
|
|
}
|
|
|
|
// GetSatelliteNames is the list behind the entry form's SAT_NAME box.
|
|
//
|
|
// One list, not two. It used to be a text box in Settings ▸ Lists that an
|
|
// operator typed their birds into by hand, which then had nothing to do with
|
|
// the satellites the tracker knew — the same station kept two lists of the same
|
|
// satellites and they drifted apart. This is the followed set (or every
|
|
// satellite with a frequency plan, when none is followed), plus anything the
|
|
// old hand-kept list still holds so nobody's typing is thrown away.
|
|
//
|
|
// SAT_NAME is compared character for character by the awards and by LoTW, so
|
|
// offering the spelling already used beats inventing a new one every pass.
|
|
func (a *App) GetSatelliteNames() []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
add := func(n string) {
|
|
n = strings.ToUpper(strings.TrimSpace(n))
|
|
if n == "" || seen[n] {
|
|
return
|
|
}
|
|
seen[n] = true
|
|
out = append(out, n)
|
|
}
|
|
for _, n := range a.satNames(nil) {
|
|
add(n)
|
|
}
|
|
// The legacy list. Read, never written: the panel that edited it is gone,
|
|
// and what it holds is somebody's past work.
|
|
if a.settings != nil {
|
|
if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
|
|
var legacy []string
|
|
if json.Unmarshal([]byte(raw), &legacy) == nil {
|
|
for _, n := range legacy {
|
|
add(n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
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
|
|
}
|
|
|
|
// SatSkyPoint is one moment of a pass as the antenna sees it.
|
|
type SatSkyPoint struct {
|
|
At time.Time `json:"at"`
|
|
Az float64 `json:"az"`
|
|
El float64 `json:"el"`
|
|
}
|
|
|
|
// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
|
|
//
|
|
// The map answers "where is it over the earth"; this answers "where do I look",
|
|
// which on a pass is the question that matters. An operator reading a polar
|
|
// plot knows in one glance whether the bird comes over the top or clips the
|
|
// horizon behind the house — something no amount of azimuth and elevation
|
|
// digits conveys.
|
|
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
|
|
if points < 8 || points > 400 {
|
|
points = 120
|
|
}
|
|
p, err := a.GetSatelliteNextPass(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !p.HasPass {
|
|
return nil, nil
|
|
}
|
|
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()
|
|
span := p.LOS.Sub(p.AOS)
|
|
if span <= 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]SatSkyPoint, 0, points+1)
|
|
for i := 0; i <= points; i++ {
|
|
at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
|
|
pos, err := store.Track(real, obs, at)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Below the horizon at the very ends, by a fraction of a degree, because
|
|
// the pass boundaries come from a coarser search than this sampling. A
|
|
// negative elevation would draw the track outside the horizon circle.
|
|
if pos.El < 0 {
|
|
pos.El = 0
|
|
}
|
|
out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSatelliteNextPass is the pass in progress, or the next one to come.
|
|
//
|
|
// The one question that decides whether an operator sits down at the radio, and
|
|
// the reason a satellite tab is worth having at all: how long have I got, and
|
|
// how high does it get.
|
|
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
|
|
out := SatPassInfo{Name: name}
|
|
obs, err := a.satObserver()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
real, ok := a.satResolve(name)
|
|
if !ok {
|
|
return out, fmt.Errorf("%s is not in the element set", name)
|
|
}
|
|
store, _, _ := a.satParts()
|
|
now := time.Now().UTC()
|
|
// From a little before now: a pass that started two minutes ago is the one
|
|
// the operator is in, and asking from this instant would skip it and report
|
|
// the next orbit instead — an hour and a half away, while the satellite is
|
|
// overhead.
|
|
from := now.Add(-30 * time.Minute)
|
|
// Elevation zero, not the operator's minimum. That threshold filters the
|
|
// table of passes worth waiting for; it must not hide the pass they are
|
|
// actually working.
|
|
passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for _, p := range passes {
|
|
if p.LOS.Before(now) {
|
|
continue // already over
|
|
}
|
|
out.HasPass = true
|
|
out.InPass = !p.AOS.After(now)
|
|
out.AOS, out.LOS = p.AOS, p.LOS
|
|
out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
|
|
out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
|
|
out.Duration = p.Duration
|
|
return out, nil
|
|
}
|
|
return out, 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.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
|
|
out.Visible = p.Visible()
|
|
return out, nil
|
|
}
|