first commit

This commit is contained in:
2026-03-24 23:24:36 +01:00
commit a69394a05b
1638 changed files with 891299 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
package doppler
import (
"fmt"
"math"
"sync"
"time"
"SatMaster/backend/propagator"
)
const (
SpeedOfLight = 299792.458 // km/s
)
// Calculator computes Doppler-shifted frequencies.
type Calculator struct {
mu sync.RWMutex
nominalDown float64 // Hz
nominalUp float64 // Hz
obsLat float64
obsLon float64
obsAlt float64
}
func NewCalculator() *Calculator {
return &Calculator{}
}
func (c *Calculator) SetObserver(lat, lon, altM float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.obsLat = lat
c.obsLon = lon
c.obsAlt = altM
}
func (c *Calculator) SetNominal(downHz, upHz float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.nominalDown = downHz
c.nominalUp = upHz
}
// Correct computes Doppler-corrected downlink and uplink frequencies.
// Returns (downlinkHz, uplinkHz).
func (c *Calculator) Correct(pos *propagator.SatPosition, obs propagator.Observer, _ time.Time) (float64, float64) {
c.mu.RLock()
nomDown := c.nominalDown
nomUp := c.nominalUp
c.mu.RUnlock()
if nomDown == 0 && nomUp == 0 {
return 0, 0
}
if pos == nil {
return nomDown, nomUp
}
// Range rate in km/s (positive = receding, negative = approaching)
rr := pos.RangeRate
// Doppler factor: f_received = f_nominal * (1 - v/c)
// For downlink: satellite is the transmitter
dopplerFactor := 1.0 - rr/SpeedOfLight
correctedDown := nomDown * dopplerFactor
// For uplink: we pre-correct in reverse so the satellite receives nominal
correctedUp := nomUp / dopplerFactor
return correctedDown, correctedUp
}
// ShiftHz returns the Doppler shift in Hz for a given nominal frequency.
func ShiftHz(nominalHz, rangeRateKmS float64) float64 {
return nominalHz * (-rangeRateKmS / SpeedOfLight)
}
// RangeRateFromPositions computes range rate from two consecutive positions.
func RangeRateFromPositions(prev, curr *propagator.SatPosition, dt float64) float64 {
prevRange := prev.Range
currRange := curr.Range
return (currRange - prevRange) / dt
}
// FormatShift formats a Doppler shift in Hz for display.
func FormatShift(shiftHz float64) string {
if math.Abs(shiftHz) >= 1000 {
return fmt.Sprintf("%+.2f kHz", shiftHz/1000)
}
return fmt.Sprintf("%+.0f Hz", shiftHz)
}

241
backend/flexradio/client.go Normal file
View File

