first commit
This commit is contained in:
283
backend/tle/manager.go
Normal file
283
backend/tle/manager.go
Normal file
@@ -0,0 +1,283 @@
|
||||
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
|
||||
`
|
||||
Reference in New Issue
Block a user