283 lines
7.8 KiB
Go
283 lines
7.8 KiB
Go
package orbitviewer
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SatelliteCatalogEntry holds metadata and orbital data for a satellite.
|
|
type SatelliteCatalogEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
NoradID int `json:"norad_id"`
|
|
Description string `json:"description"`
|
|
StandardMag float64 `json:"standard_mag"`
|
|
TLE TLE `json:"tle"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type CatalogManager struct {
|
|
mu sync.RWMutex
|
|
satellites map[string]*SatelliteCatalogEntry
|
|
httpClient *http.Client
|
|
}
|
|
|
|
var DefaultCatalog = NewCatalogManager()
|
|
|
|
func NewCatalogManager() *CatalogManager {
|
|
cm := &CatalogManager{
|
|
satellites: make(map[string]*SatelliteCatalogEntry),
|
|
httpClient: &http.Client{Timeout: 8 * time.Second},
|
|
}
|
|
cm.initPresets()
|
|
// Fetch fresh live TLEs from CelesTrak in background
|
|
go cm.refreshAllBackground()
|
|
return cm
|
|
}
|
|
|
|
func (cm *CatalogManager) refreshAllBackground() {
|
|
time.Sleep(500 * time.Millisecond)
|
|
cm.mu.RLock()
|
|
ids := make([]string, 0, len(cm.satellites))
|
|
for id := range cm.satellites {
|
|
ids = append(ids, id)
|
|
}
|
|
cm.mu.RUnlock()
|
|
|
|
for _, id := range ids {
|
|
_ = cm.RefreshFromCelesTrak(id)
|
|
time.Sleep(200 * time.Millisecond) // avoid hammering celestrak
|
|
}
|
|
}
|
|
|
|
func (cm *CatalogManager) initPresets() {
|
|
presets := []struct {
|
|
id string
|
|
name string
|
|
noradID int
|
|
description string
|
|
standardMag float64
|
|
line1 string
|
|
line2 string
|
|
}{
|
|
{
|
|
id: "iss",
|
|
name: "ISS (International Space Station)",
|
|
noradID: 25544,
|
|
description: "The largest modular space station currently in low Earth orbit, frequently visible to the naked eye.",
|
|
standardMag: -1.8,
|
|
line1: "1 25544U 98067A 26265.50000000 .00014524 00000-0 26388-3 0 9993",
|
|
line2: "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566",
|
|
},
|
|
{
|
|
id: "tiangong",
|
|
name: "Tiangong (CSS)",
|
|
noradID: 48274,
|
|
description: "Chinese space station in low Earth orbit between 340 and 450 km altitude.",
|
|
standardMag: -1.0,
|
|
line1: "1 48274U 21035A 26265.45678901 .00021456 00000-0 31456-3 0 9991",
|
|
line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890",
|
|
},
|
|
{
|
|
id: "hubble",
|
|
name: "Hubble Space Telescope (HST)",
|
|
noradID: 20580,
|
|
description: "Famous NASA/ESA optical space observatory operating in orbit since 1990.",
|
|
standardMag: 1.5,
|
|
line1: "1 20580U 90037B 26265.34567890 .00001234 00000-0 54321-4 0 9992",
|
|
line2: "2 20580 28.4691 245.1234 0002845 280.1234 79.8765 15.08765432854321",
|
|
},
|
|
{
|
|
id: "noaa19",
|
|
name: "NOAA 19",
|
|
noradID: 33591,
|
|
description: "Polar-orbiting operational weather satellite operated by NOAA.",
|
|
standardMag: 3.5,
|
|
line1: "1 33591U 09005A 26265.23456789 .00000123 00000-0 65432-4 0 9995",
|
|
line2: "2 33591 98.7123 145.6789 0014234 65.4321 294.7890 14.12345678789012",
|
|
},
|
|
{
|
|
id: "terra",
|
|
name: "Terra (EOS AM-1)",
|
|
noradID: 25994,
|
|
description: "NASA flagship Earth-observing satellite in a sun-synchronous orbit.",
|
|
standardMag: 2.0,
|
|
line1: "1 25994U 99068A 26265.12345678 .00000234 00000-0 76543-4 0 9998",
|
|
line2: "2 25994 98.2123 210.3456 0001234 110.1234 250.0123 14.58765432298765",
|
|
},
|
|
{
|
|
id: "envisat",
|
|
name: "Envisat",
|
|
noradID: 27386,
|
|
description: "Large inactive European Earth observation satellite, bright and easily tracked.",
|
|
standardMag: 2.0,
|
|
line1: "1 27386U 02009A 26265.65432109 .00000345 00000-0 87654-4 0 9997",
|
|
line2: "2 27386 98.5432 78.9012 0001567 95.4321 264.7654 14.37654321154321",
|
|
},
|
|
{
|
|
id: "starlink",
|
|
name: "Starlink (Sample)",
|
|
noradID: 44713,
|
|
description: "SpaceX Starlink low Earth orbit communications constellation satellite.",
|
|
standardMag: 4.5,
|
|
line1: "1 44713U 19074A 26265.51234567 .00001567 00000-0 12345-3 0 9990",
|
|
line2: "2 44713 53.0543 180.1234 0001456 80.1234 280.0123 15.06543210234567",
|
|
},
|
|
}
|
|
|
|
for _, p := range presets {
|
|
tle, err := ParseTLE(p.name, p.line1, p.line2)
|
|
if err == nil {
|
|
cm.satellites[p.id] = &SatelliteCatalogEntry{
|
|
ID: p.id,
|
|
Name: p.name,
|
|
NoradID: p.noradID,
|
|
Description: p.description,
|
|
StandardMag: p.standardMag,
|
|
TLE: tle,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetAll returns all catalog entries sorted.
|
|
func (cm *CatalogManager) GetAll() []*SatelliteCatalogEntry {
|
|
cm.mu.RLock()
|
|
defer cm.mu.RUnlock()
|
|
|
|
result := make([]*SatelliteCatalogEntry, 0, len(cm.satellites))
|
|
for _, e := range cm.satellites {
|
|
result = append(result, e)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Get returns an entry by ID or NORAD ID string.
|
|
func (cm *CatalogManager) Get(idOrNorad string) (*SatelliteCatalogEntry, bool) {
|
|
cm.mu.RLock()
|
|
defer cm.mu.RUnlock()
|
|
|
|
if e, ok := cm.satellites[idOrNorad]; ok {
|
|
return e, true
|
|
}
|
|
for _, e := range cm.satellites {
|
|
if fmt.Sprintf("%d", e.NoradID) == idOrNorad {
|
|
return e, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// RefreshFromCelesTrak fetches updated TLE for a given catalog entry by NORAD ID.
|
|
func (cm *CatalogManager) RefreshFromCelesTrak(id string) error {
|
|
cm.mu.RLock()
|
|
entry, ok := cm.satellites[id]
|
|
cm.mu.RUnlock()
|
|
if !ok {
|
|
return fmt.Errorf("satellite ID %s not found in catalog", id)
|
|
}
|
|
|
|
urls := []string{
|
|
fmt.Sprintf("https://celestrak.org/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
|
|
fmt.Sprintf("https://celestrak.com/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
|
|
}
|
|
|
|
var body []byte
|
|
var lastErr error
|
|
for _, targetURL := range urls {
|
|
req, err := http.NewRequest("GET", targetURL, nil)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
|
req.Header.Set("Accept", "text/plain, */*")
|
|
|
|
resp, err := cm.httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
data, err := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if err == nil && len(data) > 60 {
|
|
body = data
|
|
lastErr = nil
|
|
break
|
|
}
|
|
} else {
|
|
_ = resp.Body.Close()
|
|
lastErr = fmt.Errorf("celestrak returned HTTP %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
if lastErr != nil && len(body) == 0 {
|
|
return lastErr
|
|
}
|
|
|
|
lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
|
|
var l1, l2, name string
|
|
for _, l := range lines {
|
|
l = strings.TrimSpace(l)
|
|
if strings.HasPrefix(l, "1 ") && len(l) >= 68 {
|
|
l1 = l
|
|
} else if strings.HasPrefix(l, "2 ") && len(l) >= 68 {
|
|
l2 = l
|
|
} else if l != "" && !strings.HasPrefix(l, "1 ") && !strings.HasPrefix(l, "2 ") && name == "" {
|
|
name = l
|
|
}
|
|
}
|
|
|
|
if l1 == "" || l2 == "" {
|
|
return fmt.Errorf("invalid TLE response from CelesTrak for NORAD %d", entry.NoradID)
|
|
}
|
|
|
|
if name == "" {
|
|
name = entry.Name
|
|
}
|
|
|
|
tle, err := ParseTLE(name, l1, l2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cm.mu.Lock()
|
|
entry.TLE = tle
|
|
entry.UpdatedAt = time.Now()
|
|
cm.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddCustomTLE adds or updates a custom user-supplied TLE into the catalog.
|
|
func (cm *CatalogManager) AddCustomTLE(name, line1, line2 string) (*SatelliteCatalogEntry, error) {
|
|
tle, err := ParseTLE(name, line1, line2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := fmt.Sprintf("custom-%d", tle.NoradID)
|
|
entry := &SatelliteCatalogEntry{
|
|
ID: id,
|
|
Name: tle.Name,
|
|
NoradID: tle.NoradID,
|
|
Description: "User-defined custom satellite orbital parameters",
|
|
StandardMag: 3.0,
|
|
TLE: tle,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
cm.mu.Lock()
|
|
cm.satellites[id] = entry
|
|
cm.mu.Unlock()
|
|
|
|
return entry, nil
|
|
}
|