@@ -0,0 +1,241 @@
package flexradio
import (
"bufio"
"fmt"
"log"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
// Client manages a TCP connection to FlexRadio SmartSDR API.
// For satellite operation:
// - Slice A (index 0) = RX downlink (436 MHz for SO-50)
// - Slice B (index 1) = TX uplink (145 MHz for SO-50)
type Client struct {
mu sync.Mutex
conn net.Conn
scanner *bufio.Scanner
connected atomic.Bool
seqNum uint32
rxSlice int // Slice index for RX (downlink) — default 0 = Slice A
txSlice int // Slice index for TX (uplink) — default 1 = Slice B
lastDownHz float64 // Last sent RX frequency (dead-band)
lastUpHz float64 // Last sent TX frequency (dead-band)
}
func NewClient() *Client {
return &Client{rxSlice: 0, txSlice: 1} // defaults — always overridden by SetSliceConfig on connect
}
// Connect establishes TCP connection to FlexRadio SmartSDR API (port 4992).
func (c *Client) Connect(host string, port int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected.Load() {
c.disconnectLocked()
}
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("connexion FlexRadio %s : %w", addr, err)
}
c.conn = conn
c.scanner = bufio.NewScanner(conn)
c.connected.Store(true)
go c.readLoop()
log.Printf("[FlexRadio] Connecté à %s (RX=Slice %s, TX=Slice %s)",
addr, sliceLetter(c.rxSlice), sliceLetter(c.txSlice))
return nil
}
// Disconnect closes the connection.
func (c *Client) Disconnect() {
c.mu.Lock()
defer c.mu.Unlock()
c.disconnectLocked()
}
func (c *Client) disconnectLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connected.Store(false)
log.Println("[FlexRadio] Déconnecté")
}
func (c *Client) IsConnected() bool {
return c.connected.Load()
}
// SetFrequency applies Doppler-corrected frequencies to both slices.
// downHz = corrected RX frequency → rxSlice (default Slice A)
// upHz = corrected TX frequency → txSlice (default Slice B)
// Only sends command if frequency changed by more than 1 Hz (dead-band)
func (c *Client) SetFrequency(downHz, upHz float64) error {
if !c.connected.Load() {
return fmt.Errorf("non connecté")
}
var errs []error
// RX downlink — use "slice t" (tune) command
if downHz > 0 {
downMHz := downHz / 1e6
if abs(downHz-c.lastDownHz) >= 1.0 { // 1 Hz dead-band
cmd := fmt.Sprintf("slice t %d %.6f", c.rxSlice, downMHz)
if err := c.sendCommand(cmd); err != nil {
errs = append(errs, err)
} else {
c.lastDownHz = downHz
}
}
}
// TX uplink — use "slice t" (tune) command
if upHz > 0 {
upMHz := upHz / 1e6
if abs(upHz-c.lastUpHz) >= 1.0 {
cmd := fmt.Sprintf("slice t %d %.6f", c.txSlice, upMHz)
if err := c.sendCommand(cmd); err != nil {
errs = append(errs, err)
} else {
c.lastUpHz = upHz
}
}
}
if len(errs) > 0 {
return fmt.Errorf("FlexRadio SetFrequency: %v", errs)
}
return nil
}
// ResetDeadband forces next frequency command to be sent regardless of change.
func (c *Client) ResetDeadband() {
c.lastDownHz = 0
c.lastUpHz = 0
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// SetSlices configures which slice indices to use for RX and TX.
// Default: rx=0 (Slice A), tx=1 (Slice B)
func (c *Client) SetSlices(rxIdx, txIdx int) {
c.mu.Lock()
defer c.mu.Unlock()
c.rxSlice = rxIdx
c.txSlice = txIdx
log.Printf("[FlexRadio] Slices configurées: RX=Slice %s, TX=Slice %s",
sliceLetter(rxIdx), sliceLetter(txIdx))
}
// GetSlices returns current RX and TX slice indices.
func (c *Client) GetSlices() (rx, tx int) {
return c.rxSlice, c.txSlice
}
// QuerySlices sends a slice list request to discover available slices.
func (c *Client) QuerySlices() error {
return c.sendCommand("slice list")
}
// SetMode sets the demodulation mode on RX and/or TX slices.
// mode: "usb", "lsb", "cw", "am", "sam", "fm", "nfm", "dfm", "digl", "digu", "rtty"
// For satellite: RX and TX usually have the same mode (FM for FM sats, LSB/USB for linear)
func (c *Client) SetMode(mode string) error {
if !c.connected.Load() {
return fmt.Errorf("not connected")
}
mode = strings.ToLower(strings.TrimSpace(mode))
var errs []error
// Set mode on RX slice
if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.rxSlice, mode)); err != nil {
errs = append(errs, fmt.Errorf("RX mode: %w", err))
}
// Set mode on TX slice (same mode for satellite split operation)
if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.txSlice, mode)); err != nil {
errs = append(errs, fmt.Errorf("TX mode: %w", err))
}
if len(errs) > 0 {
return fmt.Errorf("SetMode: %v", errs)
}
log.Printf("[FlexRadio] Mode set to %s on slices %s/%s",
strings.ToUpper(mode), sliceLetter(c.rxSlice), sliceLetter(c.txSlice))
return nil
}
// SatModeToFlex converts a satellite DB mode string to FlexRadio mode.
// FM satellites use "fm", linear transponders use "lsb" or "usb".
func SatModeToFlex(satMode string) string {
switch strings.ToUpper(strings.TrimSpace(satMode)) {
case "FM", "NFM":
return "fm"
case "LSB":
return "lsb"
case "USB":
return "usb"
case "CW", "CW/DATA":
return "cw"
case "APRS", "DATA", "DIGI":
return "digl"
case "AM":
return "am"
default:
return "usb" // safe default
}
}
func (c *Client) sendCommand(cmd string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("non connecté")
}
seq := atomic.AddUint32(&c.seqNum, 1)
line := fmt.Sprintf("C%d|%s\n", seq, cmd)
c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
_, err := fmt.Fprint(c.conn, line)
if err != nil {
c.connected.Store(false)
return fmt.Errorf("envoi commande: %w", err)
}
log.Printf("[FlexRadio] → %s", strings.TrimSpace(line))
return nil
}
func (c *Client) readLoop() {
for c.scanner.Scan() {
line := c.scanner.Text()
// Log ALL responses for debugging
log.Printf("[FlexRadio] ← %s", line)
}
c.connected.Store(false)
log.Println("[FlexRadio] Disconnected")
}
func sliceLetter(idx int) string {
letters := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
if idx < len(letters) {
return letters[idx]
}
return fmt.Sprintf("%d", idx)
}

