β οΈ ARCHIVED β PRE-TRL-5 DOCUMENT
This spec was approved on 2026-04-17 and drove the first implementation of the AX.25 + CCSDS stack. It is preserved for historical reference; current behaviour is documented in the SRS (
docs/requirements/SRS.md), the eight ADRs underdocs/adr/, and the threat model (docs/security/ax25_threat_model.md). The TRL-5 hardening work in Phase 2β8 refined the APIs β when this spec and the current code disagree, the code is authoritative.See
docs/superpowers/README.mdfor the full archival context.
Status: Archived (was: Approved) Date: 2026-04-17 Author: UniSat engineering Track: 1 of 4 (Protocol Completion)
docs/design/communication_protocol.md Β§7 β both on the satellite (STM32 firmware)
and on the ground station (Python).make demo sends a beacon from the firmware (in SITL), and the ground
station decodes and displays it.comm.c:139 is a Placeholder for AX.25 frame parsing.libcsp port (see ADR-001).docs/design/communication_protocol.md Β§7 β AX.25 UI Frame Format.utils/ax25.py and CLI).L4 DEMO ORCHESTRATION
scripts/demo.py β spawns fw-SITL, GS listener, TCP bridge
L3 APPLICATION (existing, minimal touch)
comm.c, telemetry.c Streamlit UI, CLI tools
L2 STYLE ADAPTER (new, thin)
ax25_api.h β AX25_Xxx() ax25_client.py (Pythonic)
L1 PURE AX.25 LIBRARY (new, core)
Drivers/AX25/ax25.c/.h utils/ax25.py
βββββ shared source of truth: tests/golden/ax25_vectors.json βββββ
Each layer depends only on the one below; no sibling dependencies.
Existing firmware uses PascalCase_Xxx() + xxx_t types (embedded-HAL
convention). The pure library uses Google C++ snake_case. To avoid a
half-and-half mix inside a single translation unit, a static-inline facade
exposes project-style names:
/* Public project-facing API in ax25_api.h */
static inline bool AX25_EncodeUiFrame(
const AX25_Address_t *dst, const AX25_Address_t *src,
uint8_t pid, const uint8_t *info, uint16_t info_len,
uint8_t *out_buf, uint16_t out_cap, uint16_t *out_len) {
size_t len_tmp = 0;
ax25_status_t s = ax25_encode_ui_frame(dst, src, pid, info, info_len,
out_buf, out_cap, &len_tmp);
*out_len = (uint16_t)len_tmp;
return s == AX25_OK;
}
Documented in ADR-002.
TCP loopback 127.0.0.1:52100 replaces a potential Unix socket (which is
not portable to pre-Win10 hosts and behaves differently under CI).
In SIMULATION_MODE, a shim (Drivers/VirtualUART/virtual_uart.c) replaces
HAL_UART_Transmit and HAL_UART_Receive_IT with TCP-backed equivalents.
Firmware application code is unchanged.
SITL Limitations (explicit)
SITL provides functional correctness validation only. It does NOT reproduce:
SITL SHALL NOT be used for timing validation. Timing validation is deferred to Track 3 (Renode / QEMU / HIL with a real CC1125 transceiver).
Every bullet in docs/design/communication_protocol.md Β§7 receives an ID of the form
REQ-AX25-NNN. A matrix in docs/verification/ax25_trace_matrix.md maps:
REQ-AX25-NNN β test file : test name β golden vector(s) β status
The matrix is auto-generated from test docstrings by
scripts/gen_trace_matrix.py.
The following upstream issues MUST be verified, and fixed if broken, before
the main AX.25 implementation begins. They are tracked in a separate
artifact: docs/superpowers/specs/2026-04-17-track1-preflight.md.
Telemetry_PackBeacon() emits exactly the 48-byte layout from
communication_protocol.md Β§7.2.ccsds.c builds a valid CCSDS Space Packet including secondary header.HAL_UART_Transmit timeout in config.h is raised from 100 ms to
500 ms β a worst-case bit-stuffed 320-byte frame at 9600 bps takes 266 ms.| Metric | Budget | Rationale |
|---|---|---|
| Encode time | < 5 ms @ 168 MHz | Beacon cadence 30 s |
| Decode time | < 2 ms per frame | Decoder runs in task (see Β§4.10), not ISR |
| Flash footprint | < 6 KB (incl. 512 B CRC) | STM32F446 has 512 KB |
| Static RAM (library) | 1 Γ ax25_decoder_t (~430 B) + encode scratch (~400 B) |
STM32F446 has 128 KB |
| Worst-case stuffing growth | +20 % of frame size | All-0xFF info produces maximum stuffing |
| UART TX timeout (raised) | 500 ms | Covers 266 ms worst-case transmission |
Full throughput derivation is in Β§4.11. Threading invariants that make the decode budget meaningful are in Β§4.10.
AX.25 is an unauthenticated link layer. Attacks and mitigations:
AX25_MAX_FRAME_BYTES (400 B by default; see Β§5.1a) at every stage
(flag scanner, unstuffer, parser).Documented in docs/security/ax25_threat_model.md.
ENABLE_AX25_FRAMING feature flag in config.h (default: on).Drivers/AX25/ β removed by deleting the
folder and one line in CMakeLists.txt.For goal B (educational flagship) the project default of βno comments unless the WHY is non-obviousβ is explicitly relaxed within this track:
ax25.c with inline references to AX.25 v2.2 Β§X.Y.docs/tutorials/ax25_walkthrough.md β byte-by-byte walkthrough of one
real frame with diagrams.notebooks/ax25_interactive.ipynb β interactive visualization
of bit-stuffing and FCS.Strict separation between interrupt context and task context.
βββββββββββββββββββββββββ UART RX interrupt βββββββββββββββββββββββββββ
β HAL_UART_RxCpltCallback β
β ββ COMM_UART_RxCallback(byte) β
β ββ ring buffer push (volatile head/tail, no locks) β
β [CONSTRAINT] no decode, no memcpy > 1 byte, no logging β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (lock-free SPSC ring buffer, 512 B)
βββββββββββββββββββββββββ comm_rx_task (FreeRTOS) βββββββββββββββββββββ
β priority: osPriorityAboveNormal (existing convention) β
β stack: 1024 B β
β period: 10 ms (tick notification) or signal on ring fill β
β loop: β
β while (ring_not_empty): β
β byte = ring_pop() β
β status = ax25_decoder_push_byte(&dec, byte, &frame, &rdy) β
β if (rdy): β
β CCSDS_Dispatcher_Submit(&frame.info[0], frame.info_len) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Invariants:
uint8_t push and nothing else.comm.c and the task wrapper.ax25_decoder_t instance per RX channel. Decoders are not shared
across threads.Worst-case latency analysis:
Ring buffer depth: 512 B
UART bit rate: 9600 bps β 1200 B/s
Full-buffer drain time: 512 / 1200 = 427 ms
Task period: 10 ms β up to 12 bytes buffered between wakeups
Backlog headroom: 512 - 12 = 500 B (40Γ safety factor)
The ring buffer can hold 427 ms of incoming UART data. The task runs every 10 ms, so it only needs to drain ~12 bytes per wakeup. Overflow is physically impossible under nominal conditions at 9600 bps.
All numbers derived for the worst-case bit-stuffed frame (+20 % growth).
Bit rate: 9600 bps
Byte rate: 1200 B/s
Max stuffed frame: 400 B (configurable, see Β§5.7)
Wire time per frame: 400 / 1200 = 333 ms
Max frame rate: 3.0 frames/s
Per-frame decoder cost: < 2 ms (budget from Β§4.6)
CPU time/s at max load: 3.0 Γ 2 ms = 6 ms/s
CPU utilization: 6 ms / 1000 ms = 0.6 %
Decoder throughput ceiling: 1000 / 2 = 500 frames/s
Safety margin over radio: 500 / 3 = 166Γ headroom
Conclusion: The decoder cannot saturate the CPU under any physically realizable UART load. The ring buffer provides 427 ms of absorb capacity against scheduling jitter. No backpressure mechanism is required at the link layer at 9600 bps. If the UART bit rate ever rises above ~480 kbps (400Γ nominal), this budget must be re-derived.
firmware/stm32/Drivers/AX25/)ax25.h β public pure-library API (snake_case).ax25.c β implementation (encode, batch-decode, FCS, bit-stuffing).ax25_decoder.h / ax25_decoder.c β streaming decoder (stateful).ax25_api.h β static inline facade (AX25_Xxx()).ax25_types.h β ax25_address_t, ax25_ui_frame_t, ax25_status_t,
ax25_decoder_t.Pure / stateless API (used internally by the streaming decoder, and directly in unit tests where a complete frame is given):
ax25_status_t ax25_encode_ui_frame(
const ax25_address_t *dst, const ax25_address_t *src,
uint8_t pid,
const uint8_t *info, size_t info_len,
uint8_t *out, size_t out_cap, size_t *out_len);
ax25_status_t ax25_decode_ui_frame(
const uint8_t *in, size_t in_len,
ax25_ui_frame_t *out_frame);
uint16_t ax25_fcs_crc16(const uint8_t *data, size_t len);
size_t ax25_bit_stuff(const uint8_t *in, size_t in_len,
uint8_t *out, size_t out_cap);
size_t ax25_bit_unstuff(const uint8_t *in, size_t in_len,
uint8_t *out, size_t out_cap);
Streaming decoder (first-class stateful type, consumed byte-by-byte
from the comm_rx_task):
typedef enum {
AX25_STATE_HUNT, /* scanning for opening 0x7E flag */
AX25_STATE_FRAME /* inside frame, accumulating bits into shift reg*/
} ax25_decoder_state_t;
typedef struct {
uint8_t buf[AX25_MAX_FRAME_BYTES]; /* assembled bytes */
size_t len; /* bytes written so far*/
uint16_t shift_reg; /* bit-level state */
uint8_t bit_count; /* bits in shift_reg */
uint8_t ones_run; /* consecutive-1 counter*/
ax25_decoder_state_t state;
uint32_t frames_ok; /* statistics */
uint32_t frames_fcs_err;
uint32_t frames_overflow;
} ax25_decoder_t;
void ax25_decoder_init(ax25_decoder_t *d);
void ax25_decoder_reset(ax25_decoder_t *d);
/* Returns:
* AX25_OK β byte consumed; *frame_ready == true means
* *out_frame is populated with a valid frame.
* AX25_ERR_* β byte consumed but produced a malformed frame
* (counter incremented); *frame_ready == false;
* decoder is back in AX25_STATE_HUNT.
*
* This function NEVER returns without consuming the byte. It NEVER blocks.
* It NEVER allocates. It is safe to call at up to the CPU's clock rate,
* but the spec guarantees correctness at β€ 480 kbps (see Β§4.11).
*/
ax25_status_t ax25_decoder_push_byte(
ax25_decoder_t *d,
uint8_t byte,
ax25_ui_frame_t *out_frame,
bool *frame_ready);
Key design properties:
comm.c depends on ax25_decoder_t;
the decoder does not depend on comm.c, HAL, or FreeRTOS.comm_rx_task).shift_reg + bit_count + ones_run
track bits across byte boundaries β satisfies REQ-AX25-016.AX25_STATE_HUNT, but
never corrupts or leaks state.ax25_decode_ui_frame() is used by
the streaming decoder internally (to validate/parse a complete frame
once the closing flag is found) and is directly callable from unit
tests and offline tools.config.h)All hard limits surface as compile-time constants β no magic numbers in library code:
#define AX25_MAX_INFO_LEN 256 /* bytes; AX.25 v2.2 hard max */
#define AX25_MAX_FRAME_BYTES 400 /* stuffed; margin above +20% */
#define AX25_RING_BUFFER_SIZE 512 /* UART RX ring; 427 ms @ 9600 */
#define AX25_DECODER_TASK_STACK 1024 /* bytes */
#define AX25_DECODER_TASK_PRIO osPriorityAboveNormal
Library code references these symbolically. Tests override them via
-D for boundary-case exercises.
ground-station/utils/ax25.py)Mirrors the C library one-for-one; shares golden vectors.
@dataclass(frozen=True) types: Address(callsign: str, ssid: int),
UiFrame(dst: Address, src: Address, pid: int, info: bytes, fcs: int,
fcs_valid: bool).Pure / stateless functions:
encode_ui_frame, decode_ui_frame, fcs_crc16, bit_stuff,
bit_unstuff.
Streaming decoder (class Ax25Decoder):
class Ax25Decoder:
def __init__(self) -> None: ...
def reset(self) -> None: ...
def push_byte(self, byte: int) -> Optional[UiFrame]:
"""Feed one byte. Returns a UiFrame when a complete valid frame
is assembled, else None. Malformed frames raise AX25Error and
reset the decoder to HUNT state."""
Exception hierarchy:
AX25Error
βββ FcsMismatch
βββ FrameOverflow
βββ InvalidAddress
βββ InvalidControl
βββ InvalidPid
βββ StuffingViolation
βββ DigipeaterUnsupported
firmware/stm32/Drivers/VirtualUART/virtual_uart.{c,h} β compiled only when
SIMULATION_MODE=1. Connects to 127.0.0.1:52100 as a TCP client,
replaces HAL UART functions via link-time substitution.
ground-station/cli/)ax25_listen.py β TCP client, decodes incoming frames, prints JSON.ax25_send.py β reads hex/JSON input, encodes, sends over TCP.Integration inverts the existing dependency: the AX.25 streaming decoder
owns the link-layer state machine; comm.c is the consumer.
comm.c:
ax25_decoder_t g_uhf_decoder;
initialised in COMM_Init().COMM_UART_RxCallback() (ISR) β unchanged; still pushes one byte
to the ring buffer.COMM_ProcessRxBuffer() β replaced. Now drains the ring buffer and
calls ax25_decoder_push_byte() per byte. On frame_ready, passes
frame.info[] to the CCSDS dispatcher.COMM_SendAX25(channel, dst, src, info, info_len) β
calls ax25_encode_ui_frame() then the existing UART transmit path.comm.h β adds ax25_frames_ok, ax25_fcs_errors, ax25_frame_errors,
ax25_overflow_errors to COMM_Status_t (mirrored from decoder stats).config.h β adds ENABLE_AX25_FRAMING feature flag and the
AX25_MAX_* / AX25_DECODER_* compile-time constants from Β§5.1a.comm_rx_task (either new or existing) polls the ring
buffer every 10 ms per Β§4.10.scripts/demo.py β spawns fw-SITL and GS listener, pipes stdout.Makefile targets: lib-c, lib-py, goldens, demo, trace-matrix.Telemetry_PackBeacon(buf, 48) // existing
CCSDS_BuildSpacePacket(buf, 48, ccsds_out, 66) // existing
AX25_EncodeUiFrame(dst=CQ-0, src=UN8SAT-1,
pid=0xF0, info=ccsds_out, 66)
ββ prepend flag 0x7E
ββ encode 14 B address field (callsign << 1, SSID byte)
ββ emit 0x03 (UI control), 0x0F (PID no L3)
ββ emit info (66 B CCSDS packet)
ββ compute FCS = CRC-16/AX.25 over address..info
ββ bit-stuff bytes between flags
ββ append flag 0x7E
HAL_UART_Transmit(huart1, frame, n, 500 /*ms*/)
β
[ SIMULATION_MODE: virtual_uart.c β TCP 127.0.0.1:52100 ]
β
ax25_listen.py β decode β print JSON beacon
ax25_send.py --dst UN8SAT-1 --info <hex>
β TCP 127.0.0.1:52100
HAL_UART_Receive_IT (real or virtual shim)
β COMM_UART_RxCallback(byte) // ISR, existing
β ring_push(uhf_rx_buffer, byte) // lock-free SPSC
ββββββββββββββββββ task / ISR boundary ββββββββββββββββββ
FreeRTOS comm_rx_task (period 10 ms, priority above-normal)
loop:
while ring_not_empty:
byte = ring_pop()
ax25_decoder_push_byte(&g_uhf_decoder, byte, &frame, &ready)
ββ internally: feed bit-level shift register
ββ on 0x7E in HUNT β transition to FRAME
ββ on 0x7E in FRAME β validate FCS, populate out_frame, ready=true
ββ on >5 ones run β StuffingViolation (reset to HUNT)
ββ on buf overflow β FrameOverflow (reset to HUNT)
if ready:
CCSDS_Dispatcher_Submit(frame.info, frame.info_len)
The decoder owns the protocol state machine end-to-end. comm.c is a
pure byte pump between the ring buffer and the decoder. No flag
scanning, no bit manipulation, no CRC logic lives in comm.c.
[0x7E] [Dest 7 B] [Src 7 B] [0x03] [0xF0] [Info β€256 B] [FCS 2 B LE] [0x7E]
β β β β β β β β
β β β β β β β end flag
β β β β β β CRC-16/AX.25
β β β β β CCSDS Space Packet
β β β β PID (0xF0 = no L3)
β β β UI control (0x03)
β β callsign + SSID (encoded per AX.25 v2.2 Β§3.12)
β callsign + SSID (encoded per AX.25 v2.2 Β§3.12, H-bit last)
HDLC flag; bit-stuffing is not applied to flags themselves
Exact bit-level address encoding (left-shift by 1, H-bit on last SSID
byte, C/R bits) is specified in AX.25 v2.2 Β§3.12 and implemented in
ax25_encode_address() / ax25_decode_address().
typedef enum {
AX25_OK = 0,
AX25_ERR_FLAG_MISSING,
AX25_ERR_FCS_MISMATCH,
AX25_ERR_INFO_TOO_LONG, /* info > AX25_MAX_INFO_LEN */
AX25_ERR_FRAME_TOO_LONG, /* stuffed frame > AX25_MAX_FRAME_BYTES
(DoS guard; see Β§4.11) */
AX25_ERR_BUFFER_OVERFLOW,
AX25_ERR_ADDRESS_INVALID, /* non-alnum or >6-char callsign, OR
>2 address fields (digipeater path,
see REQ-AX25-018) */
AX25_ERR_CONTROL_INVALID, /* control != 0x03 */
AX25_ERR_PID_INVALID, /* pid != 0xF0 */
AX25_ERR_STUFFING_VIOLATION /* > 5 consecutive 1s in payload */
} ax25_status_t;
Error_Handler().
Failures are reported via return code only.AX25_MAX_FRAME_BYTES
(400 B by default, configurable per Β§5.1a) at every stage.AX25Error subclass; ax25_listen.py catches,
logs, and continues β the process never crashes.COMM_Status_t)ax25_frames_okax25_fcs_errorsax25_frame_errorsax25_overflow_errorsExposed in telemetry housekeeping packets.
ax25_overflow_errors exceeds a per-window threshold, uhf_rx_buffer
is flushed and error_handler.c logs ERR_COMM_RX_OVERFLOW.tests/golden/ax25_vectors.json)β₯28 vectors, 7 categories:
0x7E, 0xFE,
five consecutive 1s across byte boundaries, all-0xFF payload.AX25_ERR_ADDRESS_INVALID.0x7E 0x7E 0x7E) silently ignored; back-to-back frames sharing
a flag (β¦ 0x7E 0x7E β¦) yield two valid frames; trailing flag
runs after a valid frame do not produce spurious errors.Dedicated FCS oracle (REQ-AX25-022): the golden set includes a
fixed entry {"input": "123456789", "fcs": "0x906E"} that both C
and Python test runners assert before any other test runs.
Vector schema:
{
"description": "...",
"reqs": ["REQ-AX25-006", "REQ-AX25-007"],
"inputs": { ... },
"encoded_hex": "...",
"decode_result": { ... },
"decode_status": "AX25_OK"
}
firmware/tests/test_ax25.c)encode, pure decode_ui_frame, fcs,
bit_stuff, address encoding, boundary cases.ax25_decoder_t;
assert that the emitted frame is byte-identical to the output of
the pure ax25_decode_ui_frame() on the same input.frame_ready == false and no state corruption; then feed the
remainder, assert success.gcovr).ground-station/tests/test_ax25.py)hypothesis property-based: decode(encode(x)) == x for random valid
frames within size bounds.scripts/test_roundtrip.sh).github/workflows/ax25.yml)lint-c (clang-format Google), lint-py (pyink, pylint),
build, unit-c, unit-py, fuzz (60-second timeout, 100 000 iters),
roundtrip, coverage (β₯95 % gate).docs/verification/ax25_trace_matrix.md auto-generated from test
docstrings. Every REQ-AX25-NNN has at least one test and one golden
vector.
make demo runs a 30-second scenario: firmware emits two beacons, the
ground station decodes them, prints the expected JSON. Exit code 0 = pass.
communication_protocol.md Β§7)AX25_MAX_FRAME_BYTES
(default 400 bytes, configurable per Β§5.1a) at every
stage.ax25_ui_frame_t byte-
identical to the output of ax25_decode_ui_frame() called on the
same full buffer.AX25_ERR_ADDRESS_INVALID. Digipeater support is explicitly out
of scope (see Β§11).AX25_STATE_HUNT, and does not leak bytes of the malformed
frame into subsequent frames.fcs_crc16("123456789") == 0x906E.
This exact value MUST be asserted in both the C Unity test suite
and the Python pytest suite. It protects against the three most
common silent bugs: bit-order reversal, missing input/output
reflection, and XorOut endian confusion.0x7E 0x7E, and any run
of flags) MUST be treated as frame delimiters with zero-length
payload between them and MUST be silently ignored β no error,
no counter increment, no frame emission. This is the standard
AX.25 idle pattern between frames.AX25_STATE_FRAME, the decoder MUST:
AX25_STATE_HUNT to scan for the next opening flag.firmware/stm32/Drivers/AX25/ β pure C library (encode, batch decode,
FCS, bit-stuff) + streaming decoder (ax25_decoder.{c,h}) + public
AX25_Xxx() adapter facade.firmware/stm32/Drivers/VirtualUART/ β SIM-only TCP shim.ground-station/utils/ax25.py β Python library.ground-station/cli/ax25_listen.py, ax25_send.py β CLI tools.tests/golden/ax25_vectors.json β shared test vectors.firmware/tests/test_ax25.c β Unity tests.ground-station/tests/test_ax25.py β pytest tests (+ hypothesis).scripts/test_roundtrip.sh, scripts/demo.py,
scripts/gen_trace_matrix.py..github/workflows/ax25.yml β CI pipeline.docs/adr/ADR-001-no-csp.md β CSP architectural decision.docs/adr/ADR-002-style-adapter.md β style-adapter rationale.docs/security/ax25_threat_model.md.docs/verification/ax25_trace_matrix.md (auto-generated).docs/tutorials/ax25_walkthrough.md β educational walkthrough.docs/superpowers/specs/2026-04-17-track1-preflight.md β upstream
fixes to validate before coding.libcsp port β addressed by ADR-001, not by code.make all across the whole repo) β Track 4.AX25_ERR_ADDRESS_INVALID (REQ-AX25-018). This is a mission
constraint: UN8SAT-1 communicates directly with the ground station
with no relay hops.None at design time. Pre-flight spec (Β§4.5) may raise concrete issues to close before the implementation plan is written.