feat: port gr-sarsat to GNU Radio 3.10+

Ported from zleffke/gr-sarsat (2018, GNU Radio 3.7):
- Updated Python 2 → Python 3
- Converted XML block definitions to YAML
- Added pyproject.toml for modern packaging
- Preserved original MIT license

Blocks:
- biphase_l_decode_bb: Manchester decoder (decim_block)
- pds_frame_sync: Frame synchronizer with PDU output
- sarp_msg_extract: SARP message splitter/validator
This commit is contained in:
Ryan Malloy 2026-02-11 02:22:28 -07:00
commit 5867c54de3
9 changed files with 469 additions and 0 deletions

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# gr-sarsat-modern
GNU Radio 3.10+ compatible Cospas-Sarsat 406 MHz beacon decoder.
Ported from [zleffke/gr-sarsat](https://github.com/zleffke/gr-sarsat) (2018, GNU Radio 3.7).
## Blocks
| Block | Description |
|-------|-------------|
| **Biphase-L Decoder** | Decodes Manchester/Biphase-L encoded bit stream |
| **PDS Frame Sync** | Synchronizes and extracts 72-byte PDS frames |
| **SARP Message Extractor** | Splits PDS frames into 24-byte SARP messages |
## Installation
```bash
# Install Python package
cd gr-sarsat-modern
pip install -e .
# Copy GRC blocks to GNU Radio
cp grc/*.yml ~/.local/share/gnuradio/grc/blocks/
```
## Signal Chain
```
RF Input (406 MHz)
|
v
[FM Demod] -> [Clock Recovery] -> [Slicer]
|
v
[Correlate Access Code - Tag] (Frame sync: 0x7FF, tag: "pds_sync")
|
v
[Biphase-L Decoder]
|
v
[PDS Frame Sync]
|
v
[SARP Message Extractor]
|
+---> valid (sync word OK)
+---> invalid (sync word mismatch)
```
## Protocol Background
Cospas-Sarsat is the international satellite-based search and rescue system:
- **Frequency**: 406.025 MHz
- **Modulation**: Phase-modulated carrier, Biphase-L encoded
- **Bit rate**: 400 bps
- **Frame structure**:
- 15-bit frame sync (0x7FF)
- 576-bit PDS frame (3 × 24-byte SARP messages)
- Each SARP message starts with 0xD60 sync word
## License
MIT License - Original work (c) 2018 zleffke

View File

@ -0,0 +1,31 @@
id: sarsat_biphase_l_decode_bb
label: Biphase-L Decoder
category: '[Sarsat]'
templates:
imports: from sarsat import biphase_l_decode_bb
make: biphase_l_decode_bb()
inputs:
- label: in
domain: stream
dtype: byte
outputs:
- label: out
domain: stream
dtype: byte
documentation: |-
Biphase-L (Manchester) Decoder for Cospas-Sarsat 406 MHz beacons.
Biphase-L encoding represents:
- Bit 0: Low-to-High transition at bit center
- Bit 1: High-to-Low transition at bit center
Input: Unpacked byte stream of Biphase-L encoded symbols (2 samples per bit)
Output: Unpacked byte stream of decoded bits
Decimation factor: 2 (two input samples produce one output bit)
file_format: 1

View File

@ -0,0 +1,39 @@
id: sarsat_pds_frame_sync
label: PDS Frame Sync
category: '[Sarsat]'
parameters:
- id: tag_name
label: Tag Name
dtype: string
default: pds_sync
templates:
imports: from sarsat import pds_frame_sync
make: pds_frame_sync(tag_name=${tag_name})
inputs:
- label: in
domain: stream
dtype: byte
outputs:
- label: out
domain: message
documentation: |-
PDS (Polar-orbiting satellite Data Service) Frame Synchronizer.
Expects "Correlate Access Code - Tag" block upstream with the Biphase-L
decoded frame sync pattern (0x7FF = 15 ones).
When a frame sync tag is detected, collects the following 576 bits (72 bytes)
and emits as a PDU message.
Parameters:
tag_name: Name of the stream tag to look for (default: "pds_sync")
Input: Biphase-L decoded bit stream with frame sync tags
Output: PDU messages containing 72-byte PDS frames
file_format: 1

View File

@ -0,0 +1,37 @@
id: sarsat_sarp_msg_extract
label: SARP Message Extractor
category: '[Sarsat]'
templates:
imports: from sarsat import sarp_msg_extract
make: sarp_msg_extract()
inputs:
- label: in
domain: message
outputs:
- label: valid
domain: message
- label: invalid
domain: message
documentation: |-
SARP (Search And Rescue Processor) Message Extractor.
Each 72-byte PDS frame contains three 24-byte SARP messages.
Each SARP message should start with the sync word 0xD60 (12 bits).
This block splits incoming PDS frames into individual SARP messages
and validates the sync word:
- 'valid' port: Messages with correct sync word (0xD60)
- 'invalid' port: Messages with incorrect sync word
Note: Even messages on the 'invalid' port may contain recoverable
beacon data - check BCH error correction codes.
Input: PDU from PDS Frame Sync block (72 bytes)
Output: Individual 24-byte SARP messages
file_format: 1

34
pyproject.toml Normal file
View File

@ -0,0 +1,34 @@
[project]
name = "gr-sarsat"
version = "1.0.0"
description = "Cospas-Sarsat 406 MHz beacon decoder for GNU Radio 3.10+"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{name = "zleffke", email = ""},
{name = "Ryan Malloy", email = "ryan@supported.systems"},
]
keywords = ["gnuradio", "sdr", "sarsat", "cospas", "beacon", "search-rescue"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Topic :: Communications :: Ham Radio",
]
dependencies = [
"numpy",
]
[project.urls]
"Homepage" = "https://github.com/zleffke/gr-sarsat"
"Repository" = "https://git.supported.systems/MCP/gr-sarsat-modern"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["python"]

