Files
SatMaster/backend/tle/manager.go
2026-03-24 23:24:36 +01:00

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
`