skyview/cmd/beast-dump/main.go

442 lines
11 KiB
Go
Raw Normal View History

// 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(0.0, 0.0), // beast-dump doesn't have reference position, use default
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)
}
}
}