unisat

Track 1 β€” AX.25 Link Layer: Design Spec

⚠️ 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 under docs/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.md for the full archival context.

Status: Archived (was: Approved) Date: 2026-04-17 Author: UniSat engineering Track: 1 of 4 (Protocol Completion)


1. Goals

  1. Implement the AX.25 UI-frame link layer fully specified in docs/design/communication_protocol.md Β§7 β€” both on the satellite (STM32 firmware) and on the ground station (Python).
  2. Provide a reproducible end-to-end demonstration: make demo sends a beacon from the firmware (in SITL), and the ground station decodes and displays it.
  3. Close the two audit findings that formally remain:
    • comm.c:139 is a Placeholder for AX.25 frame parsing.
    • CSP (CubeSat Space Protocol) is not implemented. This is resolved by a written architectural decision record (ADR-001), not by adding CSP code.
  4. Reach a level of documentation, traceability, and testing that a third-party reviewer (jury member or student) can walk the code and reproduce every claim without running a physical board.

2. Non-Goals

3. Reference Documents

4. Architecture

4.1 Layered Model

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.

4.2 Style Adapter β€” Conflict Resolution

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.

4.3 Cross-Platform Virtual Radio (SITL)

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).

4.4 Requirement Traceability

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.

4.5 Pre-Flight Prerequisites

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.

  1. Telemetry_PackBeacon() emits exactly the 48-byte layout from communication_protocol.md Β§7.2.
  2. ccsds.c builds a valid CCSDS Space Packet including secondary header.
  3. 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.

4.6 Performance Budget

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.

4.7 Security β€” Threat Model

AX.25 is an unauthenticated link layer. Attacks and mitigations:

Documented in docs/security/ax25_threat_model.md.

4.8 Blast Radius / Rollback

4.9 Educational Layer (Explicit Override)

For goal B (educational flagship) the project default of β€œno comments unless the WHY is non-obvious” is explicitly relaxed within this track:

4.10 Threading Model (FreeRTOS)

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:

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.

4.11 Throughput Budget

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.


5. Components

5.1 C Library (firmware/stm32/Drivers/AX25/)

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:

5.1a Configurable Limits (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.

5.2 Python Library (ground-station/utils/ax25.py)

Mirrors the C library one-for-one; shares golden vectors.

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

5.3 Virtual UART Shim (SIM-only)

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.

5.4 Ground-Station CLI (ground-station/cli/)

5.5 Integration Touchpoints in Existing Code

Integration inverts the existing dependency: the AX.25 streaming decoder owns the link-layer state machine; comm.c is the consumer.

5.6 Orchestration


6. Data Flow

6.1 TX Path (satellite β†’ ground, beacon)

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

6.2 RX Path (ground β†’ satellite, telecommand)

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.

6.3 Byte Map of an AX.25 UI Frame

[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().


7. Error Handling

7.1 Status Codes

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;

7.2 Principles

7.3 Counters (in COMM_Status_t)

Exposed in telemetry housekeeping packets.

7.4 Recovery


8. Testing

8.1 Golden Vectors (tests/golden/ax25_vectors.json)

β‰₯28 vectors, 7 categories:

  1. Canonical β€” spec beacon; min-size UI (0 B info); max-size UI (256 B).
  2. Bit-stuffing adversarial β€” info containing 0x7E, 0xFE, five consecutive 1s across byte boundaries, all-0xFF payload.
  3. Address edge cases β€” callsign shorter than 6 chars (space-padded), SSIDs 0 and 15, lower ASCII boundary, H-bit polarity.
  4. Digipeater rejection β€” frames with 3+ address fields MUST decode to AX25_ERR_ADDRESS_INVALID.
  5. Malformed β€” bad FCS, missing start flag, missing end flag, info > 256 B, control β‰  0x03, PID β‰  0xF0, stuffing violation (6 ones).
  6. DoS β€” 1000-byte bit-stuffed frame (decoder rejects without hang), all-zero 10 KB stream (scanner does not spin).
  7. Flag-handling edge cases (REQ-AX25-023) β€” idle runs of flags (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"
}

8.2 Unity Tests (firmware/tests/test_ax25.c)

8.3 pytest (ground-station/tests/test_ax25.py)

8.4 Cross-Implementation Round-Trip (scripts/test_roundtrip.sh)

8.5 CI (.github/workflows/ax25.yml)

8.6 Verification Matrix

docs/verification/ax25_trace_matrix.md auto-generated from test docstrings. Every REQ-AX25-NNN has at least one test and one golden vector.

8.7 Demo Acceptance Test

make demo runs a 30-second scenario: firmware emits two beacons, the ground station decodes them, prints the expected JSON. Exit code 0 = pass.


9. Requirements (derived from communication_protocol.md Β§7)


10. Deliverables

  1. firmware/stm32/Drivers/AX25/ β€” pure C library (encode, batch decode, FCS, bit-stuff) + streaming decoder (ax25_decoder.{c,h}) + public AX25_Xxx() adapter facade.
  2. firmware/stm32/Drivers/VirtualUART/ β€” SIM-only TCP shim.
  3. ground-station/utils/ax25.py β€” Python library.
  4. ground-station/cli/ax25_listen.py, ax25_send.py β€” CLI tools.
  5. tests/golden/ax25_vectors.json β€” shared test vectors.
  6. firmware/tests/test_ax25.c β€” Unity tests.
  7. ground-station/tests/test_ax25.py β€” pytest tests (+ hypothesis).
  8. scripts/test_roundtrip.sh, scripts/demo.py, scripts/gen_trace_matrix.py.
  9. .github/workflows/ax25.yml β€” CI pipeline.
  10. docs/adr/ADR-001-no-csp.md β€” CSP architectural decision.
  11. docs/adr/ADR-002-style-adapter.md β€” style-adapter rationale.
  12. docs/security/ax25_threat_model.md.
  13. docs/verification/ax25_trace_matrix.md (auto-generated).
  14. docs/tutorials/ax25_walkthrough.md β€” educational walkthrough.
  15. docs/superpowers/specs/2026-04-17-track1-preflight.md β€” upstream fixes to validate before coding.

11. Out-of-Scope (explicit)


12. Open Questions

None at design time. Pre-flight spec (Β§4.5) may raise concrete issues to close before the implementation plan is written.