25 satellites, typed by hand and never revisited. Eight of them were the
first-generation Tevel constellation, which re-entered in 2024; four more had
come down too; and the nine Tevel-2 satellites that replaced them, the Chinese
space station, AO-27, AO-123 and twenty others were simply absent. So: 44
satellites now, and a generator instead of a memory.
cmd/satgen joins three public sources on the NORAD catalog number — Celestrak's
amateur group and PE0SAT's mirror for which birds OpsLog can actually get
elements for, and SatNOGS DB for the transmitters. It is a one-shot tool, run by
hand, the same arrangement as cmd/cntygen, and it is deliberately conservative:
- It never destroys a curated entry. The hand-written plans hold things
SatNOGS does not reliably carry — a CTCSS tone, the QO-100 passband as
operators describe it — so an existing bird keeps its data and only gains
its catalog number.
- It prunes on the re-entry date, which is a fact SatNOGS publishes rather
than a judgement about which of the missing satellites are missing for good.
- It refuses a digital uplink that does not say what it is. A GMSK uplink is a
command channel far more often than a digipeater, and shipping the wrong one
invites somebody to transmit on a control frequency. An analog uplink with
both ends is a contact by construction, which is what catches the repeaters
that describe themselves only as "Mode V/U FM".
- Its output is deterministic. One satellite can hold two catalog entries —
GreenCube is 53106 in one feed and 53109 in the other — and iterating a map
picked a different one each run.
A bird now carries its NORAD number, and that is how its elements are found.
Names were the only join before, and they are written differently by every party
involved: "TIANYAN 01" and "TO-108" are one satellite that had never once met,
so TO-108 tracked nothing at all.
And the plan now reaches a station that has already run OpsLog. The editable
copy was written on the first launch and was the operator's list for ever after,
so a release adding nine satellites reached nobody who had opened the tab. It is
merged on each load instead: a satellite they already have is untouched, edits
and corrections included, and only the ones they have never seen are added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1024 lines
34 KiB
Go
1024 lines
34 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.
|
|
keySatRotOn = "sat.rot_enabled"
|
|
// WHICH rotor, out of the ones configured in Settings ▸ Rotator — the key
|
|
// flattenRotors gives it. How to reach it is that list's business, not
|
|
// this page's: describing one mast in two places is how a station ends up
|
|
// working on HF and not on a pass.
|
|
keySatRotID = "sat.rot_id"
|
|
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
|
|
|
|
// The satellite page used to configure its own EasyComm or PstRotator link.
|
|
// These keys are read once by migrateSatRotator, which turns what they hold
|
|
// into a real entry in the rotator list, and are never written again.
|
|
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
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// RotID names one of the rotors configured in Settings ▸ Rotator — the
|
|
// key flattenRotors gives it. Everything about HOW to reach that rotator
|
|
// (backend, host, COM port, baud, 360/450) belongs to the rotator list and
|
|
// is deliberately not repeated here.
|
|
//
|
|
// What IS here is the tracking policy, which is the satellite page's own
|
|
// business and means nothing to a rotor turned by hand: below which
|
|
// elevation not to bother, how far the antenna must be off before a command
|
|
// is worth sending, and whether to park at the end.
|
|
RotOn bool `json:"rot_on"`
|
|
RotID string `json:"rot_id"`
|
|
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() {
|
|
// Before anything else reads the rotator choice: an operator upgrading from
|
|
// the version where the satellite page held its own rotator link must find
|
|
// that mast already in the list and already selected.
|
|
a.migrateSatRotator()
|
|
|
|
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 {
|
|
// 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,
|
|
RotMinEl: 0, RotStep: 5,
|
|
}
|
|
if a.settings == nil {
|
|
return out
|
|
}
|
|
m, err := a.settings.GetMany(a.ctx,
|
|
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
|
keySatRotOn, keySatRotID, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
out.RotOn = m[keySatRotOn] == "1"
|
|
out.RotID = strings.TrimSpace(m[keySatRotID])
|
|
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.RotStep < 1 || s.RotStep > 30 {
|
|
s.RotStep = 5
|
|
}
|
|
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),
|
|
keySatRotID: strings.TrimSpace(s.RotID),
|
|
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) {
|
|
// The catalog number first: it is exact, and it is what the generated plan
|
|
// carries. Everything below is for the hand-written entries that have none.
|
|
if e, ok := store.GetNORAD(b.NORAD); ok {
|
|
return e, true
|
|
}
|
|
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
|
|
}
|
|
|
|
// migrateSatRotator moves a pre-list satellite rotator into Settings ▸ Rotator.
|
|
//
|
|
// Until now the satellite page configured its own EasyComm or PstRotator link,
|
|
// separately from the rotator list every other backend lived in. An operator who
|
|
// had set one up must not open OpsLog to an empty dropdown and a mast that no
|
|
// longer turns — so the old keys are read once, turned into a real rotor in the
|
|
// list, and the satellite page is pointed at it.
|
|
//
|
|
// Runs once. The legacy keys are cleared afterwards so a second run cannot add
|
|
// the same mast a second time, and so the next reader of this file is not left
|
|
// wondering which of the two copies is live.
|
|
func (a *App) migrateSatRotator() {
|
|
if a.settings == nil {
|
|
return
|
|
}
|
|
m, err := a.settings.GetMany(a.ctx,
|
|
keySatRotID, keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(m[keySatRotID]) != "" {
|
|
return // already migrated, or configured since
|
|
}
|
|
legacy := strings.TrimSpace(m[keySatRotType])
|
|
if legacy == "" {
|
|
return // the satellite rotator was never configured
|
|
}
|
|
|
|
atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
|
dev := RotatorDevice{
|
|
ID: fmt.Sprintf("rotor-sat-%d", time.Now().Unix()),
|
|
Name: "Satellite",
|
|
MaxAz: atoi(m[keySatRotMaxAz]),
|
|
// A satellite rotor carries a fixed antenna, not a motorized Ultrabeam
|
|
// or SteppIR: showing it pattern paths would be showing it something it
|
|
// cannot do.
|
|
Motorized: false,
|
|
}
|
|
switch legacy {
|
|
case satRotPst:
|
|
dev.Type = "pst"
|
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
|
dev.Port = atoi(m[keySatRotPstPort])
|
|
// It was in the satellite settings, so it has elevation by construction.
|
|
dev.HasElevation = true
|
|
default:
|
|
dev.Type = "easycomm"
|
|
dev.Transport = strings.TrimSpace(m[keySatRotTransport])
|
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
|
dev.Port = atoi(m[keySatRotPort])
|
|
dev.ComPort = strings.TrimSpace(m[keySatRotCOM])
|
|
dev.Baud = atoi(m[keySatRotBaud])
|
|
}
|
|
|
|
list, err := a.GetRotators()
|
|
if err != nil {
|
|
applog.Printf("satellite: cannot read the rotator list to migrate the satellite rotator: %v", err)
|
|
return
|
|
}
|
|
list = append(list, dev)
|
|
if err := a.SaveRotators(list); err != nil {
|
|
applog.Printf("satellite: cannot save the migrated satellite rotator: %v", err)
|
|
return
|
|
}
|
|
if err := a.settings.Set(a.ctx, keySatRotID, dev.ID); err != nil {
|
|
applog.Printf("satellite: migrated the rotator but could not select it: %v", err)
|
|
return
|
|
}
|
|
// Clear the old keys so this cannot run twice.
|
|
for _, k := range []string{keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz} {
|
|
_ = a.settings.Set(a.ctx, k, "")
|
|
}
|
|
applog.Printf("satellite: the %s rotator configured on the satellite page is now %q in Settings ▸ Rotator", legacy, dev.Name)
|
|
}
|