commit 5867c54de359b6ce3bc2175ec021352a29016ab2 Author: Ryan Malloy Date: Wed Feb 11 02:22:28 2026 -0700 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b0c0db --- /dev/null +++ b/README.md @@ -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 diff --git a/grc/sarsat_biphase_l_decode_bb.block.yml b/grc/sarsat_biphase_l_decode_bb.block.yml new file mode 100644 index 0000000..3018b8f --- /dev/null +++ b/grc/sarsat_biphase_l_decode_bb.block.yml @@ -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 diff --git a/grc/sarsat_pds_frame_sync.block.yml b/grc/sarsat_pds_frame_sync.block.yml new file mode 100644 index 0000000..be58fd2 --- /dev/null +++ b/grc/sarsat_pds_frame_sync.block.yml @@ -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 diff --git a/grc/sarsat_sarp_msg_extract.block.yml b/grc/sarsat_sarp_msg_extract.block.yml new file mode 100644 index 0000000..9373724 --- /dev/null +++ b/grc/sarsat_sarp_msg_extract.block.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bfe622d --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/python/sarsat/__init__.py b/python/sarsat/__init__.py new file mode 100644 index 0000000..f6b73b0 --- /dev/null +++ b/python/sarsat/__init__.py @@ -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", +] diff --git a/python/sarsat/biphase_l_decode_bb.py b/python/sarsat/biphase_l_decode_bb.py new file mode 100644 index 0000000..324988d --- /dev/null +++ b/python/sarsat/biphase_l_decode_bb.py @@ -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]) diff --git a/python/sarsat/pds_frame_sync.py b/python/sarsat/pds_frame_sync.py new file mode 100644 index 0000000..688bcb1 --- /dev/null +++ b/python/sarsat/pds_frame_sync.py @@ -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 diff --git a/python/sarsat/sarp_msg_extract.py b/python/sarsat/sarp_msg_extract.py new file mode 100644 index 0000000..debadc4 --- /dev/null +++ b/python/sarsat/sarp_msg_extract.py @@ -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))