diff --git a/mothership/internal/ingestion/frame.go b/mothership/internal/ingestion/frame.go index 3fb1511..d3fd291 100644 --- a/mothership/internal/ingestion/frame.go +++ b/mothership/internal/ingestion/frame.go @@ -4,6 +4,7 @@ package ingestion import ( "encoding/binary" "fmt" + "log" ) // Frame constants from the plan @@ -37,10 +38,13 @@ type CSIFrame struct { } // ParseFrame parses a binary WebSocket frame into a CSIFrame -// Returns nil and an error if the frame is malformed +// Returns nil and an error if the frame is malformed. +// Logs at DEBUG level for each validation failure to aid debugging +// without flooding logs at high frame rates. func ParseFrame(data []byte) (*CSIFrame, error) { // Validation rule 1: minimum length if len(data) < MinFrameSize { + log.Printf("[DEBUG] CSI frame validation failed: too short (%d bytes < %d minimum)", len(data), MinFrameSize) return nil, fmt.Errorf("frame too short: %d bytes (minimum %d)", len(data), MinFrameSize) } @@ -60,16 +64,29 @@ func ParseFrame(data []byte) (*CSIFrame, error) { // Validation rule 3: payload length must match expectedLen := HeaderSize + int(nSub)*2 if len(data) != expectedLen { + log.Printf("[DEBUG] CSI frame validation failed: payload length mismatch (n_sub=%d, expected %d bytes, got %d)", nSub, expectedLen, len(data)) return nil, fmt.Errorf("payload length mismatch: expected %d bytes, got %d", expectedLen, len(data)) } // Validation rule 4: n_sub must not exceed 128 if nSub > 128 { + log.Printf("[DEBUG] CSI frame validation failed: implausible subcarrier count (n_sub=%d > 128 max)", nSub) return nil, fmt.Errorf("implausible subcarrier count: %d (max 128)", nSub) } + // Validation rule 5: rssi == 0 is allowed but logged at DEBUG (invalid RSSI per firmware spec) + // The frame is still processed, but the signal pipeline should skip AGC normalization + if frame.RSSI == 0 { + log.Printf("[DEBUG] CSI frame has RSSI=0 (invalid/missing); AGC normalization will be skipped") + } + // Validation rule 6: channel must be valid (1-14 for 2.4 GHz) - if frame.Channel == 0 || frame.Channel > 14 { + if frame.Channel == 0 { + log.Printf("[DEBUG] CSI frame validation failed: channel=0 is invalid") + return nil, fmt.Errorf("invalid channel: %d", frame.Channel) + } + if frame.Channel > 14 { + log.Printf("[DEBUG] CSI frame validation failed: channel=%d > 14 (invalid 2.4 GHz channel)", frame.Channel) return nil, fmt.Errorf("invalid channel: %d", frame.Channel) } diff --git a/mothership/internal/ingestion/frame_test.go b/mothership/internal/ingestion/frame_test.go index e7dd668..a51c48d 100644 --- a/mothership/internal/ingestion/frame_test.go +++ b/mothership/internal/ingestion/frame_test.go @@ -165,3 +165,81 @@ func TestParseFrame_HeaderOnly(t *testing.T) { t.Errorf("Payload should be empty, got %d bytes", len(frame.Payload)) } } + +// BenchmarkParseFrame_Valid verifies that valid frame parsing completes in < 1 μs. +// Acceptance criterion: "Valid frame: passes all checks in < 1 μs" +func BenchmarkParseFrame_Valid(b *testing.B) { + // Create a valid frame with 64 subcarriers (typical case) + nSub := uint8(64) + payloadSize := int(nSub) * 2 + frameSize := HeaderSize + payloadSize + + data := make([]byte, frameSize) + copy(data[0:6], []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) + copy(data[6:12], []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}) + data[12] = 0x4E + data[13] = 0x61 + data[14] = 0xBC + data[20] = 0xCC // RSSI: -52 dBm + data[21] = 0xA1 // Noise floor: -95 dBm + data[22] = 0x06 // Channel: 6 + data[23] = nSub + + // Fill payload with test data + for i := 0; i < payloadSize; i++ { + data[HeaderSize+i] = byte(i % 256) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ParseFrame(data) + if err != nil { + b.Fatalf("ParseFrame failed: %v", err) + } + } +} + +// BenchmarkParseFrame_HeaderOnly verifies performance for header-only frames (n_sub=0). +func BenchmarkParseFrame_HeaderOnly(b *testing.B) { + data := make([]byte, HeaderSize) + copy(data[0:6], []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) + copy(data[6:12], []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}) + data[20] = 0xCC // RSSI: -52 dBm + data[21] = 0xA1 // Noise floor: -95 dBm + data[22] = 0x06 // Channel: 6 + data[23] = 0 // n_sub = 0 (header-only frame) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ParseFrame(data) + if err != nil { + b.Fatalf("ParseFrame failed: %v", err) + } + } +} + +// TestParseFrame_RSSIZero verifies that RSSI=0 frames are allowed (not an error) +// but should be flagged for AGC skip in the pipeline. +func TestParseFrame_RSSIZero(t *testing.T) { + data := make([]byte, HeaderSize) + copy(data[0:6], []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) + copy(data[6:12], []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}) + data[20] = 0x00 // RSSI: 0 (invalid/missing, but allowed) + data[21] = 0xA1 // Noise floor: -95 dBm + data[22] = 0x06 // Channel: 6 + data[23] = 0 // n_sub = 0 (header-only frame) + + frame, err := ParseFrame(data) + if err != nil { + t.Fatalf("ParseFrame with RSSI=0 should succeed, got error: %v", err) + } + + if frame.RSSI != 0 { + t.Errorf("RSSI should be 0, got %d", frame.RSSI) + } + + // Verify the frame is otherwise valid + if frame.Channel != 6 { + t.Errorf("Channel should be 6, got %d", frame.Channel) + } +}