skyview/internal/beast/parser.go
Ole-Morten Duesund 1de3e092ae 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>
2025-08-24 14:04:17 +02:00

322 lines
11 KiB
Go

// Package beast provides Beast binary format parsing for ADS-B message streams.
//
// The Beast format is a binary protocol developed by FlightAware and used by
// dump1090, readsb, and other ADS-B software to stream real-time aircraft data
// over TCP connections (typically port 30005).
//
// Beast Format Structure:
// - Each message starts with escape byte 0x1A
// - Message type byte (0x31=Mode A/C, 0x32=Mode S Short, 0x33=Mode S Long)
// - 48-bit timestamp (12MHz clock ticks)
// - Signal level byte (RSSI)
// - Message payload (2, 7, or 14 bytes depending on type)
// - Escape sequences: 0x1A 0x1A represents literal 0x1A in data
//
// This package handles:
// - Binary message parsing and validation
// - Timestamp and signal strength extraction
// - Escape sequence processing
// - ICAO address and message type extraction
// - Continuous stream processing with error recovery
//
// The parser is designed to handle connection interruptions gracefully and
// can recover from malformed messages in the stream.
package beast
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"time"
)
// Beast format message type constants.
// These define the different types of messages in the Beast binary protocol.
const (
BeastModeAC = 0x31 // '1' - Mode A/C squitter (2 bytes payload)
BeastModeS = 0x32 // '2' - Mode S Short squitter (7 bytes payload)
BeastModeSLong = 0x33 // '3' - Mode S Extended squitter (14 bytes payload)
BeastStatusMsg = 0x34 // '4' - Status message (variable length)
BeastEscape = 0x1A // Escape character (0x1A 0x1A = literal 0x1A)
)
// Message represents a parsed Beast format message with metadata.
//
// Contains both the raw Beast protocol fields and additional processing metadata:
// - Original Beast format fields (type, timestamp, signal, data)
// - Processing timestamp for age calculations
// - Source identification for multi-receiver setups
type Message struct {
Type byte // Beast message type (0x31, 0x32, 0x33, 0x34)
Timestamp uint64 // 48-bit timestamp in 12MHz ticks from receiver
Signal uint8 // Signal level (RSSI) - 255 = 0 dBFS, 0 = minimum
Data []byte // Mode S message payload (2, 7, or 14 bytes)
ReceivedAt time.Time // Local processing timestamp
SourceID string // Identifier for the source receiver
}
// Parser handles Beast binary format parsing from a stream.
//
// The parser maintains stream state and can recover from protocol errors
// by searching for the next valid message boundary. It uses buffered I/O
// for efficient byte-level parsing of the binary protocol.
type Parser struct {
reader *bufio.Reader // Buffered reader for efficient byte parsing
sourceID string // Source identifier for message tagging
}
// NewParser creates a new Beast format parser for a data stream.
//
// The parser wraps the provided reader with a buffered reader for efficient
// parsing of the binary protocol. Each parsed message will be tagged with
// the provided sourceID for multi-source identification.
//
// Parameters:
// - r: Input stream containing Beast format data
// - sourceID: Identifier for this data source
//
// Returns a configured parser ready for message parsing.
func NewParser(r io.Reader, sourceID string) *Parser {
return &Parser{
reader: bufio.NewReader(r),
sourceID: sourceID,
}
}
// ReadMessage reads and parses a single Beast message from the stream.
//
// The parsing process:
// 1. Search for the escape character (0x1A) that marks message start
// 2. Read and validate the message type byte
// 3. Read the 48-bit timestamp (big-endian, padded to 64-bit)
// 4. Read the signal level byte
// 5. Read the message payload (length depends on message type)
// 6. Process escape sequences in the payload data
//
// The parser can recover from protocol errors by continuing to search for
// the next valid message boundary. Status messages are currently skipped
// as they contain variable-length data not needed for aircraft tracking.
//
// Returns the parsed message or an error if the stream is closed or corrupted.
func (p *Parser) ReadMessage() (*Message, error) {
// Look for escape character
for {
b, err := p.reader.ReadByte()
if err != nil {
return nil, err
}
if b == BeastEscape {
break
}
}
// Read message type
msgType, err := p.reader.ReadByte()
if err != nil {
return nil, err
}
// Validate message type
var dataLen int
switch msgType {
case BeastModeAC:
dataLen = 2
case BeastModeS:
dataLen = 7
case BeastModeSLong:
dataLen = 14
case BeastStatusMsg:
// Status messages have variable length, skip for now
return p.ReadMessage()
case BeastEscape:
// Handle double escape sequence (0x1A 0x1A) - skip and continue
return p.ReadMessage()
default:
// Skip unknown message types and continue parsing instead of failing
// This makes the parser more resilient to malformed or extended Beast formats
return p.ReadMessage()
}
// Read timestamp (6 bytes, 48-bit)
timestampBytes := make([]byte, 8)
if _, err := io.ReadFull(p.reader, timestampBytes[2:]); err != nil {
return nil, err
}
timestamp := binary.BigEndian.Uint64(timestampBytes)
// Read signal level (1 byte)
signal, err := p.reader.ReadByte()
if err != nil {
return nil, err
}
// Read Mode S data
data := make([]byte, dataLen)
if _, err := io.ReadFull(p.reader, data); err != nil {
return nil, err
}
// Unescape data if needed
data = p.unescapeData(data)
return &Message{
Type: msgType,
Timestamp: timestamp,
Signal: signal,
Data: data,
ReceivedAt: time.Now(),
SourceID: p.sourceID,
}, nil
}
// unescapeData removes escape sequences from Beast format payload data.
//
// Beast format uses escape sequences to embed the escape character (0x1A)
// in message payloads:
// - 0x1A 0x1A in the stream represents a literal 0x1A byte in the data
// - Single 0x1A bytes are message boundaries, not data
//
// This method processes the payload after parsing to restore the original
// Mode S message bytes with any embedded escape characters.
//
// Parameters:
// - data: Raw payload bytes that may contain escape sequences
//
// Returns the unescaped data with literal 0x1A bytes restored.
func (p *Parser) unescapeData(data []byte) []byte {
result := make([]byte, 0, len(data))
i := 0
for i < len(data) {
if i < len(data)-1 && data[i] == BeastEscape && data[i+1] == BeastEscape {
result = append(result, BeastEscape)
i += 2
} else {
result = append(result, data[i])
i++
}
}
return result
}
// ParseStream continuously reads messages from the stream until an error occurs.
//
// This method runs in a loop, parsing messages and sending them to the provided
// channel. It handles various error conditions gracefully:
// - EOF and closed pipe errors terminate normally (expected on disconnect)
// - Other errors are reported via the error channel with source identification
// - Protocol errors within individual messages are recovered from automatically
//
// The method blocks until the stream closes or an unrecoverable error occurs.
// It's designed to run in a dedicated goroutine for continuous processing.
//
// Parameters:
// - msgChan: Channel for sending successfully parsed messages
// - errChan: Channel for reporting parsing errors
func (p *Parser) ParseStream(msgChan chan<- *Message, errChan chan<- error) {
for {
msg, err := p.ReadMessage()
if err != nil {
if err != io.EOF && !errors.Is(err, io.ErrClosedPipe) {
errChan <- fmt.Errorf("parser error from %s: %w", p.sourceID, err)
}
return
}
msgChan <- msg
}
}
// GetSignalStrength converts the Beast signal level byte to dBFS (decibels full scale).
//
// The Beast format encodes signal strength as:
// - 255 = 0 dBFS (maximum signal, clipping)
// - Lower values = weaker signals
// - 0 = minimum detectable signal (~-50 dBFS)
//
// The conversion provides a logarithmic scale suitable for signal quality
// comparison and coverage analysis. Values typically range from -50 to 0 dBFS
// in normal operation.
//
// Returns signal strength in dBFS (negative values, closer to 0 = stronger).
func (msg *Message) GetSignalStrength() float64 {
// Beast format: signal level is in units where 255 = 0 dBFS
// Typical range is -50 to 0 dBFS
if msg.Signal == 0 {
return -50.0 // Minimum detectable signal
}
return float64(msg.Signal) * (-50.0 / 255.0)
}
// GetICAO24 extracts the ICAO 24-bit aircraft address from Mode S messages.
//
// The ICAO address is a unique 24-bit identifier assigned to each aircraft.
// In Mode S messages, it's located in bytes 1-3 of the message payload:
// - Byte 1: Most significant 8 bits
// - Byte 2: Middle 8 bits
// - Byte 3: Least significant 8 bits
//
// Mode A/C messages don't contain ICAO addresses and will return an error.
// The ICAO address is used as the primary key for aircraft tracking.
//
// Returns the 24-bit ICAO address as a uint32, or an error for invalid messages.
func (msg *Message) GetICAO24() (uint32, error) {
if msg.Type == BeastModeAC {
return 0, errors.New("mode A/C messages don't contain ICAO address")
}
if len(msg.Data) < 4 {
return 0, errors.New("insufficient data for ICAO address")
}
// ICAO address is in bytes 1-3 of Mode S messages
icao := uint32(msg.Data[1])<<16 | uint32(msg.Data[2])<<8 | uint32(msg.Data[3])
return icao, nil
}
// GetDownlinkFormat extracts the Downlink Format (DF) from Mode S messages.
//
// The DF field occupies the first 5 bits of every Mode S message and indicates
// the message type and structure:
// - DF 0: Short air-air surveillance
// - DF 4/5: Surveillance altitude/identity reply
// - DF 11: All-call reply
// - DF 17: Extended squitter (ADS-B)
// - DF 18: Extended squitter/non-transponder
// - DF 19: Military extended squitter
// - Others: Various surveillance and communication types
//
// Returns the 5-bit DF field value, or 0 if no data is available.
func (msg *Message) GetDownlinkFormat() uint8 {
if len(msg.Data) == 0 {
return 0
}
return (msg.Data[0] >> 3) & 0x1F
}
// GetTypeCode extracts the Type Code (TC) from ADS-B extended squitter messages.
//
// The Type Code is a 5-bit field that indicates the specific type of ADS-B message:
// - TC 1-4: Aircraft identification and category
// - TC 5-8: Surface position messages
// - TC 9-18: Airborne position messages (different altitude sources)
// - TC 19: Airborne velocity messages
// - TC 20-22: Reserved for future use
// - Others: Various operational and status messages
//
// Only extended squitter messages (DF 17/18) contain type codes. Other
// message types will return an error.
//
// Returns the 5-bit type code, or an error for non-extended squitter messages.
func (msg *Message) GetTypeCode() (uint8, error) {
df := msg.GetDownlinkFormat()
if df != 17 && df != 18 { // Extended squitter
return 0, errors.New("not an extended squitter message")
}
if len(msg.Data) < 5 {
return 0, errors.New("insufficient data for type code")
}
return (msg.Data[4] >> 3) & 0x1F, nil
}