24
python/sarsat/__init__.py Normal file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
# Copyright (c) 2018 zleffke
# Ported to GNU Radio 3.10+ by gr-mcp
"""
gr-sarsat: Cospas-Sarsat 406 MHz beacon decoder blocks for GNU Radio.
Blocks:
biphase_l_decode_bb - Biphase-L (Manchester) decoder
pds_frame_sync - PDS frame synchronizer
sarp_msg_extract - SARP message extractor
"""
from .biphase_l_decode_bb import biphase_l_decode_bb
from .pds_frame_sync import pds_frame_sync
from .sarp_msg_extract import sarp_msg_extract
__all__ = [
"biphase_l_decode_bb",
"pds_frame_sync",
"sarp_msg_extract",
]

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
# Copyright (c) 2018 zleffke
# Ported to GNU Radio 3.10+ by gr-mcp
"""
Biphase-L Decoder for Cospas-Sarsat 406 MHz beacons.
Biphase-L (Manchester) encoding represents:
- Bit 0: Low-to-High transition at bit center
- Bit 1: High-to-Low transition at bit center
This decoder extracts the data bit from each symbol pair.
"""
import numpy as np
from gnuradio import gr
class biphase_l_decode_bb(gr.decim_block):
"""
Biphase-L Decoder.
Input: Unpacked byte stream of Biphase-L encoded symbols (2 samples per bit).
Output: Unpacked byte stream of decoded bits.
Decimation factor: 2 (two input samples produce one output bit)
"""
def __init__(self):
gr.decim_block.__init__(
self,
name="biphase_l_decode_bb",
in_sig=[np.int8],
out_sig=[np.int8],
decim=2,
)
def work(self, input_items, output_items):
in0 = input_items[0]
out = output_items[0]
# Extract first sample of each symbol pair (the data bit value)
out[:] = in0[::2]
return len(output_items[0])

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
# Copyright (c) 2018 zleffke
# Ported to GNU Radio 3.10+ by gr-mcp
"""
PDS (Polar-orbiting satellite Data Service) Frame Synchronizer.
Cospas-Sarsat 406 MHz beacons transmit:
- 15-bit frame sync: 0x7FF (all ones, Biphase-L encoded)
- 576-bit PDS frame containing 3 SARP messages
This block:
1. Waits for frame sync tag from upstream correlator
2. Collects the following 576 bits
3. Packs bits into bytes
4. Emits as PDU message
"""
import numpy as np
import pmt
import binascii
from gnuradio import gr
# State machine states
SEARCH = 1
COPY = 2
class pds_frame_sync(gr.sync_block):
"""
PDS Frame Synchronizer.
Expects "Correlate Access Code - Tag" block upstream.
Tag indicates the last bit of the Biphase-L DECODED frame sync.
Operates on unpacked byte stream (one bit per byte).
Input: Biphase-L decoded bit stream with frame sync tags
Output: PDU messages containing 72-byte PDS frames
"""
def __init__(self, tag_name="pds_sync"):
gr.sync_block.__init__(
self,
name="pds_frame_sync",
in_sig=[np.int8],
out_sig=None,
)
self.tag_name = tag_name
self.message_port_register_out(pmt.intern("out"))
self.len_encoded_msg = 576 # Bits in PDS frame (72 bytes)
self.pds_msg = []
self.msg_packed = []
self.msg_count = 0
self.state = SEARCH
def pack_bytes(self):
"""Pack unpacked bit list into bytearray."""
self.msg_count += 1
a = [
int("".join(map(str, self.pds_msg[i : i + 8])), 2)
for i in range(0, len(self.pds_msg), 8)
]
self.msg_packed = bytearray(a)
def work(self, input_items, output_items):
in0 = input_items[0]
num_input_items = len(in0)
return_value = num_input_items
nread = self.nitems_read(0)
if self.state == SEARCH:
tags = self.get_tags_in_window(0, 0, num_input_items)
if len(tags) > 0:
for t in tags:
t_str = pmt.symbol_to_string(t.key)
if t_str == self.tag_name:
# Frame sync detected - start collecting
del self.pds_msg[:]
del self.msg_packed[:]
cur_idx = t.offset - nread
self.pds_msg.extend(in0[cur_idx:])
self.state = COPY
elif self.state == COPY:
cur_msg_len = len(self.pds_msg)
if (cur_msg_len + num_input_items) < self.len_encoded_msg:
# Still collecting bits
self.pds_msg.extend(in0)
else:
# Frame complete
num_remain = self.len_encoded_msg - cur_msg_len
self.pds_msg.extend(in0[0:num_remain])
return_value = num_remain
self.pack_bytes()
msg_str = f"[{self.msg_count:d}] {binascii.hexlify(self.msg_packed).decode()}"
print(msg_str)
# Emit PDU with packed frame
self.message_port_pub(
pmt.intern("out"),
pmt.cons(
pmt.PMT_NIL,
pmt.init_u8vector(len(self.msg_packed), self.msg_packed),
),
)
self.state = SEARCH
return return_value

