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
275 lines
9.3 KiB
Python
275 lines
9.3 KiB
Python
"""
|
|
Apollo Uplink Command Encoder — formats ground commands for AGC channel 45 (INLINK).
|
|
|
|
The MSFN ground station sends commands to the spacecraft via the Up-Data Link,
|
|
which delivers 15-bit words to AGC I/O channel 045 (octal). Each word triggers
|
|
the UPRUPT interrupt in the flight software.
|
|
|
|
Command encoding follows the DSKY command structure: VERB-NOUN pairs optionally
|
|
followed by data words. This module translates high-level command descriptions
|
|
into the (channel, value) pairs expected by the AGC socket protocol.
|
|
|
|
Standalone class:
|
|
UplinkEncoder — encodes command types into (channel, value) tuples
|
|
|
|
GNU Radio wrapper:
|
|
uplink_encoder — message port block for use in GRC flowgraphs
|
|
|
|
Reference: IMPLEMENTATION_SPEC.md section 1 (INLINK / UPRUPT)
|
|
"""
|
|
|
|
import logging
|
|
|
|
from apollo.constants import AGC_CH_INLINK
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# DSKY key codes (5-bit encoding used by the AGC for keyboard input).
|
|
# These map to the bit patterns the Up-Data Link sends on channel 045.
|
|
# Bits 14-10 carry the key code, bits 9-5 carry additional data for DATA words.
|
|
KEYCODE_VERB = 0o21 # 17 decimal — VERB key
|
|
KEYCODE_NOUN = 0o37 # 31 decimal — NOUN key
|
|
KEYCODE_ENTER = 0o34 # 28 decimal — ENTER / PROCEED
|
|
KEYCODE_RESET = 0o22 # 18 decimal — RESET / KEY RELEASE
|
|
KEYCODE_CLEAR = 0o36 # 30 decimal — CLEAR
|
|
|
|
# Digit keycodes 0-9
|
|
KEYCODE_DIGITS = {
|
|
0: 0o20, # 16 decimal
|
|
1: 0o01,
|
|
2: 0o02,
|
|
3: 0o03,
|
|
4: 0o04,
|
|
5: 0o05,
|
|
6: 0o06,
|
|
7: 0o07,
|
|
8: 0o10, # 8 decimal
|
|
9: 0o11, # 9 decimal
|
|
}
|
|
|
|
KEYCODE_PLUS = 0o32 # 26 decimal — + sign
|
|
KEYCODE_MINUS = 0o33 # 27 decimal — - sign
|
|
|
|
|
|
class UplinkEncoder:
|
|
"""Encodes ground commands into AGC INLINK (channel, value) pairs.
|
|
|
|
Each method returns a list of (channel, value) tuples representing the
|
|
sequence of words to deliver to AGC channel 045. The AGC processes one
|
|
word per UPRUPT, so multi-word sequences must be sent with appropriate
|
|
timing (the bridge handles pacing).
|
|
|
|
Args:
|
|
channel: AGC I/O channel for uplink data. Default is channel 045 (INLINK).
|
|
"""
|
|
|
|
def __init__(self, channel: int = AGC_CH_INLINK):
|
|
self.channel = channel
|
|
|
|
def encode_keycode(self, keycode: int) -> tuple[int, int]:
|
|
"""Encode a single DSKY keycode as a (channel, value) pair.
|
|
|
|
The keycode occupies bits 14-10 of the 15-bit value.
|
|
Bits 9-0 are zero for simple key presses.
|
|
"""
|
|
value = (keycode & 0x1F) << 10
|
|
return (self.channel, value)
|
|
|
|
def encode_digit(self, digit: int) -> tuple[int, int]:
|
|
"""Encode a single decimal digit (0-9)."""
|
|
if digit not in KEYCODE_DIGITS:
|
|
raise ValueError(f"digit must be 0-9, got {digit}")
|
|
return self.encode_keycode(KEYCODE_DIGITS[digit])
|
|
|
|
def encode_verb(self, verb_number: int) -> list[tuple[int, int]]:
|
|
"""Encode a VERB command (e.g., V37 → [VERB, 3, 7]).
|
|
|
|
Args:
|
|
verb_number: Two-digit verb number (0-99).
|
|
|
|
Returns:
|
|
List of (channel, value) pairs: VERB key + two digit keys.
|
|
"""
|
|
if not 0 <= verb_number <= 99:
|
|
raise ValueError(f"verb must be 0-99, got {verb_number}")
|
|
d1 = verb_number // 10
|
|
d2 = verb_number % 10
|
|
return [
|
|
self.encode_keycode(KEYCODE_VERB),
|
|
self.encode_digit(d1),
|
|
self.encode_digit(d2),
|
|
]
|
|
|
|
def encode_noun(self, noun_number: int) -> list[tuple[int, int]]:
|
|
"""Encode a NOUN selection (e.g., N01 → [NOUN, 0, 1]).
|
|
|
|
Args:
|
|
noun_number: Two-digit noun number (0-99).
|
|
|
|
Returns:
|
|
List of (channel, value) pairs: NOUN key + two digit keys.
|
|
"""
|
|
if not 0 <= noun_number <= 99:
|
|
raise ValueError(f"noun must be 0-99, got {noun_number}")
|
|
d1 = noun_number // 10
|
|
d2 = noun_number % 10
|
|
return [
|
|
self.encode_keycode(KEYCODE_NOUN),
|
|
self.encode_digit(d1),
|
|
self.encode_digit(d2),
|
|
]
|
|
|
|
def encode_data(self, value: int, signed: bool = True) -> list[tuple[int, int]]:
|
|
"""Encode a 5-digit data entry (e.g., +12345 → [+, 1, 2, 3, 4, 5]).
|
|
|
|
Args:
|
|
value: Integer data value. If signed, range is -99999 to +99999.
|
|
If unsigned, range is 0 to 99999.
|
|
signed: If True, prepend a +/- sign key.
|
|
|
|
Returns:
|
|
List of (channel, value) pairs for the digit sequence.
|
|
"""
|
|
pairs: list[tuple[int, int]] = []
|
|
|
|
if signed:
|
|
if value < 0:
|
|
pairs.append(self.encode_keycode(KEYCODE_MINUS))
|
|
value = abs(value)
|
|
else:
|
|
pairs.append(self.encode_keycode(KEYCODE_PLUS))
|
|
|
|
if not 0 <= value <= 99999:
|
|
raise ValueError(f"data magnitude must be 0-99999, got {value}")
|
|
|
|
digits = f"{value:05d}"
|
|
for ch in digits:
|
|
pairs.append(self.encode_digit(int(ch)))
|
|
|
|
return pairs
|
|
|
|
def encode_proceed(self) -> list[tuple[int, int]]:
|
|
"""Encode a PROCEED (ENTER) keystroke."""
|
|
return [self.encode_keycode(KEYCODE_ENTER)]
|
|
|
|
def encode_command(
|
|
self, command_type: str, data: int | None = None
|
|
) -> list[tuple[int, int]]:
|
|
"""High-level command encoder dispatching by type.
|
|
|
|
Args:
|
|
command_type: One of "VERB", "NOUN", "DATA", "PROCEED".
|
|
data: Required for VERB (verb number), NOUN (noun number),
|
|
and DATA (integer value). Ignored for PROCEED.
|
|
|
|
Returns:
|
|
List of (channel, value) pairs.
|
|
|
|
Raises:
|
|
ValueError: Unknown command type or missing data.
|
|
"""
|
|
ct = command_type.upper()
|
|
|
|
if ct == "VERB":
|
|
if data is None:
|
|
raise ValueError("VERB requires a verb number")
|
|
return self.encode_verb(data)
|
|
elif ct == "NOUN":
|
|
if data is None:
|
|
raise ValueError("NOUN requires a noun number")
|
|
return self.encode_noun(data)
|
|
elif ct == "DATA":
|
|
if data is None:
|
|
raise ValueError("DATA requires a value")
|
|
return self.encode_data(data)
|
|
elif ct == "PROCEED":
|
|
return self.encode_proceed()
|
|
else:
|
|
raise ValueError(f"unknown command type: {command_type!r}")
|
|
|
|
def encode_verb_noun(self, verb: int, noun: int) -> list[tuple[int, int]]:
|
|
"""Convenience: encode a full V-N-ENTER sequence.
|
|
|
|
Args:
|
|
verb: Verb number (0-99).
|
|
noun: Noun number (0-99).
|
|
|
|
Returns:
|
|
Sequence: VERB + digits + NOUN + digits + ENTER.
|
|
"""
|
|
pairs = self.encode_verb(verb)
|
|
pairs.extend(self.encode_noun(noun))
|
|
pairs.extend(self.encode_proceed())
|
|
return pairs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GNU Radio wrapper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
try:
|
|
import pmt
|
|
from gnuradio import gr
|
|
|
|
class uplink_encoder(gr.basic_block):
|
|
"""GNU Radio block encoding DSKY commands for AGC uplink.
|
|
|
|
Message ports:
|
|
command (input) — PDU with metadata dict containing:
|
|
"type": string ("VERB", "NOUN", "DATA", "PROCEED")
|
|
"data": long (optional, depends on type)
|
|
uplink_words (output) — sequence of PDUs, each containing a
|
|
single (channel, value) pair for the AGC bridge
|
|
"""
|
|
|
|
def __init__(self, channel: int = AGC_CH_INLINK):
|
|
gr.basic_block.__init__(
|
|
self, name="apollo_uplink_encoder", in_sig=[], out_sig=[]
|
|
)
|
|
self.message_port_register_in(pmt.intern("command"))
|
|
self.message_port_register_out(pmt.intern("uplink_words"))
|
|
self.set_msg_handler(pmt.intern("command"), self._handle_command)
|
|
|
|
self._encoder = UplinkEncoder(channel=channel)
|
|
|
|
def _handle_command(self, msg):
|
|
"""Parse a command PDU and emit encoded uplink words."""
|
|
if not pmt.is_pair(msg):
|
|
return
|
|
|
|
meta = pmt.car(msg)
|
|
if not pmt.is_dict(meta):
|
|
return
|
|
|
|
cmd_type_pmt = pmt.dict_ref(
|
|
meta, pmt.intern("type"), pmt.PMT_NIL
|
|
)
|
|
if pmt.is_null(cmd_type_pmt):
|
|
return
|
|
cmd_type = pmt.symbol_to_string(cmd_type_pmt)
|
|
|
|
data_pmt = pmt.dict_ref(meta, pmt.intern("data"), pmt.PMT_NIL)
|
|
data = pmt.to_long(data_pmt) if not pmt.is_null(data_pmt) else None
|
|
|
|
try:
|
|
pairs = self._encoder.encode_command(cmd_type, data)
|
|
except ValueError as exc:
|
|
logger.warning("encode_command failed: %s", exc)
|
|
return
|
|
|
|
for channel, value in pairs:
|
|
out_meta = pmt.make_dict()
|
|
out_meta = pmt.dict_add(
|
|
out_meta, pmt.intern("channel"), pmt.from_long(channel)
|
|
)
|
|
out_meta = pmt.dict_add(
|
|
out_meta, pmt.intern("value"), pmt.from_long(value)
|
|
)
|
|
out_data = pmt.cons(pmt.from_long(channel), pmt.from_long(value))
|
|
self.message_port_pub(
|
|
pmt.intern("uplink_words"), pmt.cons(out_meta, out_data)
|
|
)
|
|
|
|
except ImportError:
|
|
pass
|