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 `