Complete signal processing pipeline from complex baseband to decoded PCM telemetry, verified against the 1965 NAA Study Guide (A-624): Core demod (Phase 1): - PM demodulator with carrier PLL recovery - 1.024 MHz subcarrier extractor (bandpass + downconvert) - BPSK demodulator with Costas loop + symbol sync - Convenience hier_block2 combining subcarrier + BPSK PCM frame processing (Phase 2): - 32-bit frame sync with Hamming distance correlator - SEARCH/VERIFY/LOCKED state machine, complement-on-odd handling - Frame demultiplexer (128-word, A/D voltage scaling) - AGC downlink decoder (15-bit word reassembly from DNTM1/DNTM2) Voice and analog (Phase 3): - 1.25 MHz FM voice subcarrier demod to 8 kHz audio - SCO demodulator for 9 analog sensor channels (14.5-165 kHz) Virtual AGC integration (Phase 4): - TCP bridge client with auto-reconnect and channel filtering - DSKY uplink encoder (VERB/NOUN/DATA command sequences) Top-level receiver (Phase 5): - usb_downlink_receiver hier_block2: one block, complex in, PDUs out - 14 GRC block YAML definitions for GNU Radio Companion - Example scripts for signal analysis and full-chain demo Infrastructure: - constants.py with all timing/frequency/frame parameters - protocol.py for sync word + AGC packet encode/decode - Synthetic USB signal generator for testing - 222 tests passing, ruff lint clean
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""Tests for UplinkEncoder — AGC INLINK command formatting.
|
|
|
|
Verifies that DSKY command sequences (VERB, NOUN, DATA, PROCEED) are
|
|
correctly encoded as (channel, value) pairs for delivery to AGC channel 045.
|
|
No GNU Radio required.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from apollo.constants import AGC_CH_INLINK
|
|
from apollo.protocol import form_io_packet, parse_io_packet
|
|
from apollo.uplink_encoder import (
|
|
KEYCODE_DIGITS,
|
|
KEYCODE_ENTER,
|
|
KEYCODE_MINUS,
|
|
KEYCODE_NOUN,
|
|
KEYCODE_PLUS,
|
|
KEYCODE_VERB,
|
|
UplinkEncoder,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def encoder():
|
|
return UplinkEncoder()
|
|
|
|
|
|
class TestKeycodeEncoding:
|
|
"""Basic keycode → (channel, value) encoding."""
|
|
|
|
def test_channel_is_inlink(self, encoder):
|
|
"""All encoded pairs use the INLINK channel by default."""
|
|
ch, _ = encoder.encode_keycode(KEYCODE_VERB)
|
|
assert ch == AGC_CH_INLINK
|
|
|
|
def test_custom_channel(self):
|
|
"""Custom channel overrides the default."""
|
|
enc = UplinkEncoder(channel=99)
|
|
ch, _ = enc.encode_keycode(KEYCODE_VERB)
|
|
assert ch == 99
|
|
|
|
def test_keycode_in_upper_bits(self, encoder):
|
|
"""Keycode occupies bits 14-10 of the 15-bit value."""
|
|
_, value = encoder.encode_keycode(KEYCODE_VERB)
|
|
extracted = (value >> 10) & 0x1F
|
|
assert extracted == KEYCODE_VERB
|
|
|
|
def test_lower_bits_zero(self, encoder):
|
|
"""Bits 9-0 are zero for a simple keypress."""
|
|
_, value = encoder.encode_keycode(KEYCODE_NOUN)
|
|
assert (value & 0x3FF) == 0
|
|
|
|
def test_value_is_15_bit(self, encoder):
|
|
"""Encoded value fits in 15 bits."""
|
|
_, value = encoder.encode_keycode(0x1F) # max 5-bit keycode
|
|
assert 0 <= value <= 0x7FFF
|
|
|
|
|
|
class TestDigitEncoding:
|
|
"""Digit (0-9) keycode encoding."""
|
|
|
|
def test_all_digits_encode(self, encoder):
|
|
"""Each digit 0-9 produces a valid (channel, value) pair."""
|
|
for d in range(10):
|
|
ch, val = encoder.encode_digit(d)
|
|
assert ch == AGC_CH_INLINK
|
|
assert 0 <= val <= 0x7FFF
|
|
|
|
def test_digit_keycodes_unique(self, encoder):
|
|
"""Each digit maps to a distinct keycode/value."""
|
|
values = set()
|
|
for d in range(10):
|
|
_, val = encoder.encode_digit(d)
|
|
values.add(val)
|
|
assert len(values) == 10
|
|
|
|
def test_invalid_digit(self, encoder):
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_digit(10)
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_digit(-1)
|
|
|
|
|
|
class TestVerbEncoding:
|
|
"""VERB command encoding (VERB key + 2 digit keys)."""
|
|
|
|
def test_verb_sequence_length(self, encoder):
|
|
"""V37 produces 3 pairs: VERB + digit + digit."""
|
|
pairs = encoder.encode_verb(37)
|
|
assert len(pairs) == 3
|
|
|
|
def test_verb_key_first(self, encoder):
|
|
"""First pair in the sequence is the VERB keycode."""
|
|
pairs = encoder.encode_verb(37)
|
|
_, value = pairs[0]
|
|
assert (value >> 10) & 0x1F == KEYCODE_VERB
|
|
|
|
def test_verb_digits_correct(self, encoder):
|
|
"""Digits encode the verb number."""
|
|
pairs = encoder.encode_verb(37)
|
|
_, d1_val = pairs[1]
|
|
_, d2_val = pairs[2]
|
|
# Digit 3
|
|
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[3]
|
|
# Digit 7
|
|
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[7]
|
|
|
|
def test_verb_zero_padded(self, encoder):
|
|
"""V06 encodes as VERB, 0, 6."""
|
|
pairs = encoder.encode_verb(6)
|
|
_, d1_val = pairs[1]
|
|
_, d2_val = pairs[2]
|
|
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[0]
|
|
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[6]
|
|
|
|
def test_verb_boundary_values(self, encoder):
|
|
pairs_0 = encoder.encode_verb(0)
|
|
assert len(pairs_0) == 3
|
|
pairs_99 = encoder.encode_verb(99)
|
|
assert len(pairs_99) == 3
|
|
|
|
def test_verb_out_of_range(self, encoder):
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_verb(100)
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_verb(-1)
|
|
|
|
|
|
class TestNounEncoding:
|
|
"""NOUN command encoding."""
|
|
|
|
def test_noun_sequence_length(self, encoder):
|
|
pairs = encoder.encode_noun(1)
|
|
assert len(pairs) == 3
|
|
|
|
def test_noun_key_first(self, encoder):
|
|
pairs = encoder.encode_noun(1)
|
|
_, value = pairs[0]
|
|
assert (value >> 10) & 0x1F == KEYCODE_NOUN
|
|
|
|
def test_noun_digits(self, encoder):
|
|
pairs = encoder.encode_noun(47)
|
|
_, d1_val = pairs[1]
|
|
_, d2_val = pairs[2]
|
|
assert (d1_val >> 10) & 0x1F == KEYCODE_DIGITS[4]
|
|
assert (d2_val >> 10) & 0x1F == KEYCODE_DIGITS[7]
|
|
|
|
def test_noun_out_of_range(self, encoder):
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_noun(100)
|
|
|
|
|
|
class TestDataEncoding:
|
|
"""Signed/unsigned data entry encoding."""
|
|
|
|
def test_positive_data_starts_with_plus(self, encoder):
|
|
pairs = encoder.encode_data(12345)
|
|
_, sign_val = pairs[0]
|
|
assert (sign_val >> 10) & 0x1F == KEYCODE_PLUS
|
|
|
|
def test_negative_data_starts_with_minus(self, encoder):
|
|
pairs = encoder.encode_data(-12345)
|
|
_, sign_val = pairs[0]
|
|
assert (sign_val >> 10) & 0x1F == KEYCODE_MINUS
|
|
|
|
def test_signed_data_length(self, encoder):
|
|
"""Signed data: sign + 5 digits = 6 pairs."""
|
|
pairs = encoder.encode_data(12345)
|
|
assert len(pairs) == 6
|
|
|
|
def test_unsigned_data_length(self, encoder):
|
|
"""Unsigned data: 5 digits only = 5 pairs."""
|
|
pairs = encoder.encode_data(12345, signed=False)
|
|
assert len(pairs) == 5
|
|
|
|
def test_data_digits(self, encoder):
|
|
"""Verify digit sequence for +00042."""
|
|
pairs = encoder.encode_data(42)
|
|
# Skip sign (index 0), check digits 0, 0, 0, 4, 2
|
|
expected_digits = [0, 0, 0, 4, 2]
|
|
for i, expected_d in enumerate(expected_digits):
|
|
_, val = pairs[i + 1]
|
|
actual_keycode = (val >> 10) & 0x1F
|
|
assert actual_keycode == KEYCODE_DIGITS[expected_d], (
|
|
f"digit {i}: expected {expected_d} (keycode {KEYCODE_DIGITS[expected_d]}), "
|
|
f"got keycode {actual_keycode}"
|
|
)
|
|
|
|
def test_data_zero(self, encoder):
|
|
"""Zero encodes as +00000."""
|
|
pairs = encoder.encode_data(0)
|
|
assert len(pairs) == 6 # sign + 5 digits
|
|
|
|
def test_data_max_value(self, encoder):
|
|
pairs = encoder.encode_data(99999)
|
|
assert len(pairs) == 6
|
|
|
|
def test_data_out_of_range(self, encoder):
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_data(100000)
|
|
with pytest.raises(ValueError):
|
|
encoder.encode_data(-100000)
|
|
|
|
|
|
class TestProceedEncoding:
|
|
"""PROCEED/ENTER key encoding."""
|
|
|
|
def test_proceed_single_pair(self, encoder):
|
|
pairs = encoder.encode_proceed()
|
|
assert len(pairs) == 1
|
|
|
|
def test_proceed_is_enter_key(self, encoder):
|
|
pairs = encoder.encode_proceed()
|
|
_, value = pairs[0]
|
|
assert (value >> 10) & 0x1F == KEYCODE_ENTER
|
|
|
|
|
|
class TestCommandDispatch:
|
|
"""High-level encode_command() dispatch."""
|
|
|
|
def test_verb_dispatch(self, encoder):
|
|
pairs = encoder.encode_command("VERB", 37)
|
|
assert len(pairs) == 3
|
|
|
|
def test_noun_dispatch(self, encoder):
|
|
pairs = encoder.encode_command("NOUN", 1)
|
|
assert len(pairs) == 3
|
|
|
|
def test_data_dispatch(self, encoder):
|
|
pairs = encoder.encode_command("DATA", 42)
|
|
assert len(pairs) == 6
|
|
|
|
def test_proceed_dispatch(self, encoder):
|
|
pairs = encoder.encode_command("PROCEED")
|
|
assert len(pairs) == 1
|
|
|
|
def test_case_insensitive(self, encoder):
|
|
"""Command type matching is case-insensitive."""
|
|
p1 = encoder.encode_command("verb", 37)
|
|
p2 = encoder.encode_command("Verb", 37)
|
|
p3 = encoder.encode_command("VERB", 37)
|
|
assert p1 == p2 == p3
|
|
|
|
def test_unknown_command(self, encoder):
|
|
with pytest.raises(ValueError, match="unknown command type"):
|
|
encoder.encode_command("ABORT", 0)
|
|
|
|
def test_missing_data_for_verb(self, encoder):
|
|
with pytest.raises(ValueError, match="VERB requires"):
|
|
encoder.encode_command("VERB")
|
|
|
|
def test_missing_data_for_noun(self, encoder):
|
|
with pytest.raises(ValueError, match="NOUN requires"):
|
|
encoder.encode_command("NOUN")
|
|
|
|
def test_missing_data_for_data(self, encoder):
|
|
with pytest.raises(ValueError, match="DATA requires"):
|
|
encoder.encode_command("DATA")
|
|
|
|
|
|
class TestVerbNounConvenience:
|
|
"""encode_verb_noun() full command sequence."""
|
|
|
|
def test_full_sequence_length(self, encoder):
|
|
"""V37N01 ENTER = VERB+3+7 + NOUN+0+1 + ENTER = 7 pairs."""
|
|
pairs = encoder.encode_verb_noun(37, 1)
|
|
assert len(pairs) == 7
|
|
|
|
def test_sequence_structure(self, encoder):
|
|
"""Verify key ordering: VERB, d, d, NOUN, d, d, ENTER."""
|
|
pairs = encoder.encode_verb_noun(16, 65)
|
|
keycodes = [(v >> 10) & 0x1F for _, v in pairs]
|
|
assert keycodes[0] == KEYCODE_VERB
|
|
assert keycodes[1] == KEYCODE_DIGITS[1]
|
|
assert keycodes[2] == KEYCODE_DIGITS[6]
|
|
assert keycodes[3] == KEYCODE_NOUN
|
|
assert keycodes[4] == KEYCODE_DIGITS[6]
|
|
assert keycodes[5] == KEYCODE_DIGITS[5]
|
|
assert keycodes[6] == KEYCODE_ENTER
|
|
|
|
def test_all_pairs_use_inlink(self, encoder):
|
|
pairs = encoder.encode_verb_noun(37, 1)
|
|
for ch, _ in pairs:
|
|
assert ch == AGC_CH_INLINK
|
|
|
|
|
|
class TestPacketCompatibility:
|
|
"""Verify encoded values survive the AGC packet protocol roundtrip."""
|
|
|
|
def test_keycode_survives_packet_roundtrip(self, encoder):
|
|
"""Each (channel, value) pair can be packed/unpacked via form_io_packet."""
|
|
pairs = encoder.encode_verb_noun(37, 1)
|
|
for channel, value in pairs:
|
|
packet = form_io_packet(channel, value)
|
|
got_ch, got_val, _ = parse_io_packet(packet)
|
|
assert got_ch == channel
|
|
assert got_val == value
|
|
|
|
def test_data_value_survives_packet_roundtrip(self, encoder):
|
|
"""Data encoding survives the 15-bit packet protocol."""
|
|
pairs = encoder.encode_data(54321)
|
|
for channel, value in pairs:
|
|
packet = form_io_packet(channel, value)
|
|
got_ch, got_val, _ = parse_io_packet(packet)
|
|
assert got_ch == channel
|
|
assert got_val == value
|