View File

@@ -0,0 +1,337 @@
package propagator
import (
"fmt"
"math"
"sort"
"sync"
"time"
"SatMaster/backend/tle"
"github.com/akhenakh/sgp4"
)
const (
EarthRadius = 6371.0 // km
RadToDeg = 180.0 / math.Pi
)
// SatPosition holds the computed position of a satellite at a given moment.
type SatPosition struct {
Name string `json:"name"`
Latitude float64 `json:"lat"`
Longitude float64 `json:"lon"`
Altitude float64 `json:"alt"` // km
Azimuth float64 `json:"az"` // degrees 0=N
Elevation float64 `json:"el"` // degrees above horizon
Range float64 `json:"range"` // km from observer
RangeRate float64 `json:"rangeRate"` // km/s positive=receding
Footprint float64 `json:"footprint"` // km radius on ground
}
// Pass represents one overhead pass of a satellite.
type Pass struct {
SatName string `json:"satName"`
AOS time.Time `json:"aos"`
LOS time.Time `json:"los"`
MaxEl float64 `json:"maxEl"`
MaxElTime time.Time `json:"maxElTime"`
AosAz float64 `json:"aosAz"`
LosAz float64 `json:"losAz"`
MaxElAz float64 `json:"maxElAz"`
Duration float64 `json:"duration"` // seconds
Points []PassPoint `json:"points"`
}
// PassPoint is one az/el sample in a pass track.
type PassPoint struct {
Time time.Time `json:"t"`
Az float64 `json:"az"`
El float64 `json:"el"`
}
// Observer holds the QTH location.
type Observer struct {
Lat float64 // degrees
Lon float64 // degrees
Alt float64 // meters above sea level
}
// Engine propagates satellite positions using SGP4.
type Engine struct {
mu sync.RWMutex
observer Observer
}
func NewEngine() *Engine {
return &Engine{
observer: Observer{Lat: 45.75, Lon: 4.85, Alt: 200}, // Default: Lyon area
}
}
func (e *Engine) SetObserver(lat, lon, altM float64) {
e.mu.Lock()
defer e.mu.Unlock()
e.observer = Observer{Lat: lat, Lon: lon, Alt: altM}
}
func (e *Engine) ObserverPosition() Observer {
e.mu.RLock()
defer e.mu.RUnlock()
return e.observer
}
// Position computes the current position of one satellite.
func (e *Engine) Position(sat *tle.Satellite, t time.Time) *SatPosition {
e.mu.RLock()
obs := e.observer
e.mu.RUnlock()
return computePosition(sat, obs, t)
}
// AllPositions computes positions for all loaded satellites.
func (e *Engine) AllPositions(sats []*tle.Satellite, t time.Time) []SatPosition {
e.mu.RLock()
obs := e.observer
e.mu.RUnlock()
result := make([]SatPosition, 0, len(sats))
for _, sat := range sats {
if pos := computePosition(sat, obs, t); pos != nil {
result = append(result, *pos)
}
}
return result
}
// ComputePasses predicts upcoming passes over `hours` hours.
func (e *Engine) ComputePasses(sat *tle.Satellite, start time.Time, hours float64) []Pass {
e.mu.RLock()
obs := e.observer
e.mu.RUnlock()
var passes []Pass
end := start.Add(time.Duration(float64(time.Hour) * hours))
step := 10 * time.Second
t := start
var inPass bool
var cur *Pass
for t.Before(end) {
pos := computePosition(sat, obs, t)
if pos == nil {
t = t.Add(step)
continue
}
if pos.Elevation > 0 && !inPass {
inPass = true
aosT := bisectAOS(sat, obs, t.Add(-step), t)
aosPos := computePosition(sat, obs, aosT)
if aosPos == nil {
aosPos = pos
}
cur = &Pass{
SatName: sat.Name,
AOS: aosT,
AosAz: aosPos.Azimuth,
Points: []PassPoint{},
}
}
if inPass && cur != nil {
cur.Points = append(cur.Points, PassPoint{Time: t, Az: pos.Azimuth, El: pos.Elevation})
if pos.Elevation > cur.MaxEl {
cur.MaxEl = pos.Elevation
cur.MaxElTime = t
cur.MaxElAz = pos.Azimuth
}
}
if pos.Elevation <= 0 && inPass {
inPass = false
if cur != nil {
losT := bisectLOS(sat, obs, t.Add(-step), t)
losPos := computePosition(sat, obs, losT)
if losPos == nil {
losPos = pos
}
cur.LOS = losT
cur.LosAz = losPos.Azimuth
cur.Duration = losT.Sub(cur.AOS).Seconds()
if cur.MaxEl >= 1.0 {
passes = append(passes, *cur)
}
cur = nil
}
}
if pos.Elevation < -15 {
step = 30 * time.Second
} else {
step = 10 * time.Second
}
t = t.Add(step)
}
sort.Slice(passes, func(i, j int) bool {
return passes[i].AOS.Before(passes[j].AOS)
})
return passes
}
// ─── Core SGP4 computation ────────────────────────────────────────────────────
// parsedTLE caches the orbital elements for a satellite TLE.
// akhenakh/sgp4 ParseTLE accepts the 3-line TLE as a single string.
func makeTLEString(sat *tle.Satellite) string {
return fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2)
}
func computePosition(sat *tle.Satellite, obs Observer, t time.Time) *SatPosition {
// Parse TLE — akhenakh/sgp4 accepts the 3-line block as one string
tleObj, err := sgp4.ParseTLE(makeTLEString(sat))
if err != nil {
return nil
}
// Propagate to time t → ECI state with geodetic fields
eciState, err := tleObj.FindPositionAtTime(t)
if err != nil {
return nil
}
// Convert ECI to geodetic (lat deg, lon deg, alt km)
lat, lon, altKm := eciState.ToGeodetic()
// Build observer location
location := &sgp4.Location{
Latitude: obs.Lat, // degrees
Longitude: obs.Lon, // degrees
Altitude: obs.Alt, // meters
}
// Build state vector for look angle calculation
sv := &sgp4.StateVector{
X: eciState.Position.X,
Y: eciState.Position.Y,
Z: eciState.Position.Z,
VX: eciState.Velocity.X,
VY: eciState.Velocity.Y,
VZ: eciState.Velocity.Z,
}
// GetLookAngle uses eciState.DateTime (the propagated time) not t
observation, err := sv.GetLookAngle(location, eciState.DateTime)
if err != nil {
return nil
}
la := observation.LookAngles
footprint := EarthRadius * math.Acos(EarthRadius/(EarthRadius+altKm))
// Compute range rate by finite difference (2s) - library value is unreliable
rangeRate := finiteRangeRate(tleObj, location, t)
return &SatPosition{
Name: sat.Name,
Latitude: lat,
Longitude: lon,
Altitude: altKm,
Azimuth: la.Azimuth,
Elevation: la.Elevation,
Range: la.Range,
RangeRate: rangeRate,
Footprint: footprint,
}
}
// finiteRangeRate computes range rate (km/s) by finite difference over 2 seconds.
// Positive = satellite receding, negative = approaching.
func finiteRangeRate(tleObj *sgp4.TLE, loc *sgp4.Location, t time.Time) float64 {
dt := 2.0 // seconds
t2 := t.Add(time.Duration(dt * float64(time.Second)))
e1, err1 := tleObj.FindPositionAtTime(t)
e2, err2 := tleObj.FindPositionAtTime(t2)
if err1 != nil || err2 != nil {
return 0
}
sv1 := &sgp4.StateVector{X: e1.Position.X, Y: e1.Position.Y, Z: e1.Position.Z,
VX: e1.Velocity.X, VY: e1.Velocity.Y, VZ: e1.Velocity.Z}
sv2 := &sgp4.StateVector{X: e2.Position.X, Y: e2.Position.Y, Z: e2.Position.Z,
VX: e2.Velocity.X, VY: e2.Velocity.Y, VZ: e2.Velocity.Z}
obs1, err1 := sv1.GetLookAngle(loc, e1.DateTime)
obs2, err2 := sv2.GetLookAngle(loc, e2.DateTime)
if err1 != nil || err2 != nil {
return 0
}
return (obs2.LookAngles.Range - obs1.LookAngles.Range) / dt
}
// bisectAOS finds exact AOS time (precision: 1 second).
func bisectAOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time {
for hi.Sub(lo) > time.Second {
mid := lo.Add(hi.Sub(lo) / 2)
pos := computePosition(sat, obs, mid)
if pos != nil && pos.Elevation > 0 {
hi = mid
} else {
lo = mid
}
}
return hi
}
// bisectLOS finds exact LOS time (precision: 1 second).
func bisectLOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time {
for hi.Sub(lo) > time.Second {
mid := lo.Add(hi.Sub(lo) / 2)
pos := computePosition(sat, obs, mid)
if pos != nil && pos.Elevation > 0 {
lo = mid
} else {
hi = mid
}
}
return lo
}
// GroundtrackPoint is a lat/lon position at a given time.
type GroundtrackPoint struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Time time.Time `json:"t"`
}
// ComputeGroundtrack returns the future orbit track for the next `minutes` minutes.
// Points every 30 seconds. Handles the antimeridian by splitting segments.
func (e *Engine) ComputeGroundtrack(sat *tle.Satellite, start time.Time, minutes float64) []GroundtrackPoint {
e.mu.RLock()
defer e.mu.RUnlock()
var points []GroundtrackPoint
end := start.Add(time.Duration(float64(time.Minute) * minutes))
step := 30 * time.Second
// Parse TLE once outside the loop for efficiency
tleObj, err := sgp4.ParseTLE(fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2))
if err != nil {
return points
}
for t := start; t.Before(end); t = t.Add(step) {
eciState, err := tleObj.FindPositionAtTime(t)
if err != nil {
continue
}
lat, lon, _ := eciState.ToGeodetic()
points = append(points, GroundtrackPoint{Lat: lat, Lon: lon, Time: t})
}
return points
}

