Fix aircraft markers not updating positions in real-time
Root cause: The merger was blocking position updates from the same source after the first position was established, designed for multi-source scenarios but preventing single-source position updates. Changes: - Refactor JavaScript into modular architecture (WebSocketManager, AircraftManager, MapManager, UIManager) - Add CPR coordinate validation to prevent invalid latitude/longitude values - Fix merger to allow position updates from same source for moving aircraft - Add comprehensive coordinate bounds checking in CPR decoder - Update HTML to use new modular JavaScript with cache busting - Add WebSocket debug logging to track data flow Technical details: - CPR decoder now validates coordinates within ±90° latitude, ±180° longitude - Merger allows updates when currentBest == sourceID (same source continuous updates) - JavaScript modules provide better separation of concerns and debugging - WebSocket properly transmits updated aircraft coordinates to frontend 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ddffe1428d
commit
1de3e092ae
13 changed files with 2222 additions and 33 deletions
440
cmd/beast-dump/main.go
Normal file
440
cmd/beast-dump/main.go
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
// Package main provides a utility for parsing and displaying Beast format ADS-B data.
|
||||
//
|
||||
// beast-dump can read from TCP sockets (dump1090 streams) or files containing
|
||||
// Beast binary data, decode Mode S/ADS-B messages, and display the results
|
||||
// in human-readable format on the console.
|
||||
//
|
||||
// Usage:
|
||||
// beast-dump -tcp host:port # Read from TCP socket
|
||||
// beast-dump -file path/to/file # Read from file
|
||||
// beast-dump -verbose # Show detailed message parsing
|
||||
//
|
||||
// Examples:
|
||||
// beast-dump -tcp svovel:30005 # Connect to dump1090 Beast stream
|
||||
// beast-dump -file beast.test # Parse Beast data from file
|
||||
// beast-dump -tcp localhost:30005 -verbose # Verbose TCP parsing
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"skyview/internal/beast"
|
||||
"skyview/internal/modes"
|
||||
)
|
||||
|
||||
// Config holds command-line configuration
|
||||
type Config struct {
|
||||
TCPAddress string // TCP address for Beast stream (e.g., "localhost:30005")
|
||||
FilePath string // File path for Beast data
|
||||
Verbose bool // Enable verbose output
|
||||
Count int // Maximum messages to process (0 = unlimited)
|
||||
}
|
||||
|
||||
// BeastDumper handles Beast data parsing and console output
|
||||
type BeastDumper struct {
|
||||
config *Config
|
||||
parser *beast.Parser
|
||||
decoder *modes.Decoder
|
||||
stats struct {
|
||||
totalMessages int64
|
||||
validMessages int64
|
||||
aircraftSeen map[uint32]bool
|
||||
startTime time.Time
|
||||
lastMessageTime time.Time
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
config := parseFlags()
|
||||
|
||||
if config.TCPAddress == "" && config.FilePath == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: Must specify either -tcp or -file\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if config.TCPAddress != "" && config.FilePath != "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: Cannot specify both -tcp and -file\n")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dumper := NewBeastDumper(config)
|
||||
|
||||
if err := dumper.Run(); err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseFlags parses command-line flags and returns configuration
|
||||
func parseFlags() *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.StringVar(&config.TCPAddress, "tcp", "", "TCP address for Beast stream (e.g., localhost:30005)")
|
||||
flag.StringVar(&config.FilePath, "file", "", "File path for Beast data")
|
||||
flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose output")
|
||||
flag.IntVar(&config.Count, "count", 0, "Maximum messages to process (0 = unlimited)")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "\nBeast format ADS-B data parser and console dumper\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -tcp svovel:30005\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s -file beast.test\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " %s -tcp localhost:30005 -verbose -count 100\n", os.Args[0])
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
return config
|
||||
}
|
||||
|
||||
// NewBeastDumper creates a new Beast data dumper
|
||||
func NewBeastDumper(config *Config) *BeastDumper {
|
||||
return &BeastDumper{
|
||||
config: config,
|
||||
decoder: modes.NewDecoder(),
|
||||
stats: struct {
|
||||
totalMessages int64
|
||||
validMessages int64
|
||||
aircraftSeen map[uint32]bool
|
||||
startTime time.Time
|
||||
lastMessageTime time.Time
|
||||
}{
|
||||
aircraftSeen: make(map[uint32]bool),
|
||||
startTime: time.Now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the Beast data processing
|
||||
func (d *BeastDumper) Run() error {
|
||||
fmt.Printf("Beast Data Dumper\n")
|
||||
fmt.Printf("=================\n\n")
|
||||
|
||||
var reader io.Reader
|
||||
var closer io.Closer
|
||||
|
||||
if d.config.TCPAddress != "" {
|
||||
conn, err := d.connectTCP()
|
||||
if err != nil {
|
||||
return fmt.Errorf("TCP connection failed: %w", err)
|
||||
}
|
||||
reader = conn
|
||||
closer = conn
|
||||
fmt.Printf("Connected to: %s\n", d.config.TCPAddress)
|
||||
} else {
|
||||
file, err := d.openFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("file open failed: %w", err)
|
||||
}
|
||||
reader = file
|
||||
closer = file
|
||||
fmt.Printf("Reading file: %s\n", d.config.FilePath)
|
||||
}
|
||||
|
||||
defer closer.Close()
|
||||
|
||||
// Create Beast parser
|
||||
d.parser = beast.NewParser(reader, "beast-dump")
|
||||
|
||||
fmt.Printf("Verbose mode: %t\n", d.config.Verbose)
|
||||
if d.config.Count > 0 {
|
||||
fmt.Printf("Message limit: %d\n", d.config.Count)
|
||||
}
|
||||
fmt.Printf("\nStarting Beast data parsing...\n")
|
||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6s %s\n",
|
||||
"Time", "ICAO", "Type", "Signal", "Data", "Len", "Decoded")
|
||||
fmt.Printf("%s\n",
|
||||
"------------------------------------------------------------------------")
|
||||
|
||||
return d.parseMessages()
|
||||
}
|
||||
|
||||
// connectTCP establishes TCP connection to Beast stream
|
||||
func (d *BeastDumper) connectTCP() (net.Conn, error) {
|
||||
fmt.Printf("Connecting to %s...\n", d.config.TCPAddress)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", d.config.TCPAddress, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// openFile opens Beast data file
|
||||
func (d *BeastDumper) openFile() (*os.File, error) {
|
||||
file, err := os.Open(d.config.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check file size
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("File size: %d bytes\n", stat.Size())
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// parseMessages processes Beast messages and outputs decoded data
|
||||
func (d *BeastDumper) parseMessages() error {
|
||||
for {
|
||||
// Check message count limit
|
||||
if d.config.Count > 0 && d.stats.totalMessages >= int64(d.config.Count) {
|
||||
fmt.Printf("\nReached message limit of %d\n", d.config.Count)
|
||||
break
|
||||
}
|
||||
|
||||
// Parse Beast message
|
||||
msg, err := d.parser.ReadMessage()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
fmt.Printf("\nEnd of data reached\n")
|
||||
break
|
||||
}
|
||||
if d.config.Verbose {
|
||||
fmt.Printf("Parse error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
d.stats.totalMessages++
|
||||
d.stats.lastMessageTime = time.Now()
|
||||
|
||||
// Display Beast message info
|
||||
d.displayMessage(msg)
|
||||
|
||||
// Decode Mode S data if available
|
||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
||||
d.decodeAndDisplay(msg)
|
||||
}
|
||||
|
||||
d.stats.validMessages++
|
||||
}
|
||||
|
||||
d.displayStatistics()
|
||||
return nil
|
||||
}
|
||||
|
||||
// displayMessage shows basic Beast message information
|
||||
func (d *BeastDumper) displayMessage(msg *beast.Message) {
|
||||
timestamp := msg.ReceivedAt.Format("15:04:05")
|
||||
|
||||
// Extract ICAO if available
|
||||
icao := "------"
|
||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
||||
if icaoAddr, err := msg.GetICAO24(); err == nil {
|
||||
icao = fmt.Sprintf("%06X", icaoAddr)
|
||||
d.stats.aircraftSeen[icaoAddr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Beast message type
|
||||
typeStr := d.formatMessageType(msg.Type)
|
||||
|
||||
// Signal strength
|
||||
signal := msg.GetSignalStrength()
|
||||
signalStr := fmt.Sprintf("%6.1f", signal)
|
||||
|
||||
// Data preview
|
||||
dataStr := d.formatDataPreview(msg.Data)
|
||||
|
||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6d ",
|
||||
timestamp, icao, typeStr, signalStr, dataStr, len(msg.Data))
|
||||
}
|
||||
|
||||
// decodeAndDisplay attempts to decode Mode S message and display results
|
||||
func (d *BeastDumper) decodeAndDisplay(msg *beast.Message) {
|
||||
aircraft, err := d.decoder.Decode(msg.Data)
|
||||
if err != nil {
|
||||
if d.config.Verbose {
|
||||
fmt.Printf("Decode error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("(decode failed)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Display decoded information
|
||||
info := d.formatAircraftInfo(aircraft)
|
||||
fmt.Printf("%s\n", info)
|
||||
|
||||
// Verbose details
|
||||
if d.config.Verbose {
|
||||
d.displayVerboseInfo(aircraft, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// formatMessageType converts Beast message type to string
|
||||
func (d *BeastDumper) formatMessageType(msgType uint8) string {
|
||||
switch msgType {
|
||||
case beast.BeastModeAC:
|
||||
return "Mode A/C"
|
||||
case beast.BeastModeS:
|
||||
return "Mode S"
|
||||
case beast.BeastModeSLong:
|
||||
return "Mode S Long"
|
||||
case beast.BeastStatusMsg:
|
||||
return "Status"
|
||||
default:
|
||||
return fmt.Sprintf("Type %02X", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
// formatDataPreview creates a hex preview of message data
|
||||
func (d *BeastDumper) formatDataPreview(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
preview := ""
|
||||
for i, b := range data {
|
||||
if i >= 4 { // Show first 4 bytes
|
||||
break
|
||||
}
|
||||
preview += fmt.Sprintf("%02X", b)
|
||||
}
|
||||
|
||||
if len(data) > 4 {
|
||||
preview += "..."
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
// formatAircraftInfo creates a summary of decoded aircraft information
|
||||
func (d *BeastDumper) formatAircraftInfo(aircraft *modes.Aircraft) string {
|
||||
parts := []string{}
|
||||
|
||||
// Callsign
|
||||
if aircraft.Callsign != "" {
|
||||
parts = append(parts, fmt.Sprintf("CS:%s", aircraft.Callsign))
|
||||
}
|
||||
|
||||
// Position
|
||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
||||
parts = append(parts, fmt.Sprintf("POS:%.4f,%.4f", aircraft.Latitude, aircraft.Longitude))
|
||||
}
|
||||
|
||||
// Altitude
|
||||
if aircraft.Altitude != 0 {
|
||||
parts = append(parts, fmt.Sprintf("ALT:%dft", aircraft.Altitude))
|
||||
}
|
||||
|
||||
// Speed and track
|
||||
if aircraft.GroundSpeed != 0 {
|
||||
parts = append(parts, fmt.Sprintf("SPD:%dkt", aircraft.GroundSpeed))
|
||||
}
|
||||
if aircraft.Track != 0 {
|
||||
parts = append(parts, fmt.Sprintf("HDG:%d°", aircraft.Track))
|
||||
}
|
||||
|
||||
// Vertical rate
|
||||
if aircraft.VerticalRate != 0 {
|
||||
parts = append(parts, fmt.Sprintf("VS:%d", aircraft.VerticalRate))
|
||||
}
|
||||
|
||||
// Squawk
|
||||
if aircraft.Squawk != "" {
|
||||
parts = append(parts, fmt.Sprintf("SQ:%s", aircraft.Squawk))
|
||||
}
|
||||
|
||||
// Emergency
|
||||
if aircraft.Emergency != "" && aircraft.Emergency != "None" {
|
||||
parts = append(parts, fmt.Sprintf("EMG:%s", aircraft.Emergency))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "(no data decoded)"
|
||||
}
|
||||
|
||||
info := ""
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
info += " "
|
||||
}
|
||||
info += part
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// displayVerboseInfo shows detailed aircraft information
|
||||
func (d *BeastDumper) displayVerboseInfo(aircraft *modes.Aircraft, msg *beast.Message) {
|
||||
fmt.Printf(" Message Details:\n")
|
||||
fmt.Printf(" Raw Data: %s\n", d.formatHexData(msg.Data))
|
||||
fmt.Printf(" Timestamp: %s\n", msg.ReceivedAt.Format("15:04:05.000"))
|
||||
fmt.Printf(" Signal: %.2f dBFS\n", msg.GetSignalStrength())
|
||||
|
||||
fmt.Printf(" Aircraft Data:\n")
|
||||
if aircraft.Callsign != "" {
|
||||
fmt.Printf(" Callsign: %s\n", aircraft.Callsign)
|
||||
}
|
||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
||||
fmt.Printf(" Position: %.6f, %.6f\n", aircraft.Latitude, aircraft.Longitude)
|
||||
}
|
||||
if aircraft.Altitude != 0 {
|
||||
fmt.Printf(" Altitude: %d ft\n", aircraft.Altitude)
|
||||
}
|
||||
if aircraft.GroundSpeed != 0 || aircraft.Track != 0 {
|
||||
fmt.Printf(" Speed/Track: %d kt @ %d°\n", aircraft.GroundSpeed, aircraft.Track)
|
||||
}
|
||||
if aircraft.VerticalRate != 0 {
|
||||
fmt.Printf(" Vertical Rate: %d ft/min\n", aircraft.VerticalRate)
|
||||
}
|
||||
if aircraft.Squawk != "" {
|
||||
fmt.Printf(" Squawk: %s\n", aircraft.Squawk)
|
||||
}
|
||||
if aircraft.Category != "" {
|
||||
fmt.Printf(" Category: %s\n", aircraft.Category)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
// formatHexData creates a formatted hex dump of data
|
||||
func (d *BeastDumper) formatHexData(data []byte) string {
|
||||
result := ""
|
||||
for i, b := range data {
|
||||
if i > 0 {
|
||||
result += " "
|
||||
}
|
||||
result += fmt.Sprintf("%02X", b)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// displayStatistics shows final parsing statistics
|
||||
func (d *BeastDumper) displayStatistics() {
|
||||
duration := time.Since(d.stats.startTime)
|
||||
|
||||
fmt.Printf("\nStatistics:\n")
|
||||
fmt.Printf("===========\n")
|
||||
fmt.Printf("Total messages: %d\n", d.stats.totalMessages)
|
||||
fmt.Printf("Valid messages: %d\n", d.stats.validMessages)
|
||||
fmt.Printf("Unique aircraft: %d\n", len(d.stats.aircraftSeen))
|
||||
fmt.Printf("Duration: %v\n", duration.Round(time.Second))
|
||||
|
||||
if d.stats.totalMessages > 0 && duration > 0 {
|
||||
rate := float64(d.stats.totalMessages) / duration.Seconds()
|
||||
fmt.Printf("Message rate: %.1f msg/sec\n", rate)
|
||||
}
|
||||
|
||||
if len(d.stats.aircraftSeen) > 0 {
|
||||
fmt.Printf("\nAircraft seen:\n")
|
||||
for icao := range d.stats.aircraftSeen {
|
||||
fmt.Printf(" %06X\n", icao)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue