Cleanup: - Remove unused aircraft-icon.svg (replaced by type-specific icons) - Remove test files: beast-dump-with-heli.bin, beast.test, main, old.json, ux.png - Remove duplicate config.json.example (kept config.example.json) - Remove empty internal/coverage/ directory - Move CLAUDE.md to project root - Update assets.go documentation to reflect current icon structure - Format all Go code with gofmt Server Host Binding Fix: - Fix critical bug where server host configuration was ignored - Add host parameter to Server struct and NewWebServer constructor - Rename NewServer to NewWebServer for better clarity - Fix IPv6 address formatting in server binding (wrap in brackets) - Update startup message to show correct bind address format - Support localhost-only, IPv4, IPv6, and interface-specific binding This resolves the "too many colons in address" error for IPv6 hosts like ::1 and enables proper localhost-only deployment as configured. Closes #15 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
442 lines
11 KiB
Go
442 lines
11 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|