lrfhss 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. lrfhss-1.0.0/CHANGELOG.md +23 -0
  2. lrfhss-1.0.0/LICENSE +21 -0
  3. lrfhss-1.0.0/MANIFEST.in +11 -0
  4. lrfhss-1.0.0/PKG-INFO +203 -0
  5. lrfhss-1.0.0/README.md +167 -0
  6. lrfhss-1.0.0/examples/data/capture_39kHz_hdr3.wav +0 -0
  7. lrfhss-1.0.0/lrfhss/__init__.py +31 -0
  8. lrfhss-1.0.0/lrfhss/acquire.py +84 -0
  9. lrfhss-1.0.0/lrfhss/arbitrate.py +123 -0
  10. lrfhss-1.0.0/lrfhss/config.py +201 -0
  11. lrfhss-1.0.0/lrfhss/detect.py +451 -0
  12. lrfhss-1.0.0/lrfhss/dsp.py +150 -0
  13. lrfhss-1.0.0/lrfhss/encoder.py +256 -0
  14. lrfhss-1.0.0/lrfhss/fft.py +12 -0
  15. lrfhss-1.0.0/lrfhss/frontend.py +131 -0
  16. lrfhss-1.0.0/lrfhss/header.py +358 -0
  17. lrfhss-1.0.0/lrfhss/payload.py +313 -0
  18. lrfhss-1.0.0/lrfhss/phy/__init__.py +3 -0
  19. lrfhss-1.0.0/lrfhss/phy/fec.py +209 -0
  20. lrfhss-1.0.0/lrfhss/phy/framing.py +181 -0
  21. lrfhss-1.0.0/lrfhss/phy/gmsk.py +197 -0
  22. lrfhss-1.0.0/lrfhss/phy/hopping.py +132 -0
  23. lrfhss-1.0.0/lrfhss/pipeline.py +107 -0
  24. lrfhss-1.0.0/lrfhss/plotting.py +53 -0
  25. lrfhss-1.0.0/lrfhss/quality.py +47 -0
  26. lrfhss-1.0.0/lrfhss/report.py +99 -0
  27. lrfhss-1.0.0/lrfhss/sdr.py +309 -0
  28. lrfhss-1.0.0/lrfhss/wavio.py +103 -0
  29. lrfhss-1.0.0/lrfhss.egg-info/PKG-INFO +203 -0
  30. lrfhss-1.0.0/lrfhss.egg-info/SOURCES.txt +57 -0
  31. lrfhss-1.0.0/lrfhss.egg-info/dependency_links.txt +1 -0
  32. lrfhss-1.0.0/lrfhss.egg-info/requires.txt +8 -0
  33. lrfhss-1.0.0/lrfhss.egg-info/top_level.txt +1 -0
  34. lrfhss-1.0.0/pyproject.toml +75 -0
  35. lrfhss-1.0.0/setup.cfg +4 -0
  36. lrfhss-1.0.0/setup.py +59 -0
  37. lrfhss-1.0.0/src/common.hpp +17 -0
  38. lrfhss-1.0.0/src/crc.cpp +55 -0
  39. lrfhss-1.0.0/src/crc.hpp +12 -0
  40. lrfhss-1.0.0/src/crc16_lut.inc +20 -0
  41. lrfhss-1.0.0/src/crc8_lut.inc +21 -0
  42. lrfhss-1.0.0/src/deinterleave.cpp +64 -0
  43. lrfhss-1.0.0/src/deinterleave.hpp +12 -0
  44. lrfhss-1.0.0/src/demod.cpp +199 -0
  45. lrfhss-1.0.0/src/demod.hpp +15 -0
  46. lrfhss-1.0.0/src/filters.cpp +160 -0
  47. lrfhss-1.0.0/src/filters.hpp +17 -0
  48. lrfhss-1.0.0/src/module.cpp +35 -0
  49. lrfhss-1.0.0/src/viterbi.cpp +223 -0
  50. lrfhss-1.0.0/src/viterbi.hpp +29 -0
  51. lrfhss-1.0.0/src/viterbi_dp.cpp +96 -0
  52. lrfhss-1.0.0/src/viterbi_dp.hpp +31 -0
  53. lrfhss-1.0.0/tests/conftest.py +27 -0
  54. lrfhss-1.0.0/tests/test_build.py +19 -0
  55. lrfhss-1.0.0/tests/test_capture.py +100 -0
  56. lrfhss-1.0.0/tests/test_config.py +98 -0
  57. lrfhss-1.0.0/tests/test_datarate.py +122 -0
  58. lrfhss-1.0.0/tests/test_phy.py +101 -0
  59. lrfhss-1.0.0/tests/test_roundtrip.py +141 -0
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0] - 2026-10-09
10
+
11
+ First public release.
12
+
13
+ - Blind LR-FHSS receiver: decodes all six EU868 and US915 LR-FHSS data rates
14
+ (DR8 to DR11, DR5 and DR6) without knowing a packet's timing, carrier
15
+ frequency or hopping sequence.
16
+ - Standard-compliant encoder that reuses the receiver's trellis, CRC,
17
+ whitening and interleaver.
18
+ - Live reception from an SDR through SoapySDR (`lrfhss.sdr`).
19
+ - Optional C++ core (`lrfhss._viterbi_ext`), bit-exact with the numpy
20
+ implementation, shipped in the binary wheels.
21
+
22
+ [Unreleased]: https://github.com/ShayanMajumder/LR-FHSS/compare/v1.0.0...HEAD
23
+ [1.0.0]: https://github.com/ShayanMajumder/LR-FHSS/releases/tag/v1.0.0
lrfhss-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shayan Majumder <shayan.majumder2@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ # The sdist must carry everything needed to compile lrfhss._viterbi_ext:
2
+ # setuptools picks up the .cpp sources listed in setup.py, but not the
3
+ # headers and lookup tables they include.
4
+ graft src
5
+ include CHANGELOG.md
6
+
7
+ # Tests and the one recording they read, so the sdist can be verified.
8
+ include tests/*.py
9
+ include examples/data/capture_39kHz_hdr3.wav
10
+
11
+ global-exclude *.so *.pyd *.o *.obj *.py[cod] __pycache__
lrfhss-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: lrfhss
3
+ Version: 1.0.0
4
+ Summary: Blind LR-FHSS receiver and encoder: detects, synchronises and decodes LR-FHSS packets from raw IQ
5
+ Author-email: Shayan Majumder <shayan.majumder2@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ShayanMajumder/LR-FHSS
8
+ Project-URL: Source, https://github.com/ShayanMajumder/LR-FHSS
9
+ Project-URL: Issues, https://github.com/ShayanMajumder/LR-FHSS/issues
10
+ Project-URL: Changelog, https://github.com/ShayanMajumder/LR-FHSS/blob/main/CHANGELOG.md
11
+ Keywords: LR-FHSS,LoRa,LoRaWAN,SDR,satellite IoT,physical layer,receiver
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Intended Audience :: Telecommunications Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: C++
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Communications
25
+ Classifier: Topic :: Scientific/Engineering
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.22
30
+ Requires-Dist: scipy>=1.9
31
+ Provides-Extra: plots
32
+ Requires-Dist: matplotlib>=3.5; extra == "plots"
33
+ Provides-Extra: test
34
+ Requires-Dist: pytest>=7; extra == "test"
35
+ Dynamic: license-file
36
+
37
+ # PyLR-FHSS
38
+
39
+ [![CI](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml/badge.svg)](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/lrfhss)](https://pypi.org/project/lrfhss/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/lrfhss)](https://pypi.org/project/lrfhss/)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/ShayanMajumder/LR-FHSS/blob/main/LICENSE)
43
+
44
+ A blind LR-FHSS receiver. Given raw IQ, it finds packets without being told
45
+ where they are: matched-filter sync search, header decode, then the
46
+ LFSR-predicted hop schedule to gather and decode the payload.
47
+
48
+ ![The bundled recording, decoded](examples/plots/packet_spectrogram.png)
49
+
50
+ ![The same recording at -22 dB SNR, still decoded](examples/plots/packet_SNR_-22.png)
51
+
52
+ Both are the bundled recording, a real over-the-air packet carrying
53
+ "hello world", decoded and drawn by `examples/decode_recording.py`. Each box
54
+ marks where the receiver found a header replica (red) or a payload fragment
55
+ (cyan).
56
+
57
+ - **Top: as captured.** Three header replicas, then three payload fragments,
58
+ each a narrow line on its own hop frequency.
59
+ - **Bottom: buried in noise.** The same capture with white noise added down
60
+ to -22 dB SNR, so the signal is about 160 times weaker than the noise in a
61
+ 125 kHz reference bandwidth. The packet is invisible, yet the receiver
62
+ still finds it without being told where to look, locks onto it and decodes
63
+ the payload with a passing CRC. The boxes land where they did in the clean
64
+ capture.
65
+
66
+ Why it survives: each hop is only about 488 Hz wide. Filtering down to one
67
+ hop throws away 99.6% of the noise in 125 kHz, which leaves an SNR of about
68
+ +2 dB inside the hop. The header is sent three times and the copies are
69
+ combined, and the payload is protected by convolutional coding and a CRC.
70
+ For comparison, LoRa's most robust setting, SF12, is specified down to
71
+ -20 dB SNR.
72
+
73
+ -22 dB is near this packet's limit, so whether it decodes depends on the
74
+ noise draw. To try it, set `SNR_DB` and `NOISE_SEED` at the top of the
75
+ script.
76
+
77
+ ## Install
78
+
79
+ Python 3.10+:
80
+
81
+ ```bash
82
+ pip install lrfhss # or "lrfhss[plots]" for the spectrogram plots
83
+ ```
84
+
85
+ Wheels for Linux, macOS and Windows include the compiled core
86
+ (`lrfhss._viterbi_ext`), which the hot paths use automatically. To work on
87
+ the code instead, install from a clone, which needs a C++ compiler:
88
+
89
+ ```bash
90
+ git clone https://github.com/ShayanMajumder/LR-FHSS.git
91
+ cd LR-FHSS
92
+ python3 -m venv venv
93
+ source venv/bin/activate
94
+ pip install -e ".[test]" # add ",plots" for matplotlib
95
+ ```
96
+
97
+ The compiled core is an accelerator, not a requirement: if it cannot be
98
+ built, the install still succeeds and everything runs on numpy, bit-for-bit
99
+ identical, just several times slower (`LRFHSS_NO_EXT=1` skips the build on
100
+ purpose). The fallback is silent, so to check:
101
+
102
+ ```bash
103
+ python3 -c "import lrfhss; print(lrfhss.config._HAVE_VEXT_LOCAL)"
104
+ ```
105
+
106
+ Live SDR reception additionally needs SoapySDR, which is a system package
107
+ rather than a pip one -- see [`examples/arduino/`](https://github.com/ShayanMajumder/LR-FHSS/tree/main/examples/arduino) and
108
+ `examples/live_receive.py`.
109
+
110
+ ## Decode a capture
111
+
112
+ ```python
113
+ import lrfhss
114
+
115
+ lrfhss.config.retune(722_660, sync_word='12AD101B', hdr_count=4)
116
+
117
+ for packet in lrfhss.decode('capture.wav'):
118
+ if packet['crc']:
119
+ print(bytes(packet['bytes']))
120
+ ```
121
+
122
+ Two things the receiver cannot work out for itself:
123
+
124
+ - **bandwidth**, which sets the sample rate, symbol length and dwell times.
125
+ - **`hdr_count`**, the number of header replicas (1-4). It is not carried in
126
+ the header, and a wrong value puts the payload window a whole dwell out.
127
+
128
+ Grid and coding rate *are* in the header, so they are not settings. For
129
+ standards-compliant traffic, `retune_dr(8)` or `retune_dr(5, region='US915')`
130
+ sets everything at once, replica count included.
131
+
132
+ `decode()` returns every candidate it considered; `packet['crc']` is the
133
+ accept gate. The rejects come back too, because they are what you need when
134
+ a capture will not decode.
135
+
136
+ ## Generate a packet
137
+
138
+ ```python
139
+ iq, meta = lrfhss.encode(b'hello world', dr=8)
140
+ iq, meta = lrfhss.encode(b'hello world', bw_khz=722.66, CR=0)
141
+ ```
142
+
143
+ The encoder reuses the decoder's own trellis, CRC, whitening and interleaver,
144
+ so the two are compatible by construction.
145
+
146
+ ## Examples
147
+
148
+ ```bash
149
+ python3 examples/decode_recording.py # the bundled recording, no setup
150
+ python3 examples/encode.py # generate a packet and decode it back
151
+ python3 examples/live_receive.py # decode off an SDR, live
152
+ ```
153
+
154
+ Each is a flat script: edit the settings block at the top and run it.
155
+ [`examples/arduino/`](https://github.com/ShayanMajumder/LR-FHSS/tree/main/examples/arduino) has two transmitter sketches, for an
156
+ STM32 with an SX1262 or an LR1120, to give `live_receive.py` something to
157
+ hear.
158
+
159
+ ## Tests
160
+
161
+ ```bash
162
+ pytest # 99 tests, ~45 s
163
+ ```
164
+
165
+ Self-contained: nothing is skipped and nothing needs external data. CI runs
166
+ them on Linux, macOS and Windows for every push and pull request.
167
+
168
+ ## Layout
169
+
170
+ ```
171
+ lrfhss/
172
+ config every retunable parameter, retune(), DR tables
173
+ frontend decimating channeliser dsp filters, notching
174
+ detect matched filter, CFAR, sync header header decode, CFO
175
+ payload payload window and decode quality energy/length checks
176
+ acquire IQ + candidates arbitrate which are real packets
177
+ pipeline decode() -- orders the above encoder encode()
178
+ sdr LiveReceiver (optional) plotting spectrograms
179
+ phy/ hopping, fec, gmsk, framing -- protocol primitives
180
+ src/ C++ core, one file per role
181
+ examples/ runnable scripts
182
+ ```
183
+
184
+ `arbitrate` is the one to read first if you are changing behaviour: one
185
+ physical packet appears as several candidates, and deciding which are real is
186
+ where this receiver has historically gone wrong.
187
+
188
+ ## Collaboration and support
189
+
190
+ Open to collaborations :) If you would like some personal support replicating
191
+ these results, let me know: [shayan.majumder2@gmail.com](mailto:shayan.majumder2@gmail.com).
192
+
193
+ ## Acknowledgements
194
+
195
+ Thanks to the [Microwaves and Engineering group](https://microwaves.site.hw.ac.uk/)
196
+ at Heriot-Watt University for supporting this work, and to
197
+ [jumanamirza/LR-FHSS-receiver](https://github.com/jumanamirza/LR-FHSS-receiver) for making her work open source.
198
+
199
+ ## License
200
+
201
+ MIT, see [LICENSE](https://github.com/ShayanMajumder/LR-FHSS/blob/main/LICENSE).
202
+
203
+ Shayan Majumder <shayan.majumder2@gmail.com>
lrfhss-1.0.0/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # PyLR-FHSS
2
+
3
+ [![CI](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml/badge.svg)](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/lrfhss)](https://pypi.org/project/lrfhss/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/lrfhss)](https://pypi.org/project/lrfhss/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/ShayanMajumder/LR-FHSS/blob/main/LICENSE)
7
+
8
+ A blind LR-FHSS receiver. Given raw IQ, it finds packets without being told
9
+ where they are: matched-filter sync search, header decode, then the
10
+ LFSR-predicted hop schedule to gather and decode the payload.
11
+
12
+ ![The bundled recording, decoded](examples/plots/packet_spectrogram.png)
13
+
14
+ ![The same recording at -22 dB SNR, still decoded](examples/plots/packet_SNR_-22.png)
15
+
16
+ Both are the bundled recording, a real over-the-air packet carrying
17
+ "hello world", decoded and drawn by `examples/decode_recording.py`. Each box
18
+ marks where the receiver found a header replica (red) or a payload fragment
19
+ (cyan).
20
+
21
+ - **Top: as captured.** Three header replicas, then three payload fragments,
22
+ each a narrow line on its own hop frequency.
23
+ - **Bottom: buried in noise.** The same capture with white noise added down
24
+ to -22 dB SNR, so the signal is about 160 times weaker than the noise in a
25
+ 125 kHz reference bandwidth. The packet is invisible, yet the receiver
26
+ still finds it without being told where to look, locks onto it and decodes
27
+ the payload with a passing CRC. The boxes land where they did in the clean
28
+ capture.
29
+
30
+ Why it survives: each hop is only about 488 Hz wide. Filtering down to one
31
+ hop throws away 99.6% of the noise in 125 kHz, which leaves an SNR of about
32
+ +2 dB inside the hop. The header is sent three times and the copies are
33
+ combined, and the payload is protected by convolutional coding and a CRC.
34
+ For comparison, LoRa's most robust setting, SF12, is specified down to
35
+ -20 dB SNR.
36
+
37
+ -22 dB is near this packet's limit, so whether it decodes depends on the
38
+ noise draw. To try it, set `SNR_DB` and `NOISE_SEED` at the top of the
39
+ script.
40
+
41
+ ## Install
42
+
43
+ Python 3.10+:
44
+
45
+ ```bash
46
+ pip install lrfhss # or "lrfhss[plots]" for the spectrogram plots
47
+ ```
48
+
49
+ Wheels for Linux, macOS and Windows include the compiled core
50
+ (`lrfhss._viterbi_ext`), which the hot paths use automatically. To work on
51
+ the code instead, install from a clone, which needs a C++ compiler:
52
+
53
+ ```bash
54
+ git clone https://github.com/ShayanMajumder/LR-FHSS.git
55
+ cd LR-FHSS
56
+ python3 -m venv venv
57
+ source venv/bin/activate
58
+ pip install -e ".[test]" # add ",plots" for matplotlib
59
+ ```
60
+
61
+ The compiled core is an accelerator, not a requirement: if it cannot be
62
+ built, the install still succeeds and everything runs on numpy, bit-for-bit
63
+ identical, just several times slower (`LRFHSS_NO_EXT=1` skips the build on
64
+ purpose). The fallback is silent, so to check:
65
+
66
+ ```bash
67
+ python3 -c "import lrfhss; print(lrfhss.config._HAVE_VEXT_LOCAL)"
68
+ ```
69
+
70
+ Live SDR reception additionally needs SoapySDR, which is a system package
71
+ rather than a pip one -- see [`examples/arduino/`](https://github.com/ShayanMajumder/LR-FHSS/tree/main/examples/arduino) and
72
+ `examples/live_receive.py`.
73
+
74
+ ## Decode a capture
75
+
76
+ ```python
77
+ import lrfhss
78
+
79
+ lrfhss.config.retune(722_660, sync_word='12AD101B', hdr_count=4)
80
+
81
+ for packet in lrfhss.decode('capture.wav'):
82
+ if packet['crc']:
83
+ print(bytes(packet['bytes']))
84
+ ```
85
+
86
+ Two things the receiver cannot work out for itself:
87
+
88
+ - **bandwidth**, which sets the sample rate, symbol length and dwell times.
89
+ - **`hdr_count`**, the number of header replicas (1-4). It is not carried in
90
+ the header, and a wrong value puts the payload window a whole dwell out.
91
+
92
+ Grid and coding rate *are* in the header, so they are not settings. For
93
+ standards-compliant traffic, `retune_dr(8)` or `retune_dr(5, region='US915')`
94
+ sets everything at once, replica count included.
95
+
96
+ `decode()` returns every candidate it considered; `packet['crc']` is the
97
+ accept gate. The rejects come back too, because they are what you need when
98
+ a capture will not decode.
99
+
100
+ ## Generate a packet
101
+
102
+ ```python
103
+ iq, meta = lrfhss.encode(b'hello world', dr=8)
104
+ iq, meta = lrfhss.encode(b'hello world', bw_khz=722.66, CR=0)
105
+ ```
106
+
107
+ The encoder reuses the decoder's own trellis, CRC, whitening and interleaver,
108
+ so the two are compatible by construction.
109
+
110
+ ## Examples
111
+
112
+ ```bash
113
+ python3 examples/decode_recording.py # the bundled recording, no setup
114
+ python3 examples/encode.py # generate a packet and decode it back
115
+ python3 examples/live_receive.py # decode off an SDR, live
116
+ ```
117
+
118
+ Each is a flat script: edit the settings block at the top and run it.
119
+ [`examples/arduino/`](https://github.com/ShayanMajumder/LR-FHSS/tree/main/examples/arduino) has two transmitter sketches, for an
120
+ STM32 with an SX1262 or an LR1120, to give `live_receive.py` something to
121
+ hear.
122
+
123
+ ## Tests
124
+
125
+ ```bash
126
+ pytest # 99 tests, ~45 s
127
+ ```
128
+
129
+ Self-contained: nothing is skipped and nothing needs external data. CI runs
130
+ them on Linux, macOS and Windows for every push and pull request.
131
+
132
+ ## Layout
133
+
134
+ ```
135
+ lrfhss/
136
+ config every retunable parameter, retune(), DR tables
137
+ frontend decimating channeliser dsp filters, notching
138
+ detect matched filter, CFAR, sync header header decode, CFO
139
+ payload payload window and decode quality energy/length checks
140
+ acquire IQ + candidates arbitrate which are real packets
141
+ pipeline decode() -- orders the above encoder encode()
142
+ sdr LiveReceiver (optional) plotting spectrograms
143
+ phy/ hopping, fec, gmsk, framing -- protocol primitives
144
+ src/ C++ core, one file per role
145
+ examples/ runnable scripts
146
+ ```
147
+
148
+ `arbitrate` is the one to read first if you are changing behaviour: one
149
+ physical packet appears as several candidates, and deciding which are real is
150
+ where this receiver has historically gone wrong.
151
+
152
+ ## Collaboration and support
153
+
154
+ Open to collaborations :) If you would like some personal support replicating
155
+ these results, let me know: [shayan.majumder2@gmail.com](mailto:shayan.majumder2@gmail.com).
156
+
157
+ ## Acknowledgements
158
+
159
+ Thanks to the [Microwaves and Engineering group](https://microwaves.site.hw.ac.uk/)
160
+ at Heriot-Watt University for supporting this work, and to
161
+ [jumanamirza/LR-FHSS-receiver](https://github.com/jumanamirza/LR-FHSS-receiver) for making her work open source.
162
+
163
+ ## License
164
+
165
+ MIT, see [LICENSE](https://github.com/ShayanMajumder/LR-FHSS/blob/main/LICENSE).
166
+
167
+ Shayan Majumder <shayan.majumder2@gmail.com>
@@ -0,0 +1,31 @@
1
+ # Copyright (c) 2026 Shayan Majumder <shayan.majumder2@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """LR-FHSS blind receiver."""
4
+ from importlib.metadata import PackageNotFoundError, version as _version
5
+
6
+ try:
7
+ __version__ = _version('lrfhss')
8
+ except PackageNotFoundError: # a source tree that was never pip-installed
9
+ __version__ = '0.0.0+unknown'
10
+
11
+ from . import config
12
+ from .config import datarate, retune_dr
13
+ from .detect import find_packets, find_packets_windowed
14
+ from .encoder import encode, encode_packet, payload_fragments
15
+ from .frontend import load_frontend, load_frontend_windowed
16
+ from .header import decode_header_at
17
+ from .payload import decode_payload_at
18
+ from .pipeline import DecodeOptions, decode
19
+ from .plotting import plot_packet_spectrogram
20
+ from .quality import check_energy_length
21
+
22
+ __all__ = [
23
+ 'config', 'datarate', 'retune_dr',
24
+ 'decode', 'DecodeOptions',
25
+ 'encode', 'encode_packet', 'payload_fragments',
26
+ 'load_frontend', 'load_frontend_windowed',
27
+ 'find_packets', 'find_packets_windowed',
28
+ 'decode_header_at', 'decode_payload_at',
29
+ 'check_energy_length',
30
+ 'plot_packet_spectrogram',
31
+ ]
@@ -0,0 +1,84 @@
1
+ # Copyright (c) 2026 Shayan Majumder <shayan.majumder2@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """Getting IQ and sync-word candidates out of a capture."""
4
+ import numpy as np
5
+
6
+ from . import config as cfg
7
+ from . import report
8
+ from .detect import (_fine_sync, find_packets, find_packets_windowed,
9
+ find_packets_streaming_interleaved)
10
+ from .dsp import notch_spurs
11
+ from .frontend import load_frontend
12
+ from .header import _sync_carrier, decode_header_at, header_fft_peak
13
+
14
+
15
+ def detect(iq, windowed_scan=False, window_sec=0.1, hop_sec=0.05):
16
+ """Sync-word candidates from IQ already in memory."""
17
+ report.scanning(windowed_scan, window_sec, hop_sec)
18
+ if windowed_scan:
19
+ return find_packets_windowed(iq, window_sec=window_sec, hop_sec=hop_sec)
20
+ return find_packets(iq)
21
+
22
+
23
+ def load_and_detect(fn, windowed_scan=False, window_sec=0.1, hop_sec=0.05):
24
+ """Read the capture through the front end, then scan it."""
25
+ report.loading()
26
+ iq = load_frontend(fn)
27
+ report.loaded(len(iq))
28
+ iq = notch_spurs(iq, cfg.SPUR_FREQS)
29
+ return iq, detect(iq, windowed_scan, window_sec, hop_sec)
30
+
31
+
32
+ def can_stream(fn, windowed_scan):
33
+ """Whether the interleaved path applies."""
34
+ return windowed_scan and str(fn).lower().endswith('.wav')
35
+
36
+
37
+ def stream_interleaved(fn, acquire, decide, max_packets=20,
38
+ window_sec=0.1, hop_sec=0.05):
39
+ """Detect and decode window by window, overlapping the two."""
40
+ report.streaming(window_sec, hop_sec)
41
+ results = []
42
+ confirmed_footprints = []
43
+ n_sync = 0
44
+ grown = [None]
45
+ chunks = []
46
+
47
+ def on_chunk(chunk):
48
+ chunks.append(chunk)
49
+ grown[0] = np.concatenate(chunks)
50
+
51
+ def on_window(w_start, w_buf, w_hits, iq_prefix_len):
52
+ nonlocal n_sync
53
+ for _score, t_local, hf in sorted(w_hits, reverse=True):
54
+ if len(results) >= max_packets:
55
+ return
56
+ t_global = w_start + t_local
57
+ if any(lo <= t_global <= hi for lo, hi, _ in confirmed_footprints):
58
+ continue
59
+ iq = grown[0]
60
+ acq = acquire(iq, t_global, hf)
61
+ n_sync += 1
62
+ entry = decide(iq, acq, results)
63
+ if entry is not None and entry['crc'] and entry['footprint'] is not None:
64
+ confirmed_footprints.append(entry['footprint'])
65
+
66
+ find_packets_streaming_interleaved(fn, window_sec=window_sec, hop_sec=hop_sec,
67
+ on_chunk=on_chunk, on_window=on_window)
68
+ return grown[0], results, n_sync
69
+
70
+
71
+ def acquire_one(iq, t0, hf):
72
+ """Refine one candidate: fine sync, then decode its header."""
73
+ fcorr, fhf, fstart, fcfo = _fine_sync(iq, int(t0), int(hf))
74
+ win = fstart - cfg.SYNC_START_BIT*cfg.SMBL
75
+ ws = win if win >= 0 else fstart
76
+ f0 = header_fft_peak(iq[ws:ws + cfg.HDR_BIT_NUM*cfg.SMBL + 4*cfg.SMBL], fhf)
77
+ est = _sync_carrier(iq, win, f0 - 400, f0 + 400)
78
+ sync_q = est[2] if est is not None else float('inf') # can't check: don't skip
79
+ if sync_q < cfg.SYNC_SKIP_Q:
80
+ return dict(t0=t0, hf=hf, fcorr=fcorr, fhf=fhf, fstart=fstart, fcfo=fcfo,
81
+ hdr=None, hwin=win, hf_precise=fhf, sync_q=sync_q)
82
+ hdr, soft, hwin, hf_precise = decode_header_at(iq, fhf, fstart, fcfo)
83
+ return dict(t0=t0, hf=hf, fcorr=fcorr, fhf=fhf, fstart=fstart, fcfo=fcfo,
84
+ hdr=hdr, hwin=hwin, hf_precise=hf_precise, sync_q=sync_q)
@@ -0,0 +1,123 @@
1
+ # Copyright (c) 2026 Shayan Majumder <shayan.majumder2@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """Deciding which sync candidates are real packets."""
4
+ import os
5
+
6
+ from . import config as cfg
7
+ from . import report
8
+ from .payload import _packet_footprint, _packet_slots, decode_payload_at
9
+ from .plotting import plot_packet_spectrogram
10
+ from .quality import check_energy_length, packet_snr_db
11
+
12
+
13
+ def find_duplicate(results, t0):
14
+ """The already-confirmed packet this candidate is a replica of, if any."""
15
+ for prev in results:
16
+ if not prev['crc']:
17
+ continue
18
+ if abs(t0 - prev['t0']) < cfg.STAY_HDR*cfg.HDR_COUNT:
19
+ return prev
20
+ return None
21
+
22
+
23
+ class ClusterPruner:
24
+ """Tracks, per cluster, what has been submitted / decoded / retired."""
25
+
26
+ def __init__(self, n):
27
+ self.submitted = [False]*n
28
+ self.decoded = [False]*n
29
+ self.retired = [False]*n
30
+
31
+ def next_batch(self, order, start, size):
32
+ """The next `size` not-yet-submitted clusters, in `order`."""
33
+ batch, j = [], start
34
+ while j < len(order) and len(batch) < size:
35
+ ci = order[j]
36
+ if not self.submitted[ci]:
37
+ batch.append(ci)
38
+ j += 1
39
+ return batch, j
40
+
41
+ def mark_submitted(self, batch):
42
+ for ci in batch:
43
+ self.submitted[ci] = True
44
+
45
+ def retire_predicted(self, clusters, footprint, keep):
46
+ """Retire clusters the confirmed packet's hop schedule accounts for."""
47
+ t_lo, t_hi, freqs = footprint
48
+ gone = []
49
+ for cj in range(len(clusters)):
50
+ if cj == keep or self.decoded[cj] or self.retired[cj]:
51
+ continue
52
+ _, tj, fj = clusters[cj]
53
+ if t_lo <= tj <= t_hi and any(abs(fj-p) < 1500 for p in freqs):
54
+ self.submitted[cj] = True
55
+ self.retired[cj] = True
56
+ gone.append(cj)
57
+ return gone
58
+
59
+ def restore(self, clusters_idx):
60
+ """Undo a retirement: the candidate that caused it failed its
61
+ payload, so its siblings -- other locks on the same packet, often
62
+ better aligned in time -- still deserve their turn.
63
+ """
64
+ for cj in clusters_idx:
65
+ self.retired[cj] = False
66
+ self.submitted[cj] = False
67
+
68
+
69
+ def _header_matches_known_config(hdr):
70
+ return not (hdr['CR'] != cfg.KNOWN_CR or hdr['grid'] != cfg.KNOWN_GRID
71
+ or hdr['hop'] != cfg.KNOWN_HOP or hdr['BW'] != cfg.KNOWN_BW
72
+ or int(''.join(map(str, hdr['hopseq'])), 2) != cfg.KNOWN_HOPSEQ)
73
+
74
+
75
+ def evaluate(iq, acq, results, clusters=None, pruner=None, ci=None,
76
+ plot_spectrograms=False, plot_dir='.'):
77
+ """Decide one acquired candidate, decoding its payload if it survives."""
78
+ hdr = acq['hdr']
79
+ if hdr is None:
80
+ return None
81
+
82
+ dup_of = find_duplicate(results, acq['fstart'])
83
+ if dup_of is not None:
84
+ report.duplicate(acq['fstart'], acq['fhf'], dup_of['t0'])
85
+ return None
86
+
87
+ length_ok, pwr_snr = check_energy_length(iq, acq['hwin'], hdr)
88
+ if not length_ok:
89
+ report.ghost(pwr_snr)
90
+ return None
91
+ report.snr(pwr_snr)
92
+ report.candidate(acq['fstart'], acq['fhf'], acq['fcorr'], hdr)
93
+
94
+ footprint = _packet_footprint(hdr, acq['hwin'], acq['hf_precise'])
95
+ retired_now = []
96
+ if footprint is not None and pruner is not None and clusters is not None:
97
+ retired_now = pruner.retire_predicted(clusters, footprint, ci)
98
+ report.retired(len(retired_now))
99
+
100
+ payload_bytes, crc_ok = decode_payload_at(iq, hdr, acq['hwin'], acq['hf_precise'])
101
+ report.payload(crc_ok, payload_bytes)
102
+ if not crc_ok and retired_now:
103
+ pruner.restore(retired_now)
104
+
105
+ if crc_ok:
106
+ if not _header_matches_known_config(hdr):
107
+ report.config_mismatch()
108
+ if plot_spectrograms:
109
+ os.makedirs(plot_dir, exist_ok=True)
110
+ png = os.path.join(plot_dir, 'packet_t%.3fs.png' % (acq['fstart']/cfg.FS))
111
+ saved = plot_packet_spectrogram(iq, hdr, acq['hwin'], acq['hf_precise'], png)
112
+ if saved:
113
+ report.plot_written(saved)
114
+
115
+ snr_db = slots = None
116
+ if crc_ok:
117
+ slots = _packet_slots(iq, hdr, acq['hwin'], acq['hf_precise'])
118
+ snr_db = packet_snr_db(iq, slots) if slots else None
119
+ entry = dict(idx=len(results), t0=acq['fstart'], corr=acq['fcorr'],
120
+ header=hdr, bytes=payload_bytes, crc=crc_ok, footprint=footprint,
121
+ snr_db=snr_db, slots=slots)
122
+ results.append(entry)
123
+ return entry