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:
commit
5867c54de3
64
README.md
Normal file
64
README.md
Normal 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
|
||||||
31
grc/sarsat_biphase_l_decode_bb.block.yml
Normal file
31
grc/sarsat_biphase_l_decode_bb.block.yml
Normal 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
|
||||||
39
grc/sarsat_pds_frame_sync.block.yml
Normal file
39
grc/sarsat_pds_frame_sync.block.yml
Normal 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
|
||||||
37
grc/sarsat_sarp_msg_extract.block.yml
Normal file
37
grc/sarsat_sarp_msg_extract.block.yml
Normal 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
34
pyproject.toml
Normal 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
24
python/sarsat/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
45
python/sarsat/biphase_l_decode_bb.py
Normal file
45
python/sarsat/biphase_l_decode_bb.py
Normal 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])
|
||||||
112
python/sarsat/pds_frame_sync.py
Normal file
112
python/sarsat/pds_frame_sync.py
Normal 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
|
||||||
83
python/sarsat/sarp_msg_extract.py
Normal file
83
python/sarsat/sarp_msg_extract.py
Normal 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))
|
||||||
Loading…
x
Reference in New Issue
Block a user