220
backend/rotor/pstrotator.go Normal file
View File

@@ -0,0 +1,220 @@
package rotor
import (
"fmt"
"log"
"math"
"net"
"sync"
"sync/atomic"
"time"
)
// PstRotatorClient controls PstRotator via UDP XML protocol.
// Protocol: send XML to port N, responses come back on port N+1
// Format: <AZIMUTH>180.0</AZIMUTH> or <ELEVATION>45.0</ELEVATION>
// Multiple commands: <AZIMUTH>180.0</AZIMUTH><ELEVATION>45.0</ELEVATION>
type PstRotatorClient struct {
mu sync.Mutex
conn *net.UDPConn
addr *net.UDPAddr
connected atomic.Bool
lastAz float64
lastEl float64
azThreshold float64
elThreshold float64
}
func NewPstRotatorClient() *PstRotatorClient {
return &PstRotatorClient{
azThreshold: 5.0, // 5° dead-band for Az
elThreshold: 5.0,
}
}
func (r *PstRotatorClient) Connect(host string, port int) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.connected.Load() {
r.disconnectLocked()
}
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return fmt.Errorf("resolve UDP addr: %w", err)
}
conn, err := net.DialUDP("udp", nil, addr)
if err != nil {
return fmt.Errorf("UDP connect to PstRotator: %w", err)
}
r.conn = conn
r.addr = addr
r.connected.Store(true)
r.lastAz = -999
r.lastEl = -999
log.Printf("[Rotor] Connected to PstRotator at %s:%d", host, port)
return nil
}
func (r *PstRotatorClient) Disconnect() {
r.mu.Lock()
defer r.mu.Unlock()
r.disconnectLocked()
}
func (r *PstRotatorClient) disconnectLocked() {
if r.conn != nil {
r.conn.Close()
r.conn = nil
}
r.connected.Store(false)
log.Println("[Rotor] Disconnected")
}
func (r *PstRotatorClient) IsConnected() bool {
return r.connected.Load()
}
// SetAzEl sends azimuth (and optionally elevation) to PstRotator.
// El < 0 is treated as azimuth-only (no elevation rotor).
func (r *PstRotatorClient) SetAzEl(az, el float64) error {
if !r.connected.Load() {
return fmt.Errorf("not connected to PstRotator")
}
// Normalize azimuth 0-360
az = math.Mod(az, 360.0)
if az < 0 {
az += 360
}
// Clamp elevation
if el < 0 {
el = 0
}
if el > 90 {
el = 90
}
r.mu.Lock()
defer r.mu.Unlock()
if r.conn == nil {
return fmt.Errorf("not connected")
}
// Dead-band check (inside mutex — no race on lastAz/lastEl)
azChanged := math.Abs(az-r.lastAz) >= r.azThreshold
elChanged := math.Abs(el-r.lastEl) >= r.elThreshold
if !azChanged && !elChanged {
return nil
}
// Build XML command wrapped in <PST>...</PST>
var cmd string
if azChanged && elChanged {
cmd = fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH><ELEVATION>%.0f</ELEVATION></PST>", az, el)
} else if azChanged {
cmd = fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH></PST>", az)
} else {
cmd = fmt.Sprintf("<PST><ELEVATION>%.0f</ELEVATION></PST>", el)
}
log.Printf("[Rotor] UDP → %s", cmd)
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := r.conn.Write([]byte(cmd)); err != nil {
r.connected.Store(false)
return fmt.Errorf("UDP write: %w", err)
}
if azChanged {
r.lastAz = az
}
if elChanged {
r.lastEl = el
}
log.Printf("[Rotor] → AZ=%.1f° EL=%.1f°", az, el)
return nil
}
// SetAzOnly sends only azimuth — for stations with no elevation rotor.
func (r *PstRotatorClient) SetAzOnly(az float64) error {
if !r.connected.Load() {
return fmt.Errorf("not connected")
}
az = math.Mod(az, 360.0)
if az < 0 {
az += 360
}
r.mu.Lock()
defer r.mu.Unlock()
if r.conn == nil {
return fmt.Errorf("not connected")
}
// Dead-band check (inside mutex — no race on lastAz)
if math.Abs(az-r.lastAz) < r.azThreshold {
return nil
}
cmd := fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH></PST>", az)
log.Printf("[Rotor] UDP → %s", cmd)
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := r.conn.Write([]byte(cmd)); err != nil {
r.connected.Store(false)
return fmt.Errorf("UDP write: %w", err)
}
r.lastAz = az
log.Printf("[Rotor] → AZ=%.1f°", az)
return nil
}
// StopRotor sends stop command.
func (r *PstRotatorClient) StopRotor() error {
return r.sendRaw("<PST><STOP>1</STOP></PST>")
}
// QueryAzEl requests current position — answer comes back on port+1.
func (r *PstRotatorClient) QueryAzEl() error {
return r.sendRaw("<AZ?>1</AZ?><EL?>1</EL?>")
}
func (r *PstRotatorClient) sendRaw(cmd string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.conn == nil {
return fmt.Errorf("not connected")
}
log.Printf("[Rotor] UDP → %s", cmd)
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
n, err := r.conn.Write([]byte(cmd))
if err != nil {
r.connected.Store(false)
return fmt.Errorf("UDP write: %w", err)
}
log.Printf("[Rotor] UDP sent %d bytes to %s", n, r.addr)
return nil
}
// ResetDeadband forces next azimuth command to be sent immediately.
func (r *PstRotatorClient) ResetDeadband() {
r.mu.Lock()
defer r.mu.Unlock()
r.lastAz = -999
r.lastEl = -999
}
func (r *PstRotatorClient) SetThresholds(azDeg, elDeg float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.azThreshold = azDeg
r.elThreshold = elDeg
}

283
backend/tle/manager.go Normal file
View 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
`