284 lines
7.0 KiB
Go
284 lines
7.0 KiB
Go
package tle
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// Celestrak amateur satellite TLE feed (primary)
|
|
PrimaryURL = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"
|
|
FallbackURL = "http://tle.pe0sat.nl/kepler/amateur.txt"
|
|
CacheFile = "satmaster_tle_cache.txt"
|
|
MaxAgeDays = 3
|
|
)
|
|
|
|
// Satellite holds parsed TLE data.
|
|
type Satellite struct {
|
|
Name string
|
|
TLE1 string
|
|
TLE2 string
|
|
}
|
|
|
|
// Manager handles TLE fetching, caching, and lookup.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
satellites map[string]*Satellite
|
|
fetchedAt time.Time
|
|
cacheDir string
|
|
}
|
|
|
|
func NewManager() *Manager {
|
|
cacheDir, _ := os.UserCacheDir()
|
|
cacheDir = filepath.Join(cacheDir, "SatMaster")
|
|
os.MkdirAll(cacheDir, 0755)
|
|
return &Manager{
|
|
satellites: make(map[string]*Satellite),
|
|
cacheDir: cacheDir,
|
|
}
|
|
}
|
|
|
|
// fetchURLResult holds the result of a single URL fetch.
|
|
type fetchURLResult struct {
|
|
url string
|
|
sats map[string]*Satellite
|
|
raw string
|
|
err error
|
|
}
|
|
|
|
// FetchAndCache downloads TLE data from all sources in parallel, merges them
|
|
// (primary takes precedence on duplicates), and saves to disk cache.
|
|
func (m *Manager) FetchAndCache() error {
|
|
urls := []string{PrimaryURL, FallbackURL}
|
|
results := make([]fetchURLResult, len(urls))
|
|
|
|
// Fetch all sources concurrently
|
|
var wg sync.WaitGroup
|
|
for i, url := range urls {
|
|
wg.Add(1)
|
|
go func(i int, url string) {
|
|
defer wg.Done()
|
|
log.Printf("[TLE] Fetching from %s", url)
|
|
data, err := fetchURL(url)
|
|
if err != nil {
|
|
log.Printf("[TLE] Failed %s: %v", url, err)
|
|
results[i] = fetchURLResult{url: url, err: err}
|
|
return
|
|
}
|
|
sats, err := parseTLE(data)
|
|
if err != nil || len(sats) == 0 {
|
|
log.Printf("[TLE] Parse failed or empty from %s: %v", url, err)
|
|
results[i] = fetchURLResult{url: url, err: fmt.Errorf("parse failed")}
|
|
return
|
|
}
|
|
log.Printf("[TLE] Fetched %d satellites from %s", len(sats), url)
|
|
results[i] = fetchURLResult{url: url, sats: sats, raw: data}
|
|
}(i, url)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Merge: start with fallback (lower priority), then overlay primary
|
|
// This way primary always wins on duplicates
|
|
merged := make(map[string]*Satellite)
|
|
var combinedRaw strings.Builder
|
|
anySuccess := false
|
|
|
|
for i := len(results) - 1; i >= 0; i-- {
|
|
r := results[i]
|
|
if r.err != nil || r.sats == nil {
|
|
continue
|
|
}
|
|
anySuccess = true
|
|
added := 0
|
|
for k, v := range r.sats {
|
|
if _, exists := merged[k]; !exists {
|
|
merged[k] = v
|
|
added++
|
|
}
|
|
}
|
|
combinedRaw.WriteString(r.raw)
|
|
log.Printf("[TLE] Merged %d new satellites from %s (total: %d)", added, r.url, len(merged))
|
|
}
|
|
|
|
if !anySuccess {
|
|
return fmt.Errorf("all TLE sources failed")
|
|
}
|
|
|
|
// Save combined raw data to disk cache
|
|
cachePath := filepath.Join(m.cacheDir, CacheFile)
|
|
if err := os.WriteFile(cachePath, []byte(combinedRaw.String()), 0644); err != nil {
|
|
log.Printf("[TLE] Cache write failed: %v", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.satellites = merged
|
|
m.fetchedAt = time.Now()
|
|
m.mu.Unlock()
|
|
|
|
log.Printf("[TLE] Total: %d satellites loaded from %d sources", len(merged), len(urls))
|
|
return nil
|
|
}
|
|
|
|
// LoadLocal loads TLE data from disk cache.
|
|
func (m *Manager) LoadLocal() error {
|
|
cachePath := filepath.Join(m.cacheDir, CacheFile)
|
|
data, err := os.ReadFile(cachePath)
|
|
if err != nil {
|
|
return m.loadBundledFallback()
|
|
}
|
|
sats, err := parseTLE(string(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, _ := os.Stat(cachePath)
|
|
m.mu.Lock()
|
|
m.satellites = sats
|
|
if info != nil {
|
|
m.fetchedAt = info.ModTime()
|
|
}
|
|
m.mu.Unlock()
|
|
log.Printf("[TLE] Loaded %d satellites from local cache", len(sats))
|
|
return nil
|
|
}
|
|
|
|
// loadBundledFallback loads a minimal built-in TLE set for common amateur sats.
|
|
func (m *Manager) loadBundledFallback() error {
|
|
sats, err := parseTLE(defaultTLEs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
m.satellites = sats
|
|
m.fetchedAt = time.Now().Add(-48 * time.Hour)
|
|
m.mu.Unlock()
|
|
log.Printf("[TLE] Loaded %d bundled fallback satellites", len(sats))
|
|
return nil
|
|
}
|
|
|
|
// Get returns a satellite by name (case-insensitive).
|
|
func (m *Manager) Get(name string) *Satellite {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.satellites[strings.ToUpper(name)]; ok {
|
|
return s
|
|
}
|
|
upper := strings.ToUpper(name)
|
|
for k, v := range m.satellites {
|
|
if strings.Contains(k, upper) {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// All returns all loaded satellites.
|
|
func (m *Manager) All() []*Satellite {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
result := make([]*Satellite, 0, len(m.satellites))
|
|
for _, s := range m.satellites {
|
|
result = append(result, s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SatelliteNames returns sorted list of satellite names.
|
|
func (m *Manager) SatelliteNames() []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
names := make([]string, 0, len(m.satellites))
|
|
for k := range m.satellites {
|
|
names = append(names, k)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// AgeHours returns how many hours old the TLE data is.
|
|
func (m *Manager) AgeHours() float64 {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if m.fetchedAt.IsZero() {
|
|
return 9999
|
|
}
|
|
return time.Since(m.fetchedAt).Hours()
|
|
}
|
|
|
|
func fetchURL(url string) (string, error) {
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
return string(body), err
|
|
}
|
|
|
|
func parseTLE(data string) (map[string]*Satellite, error) {
|
|
sats := make(map[string]*Satellite)
|
|
scanner := bufio.NewScanner(strings.NewReader(data))
|
|
var lines []string
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
for i := 0; i+2 < len(lines); i += 3 {
|
|
name := strings.TrimSpace(lines[i])
|
|
tle1 := strings.TrimSpace(lines[i+1])
|
|
tle2 := strings.TrimSpace(lines[i+2])
|
|
|
|
if !strings.HasPrefix(tle1, "1 ") || !strings.HasPrefix(tle2, "2 ") {
|
|
i -= 2
|
|
continue
|
|
}
|
|
|
|
key := strings.ToUpper(name)
|
|
sats[key] = &Satellite{
|
|
Name: name,
|
|
TLE1: tle1,
|
|
TLE2: tle2,
|
|
}
|
|
}
|
|
|
|
if len(sats) == 0 {
|
|
return nil, fmt.Errorf("no valid TLE entries found")
|
|
}
|
|
return sats, nil
|
|
}
|
|
|
|
const defaultTLEs = `ISS (ZARYA)
|
|
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9999
|
|
2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50174753471696
|
|
AO-7
|
|
1 07530U 74089B 24001.50000000 -.00000023 00000-0 13694-4 0 9999
|
|
2 07530 101.7044 116.7600 0012184 36.7810 323.4116 12.53590024534125
|
|
AO-27
|
|
1 22825U 93061C 24001.50000000 .00000199 00000-0 54717-4 0 9999
|
|
2 22825 98.6398 100.2553 0008530 335.2107 24.8705 14.29831944581475
|
|
SO-50
|
|
1 27607U 02058C 24001.50000000 .00000306 00000-0 71007-4 0 9999
|
|
2 27607 98.0070 105.9740 0084804 339.5060 19.9560 14.74561589148781
|
|
FO-29
|
|
1 24278U 96046B 24001.50000000 .00000056 00000-0 68723-5 0 9999
|
|
2 24278 98.5355 100.4746 0350771 334.0040 24.2890 13.52863590383849
|
|
RS-44
|
|
1 44909U 19096E 24001.50000000 .00000103 00000-0 12583-4 0 9999
|
|
2 44909 97.6561 101.2437 0009786 341.6020 18.4590 14.93614256218765
|
|
`
|