Files
OpsLog/app_sat.go
T
rouggyandClaude Opus 5 86fd03fd6b feat(sat): generate the frequency plan, and join on the catalog number
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]>
2026-09-09 11:20:22 +02:00

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)
}