View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MIT License
# Copyright (c) 2018 zleffke
# Ported to GNU Radio 3.10+ by gr-mcp
"""
SARP (Search And Rescue Processor) Message Extractor.
Each 72-byte PDS frame contains three 24-byte SARP messages.
Each SARP message starts with a 12-bit sync word: 0xD60
This block:
1. Receives PDS frame PDUs
2. Splits into three 24-byte SARP messages
3. Validates sync word (0xD60)
4. Routes to 'valid' or 'invalid' output port
"""
import numpy as np
import pmt
import binascii
from gnuradio import gr
class sarp_msg_extract(gr.basic_block):
"""
SARP Message Extractor.
Input: PDU from PDS Frame Sync Block (72 bytes)
Output: Individual SARP messages (24 bytes each) on 'valid' or 'invalid' ports
A SARP message is considered valid if its sync word matches 0xD60.
Even invalid messages may contain recoverable beacon data via BCH codes.
"""
def __init__(self):
gr.basic_block.__init__(
self,
name="sarp_msg_extract",
in_sig=None,
out_sig=None,
)
self.message_port_register_in(pmt.intern("in"))
self.set_msg_handler(pmt.intern("in"), self.handle_msg)
self.message_port_register_out(pmt.intern("valid"))
self.message_port_register_out(pmt.intern("invalid"))
def _check_sync_word(self, msg: bytearray) -> bool:
"""Check if SARP message has valid sync word (0xD60)."""
# Sync word is first 12 bits: 0xD6 followed by upper nibble 0x0
return (msg[0] == 0xD6) and ((msg[1] & 0xF0) == 0x00)
def _publish_msg(self, msg: bytearray, valid: bool):
"""Publish SARP message to appropriate output port."""
port = "valid" if valid else "invalid"
pdu = pmt.cons(pmt.PMT_NIL, pmt.init_u8vector(len(msg), msg))
self.message_port_pub(pmt.intern(port), pdu)
def handle_msg(self, msg_pmt):
"""Process incoming PDS frame PDU."""
pds_msg = pmt.cdr(msg_pmt)
if not pmt.is_u8vector(pds_msg):
print("[ERROR] Received invalid message type. Expected u8vector")
return
buff = bytearray(pmt.u8vector_elements(pds_msg))
if len(buff) < 72:
print(f"[ERROR] PDS frame too short: {len(buff)} bytes (expected 72)")
return
# Extract three 24-byte SARP messages
sarp_msg_1 = bytearray(buff[0:24])
sarp_msg_2 = bytearray(buff[24:48])
sarp_msg_3 = bytearray(buff[48:72])
# Validate and publish each message
self._publish_msg(sarp_msg_1, self._check_sync_word(sarp_msg_1))
self._publish_msg(sarp_msg_2, self._check_sync_word(sarp_msg_2))
self._publish_msg(sarp_msg_3, self._check_sync_word(sarp_msg_3))