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.
- lrfhss-1.0.0/CHANGELOG.md +23 -0
- lrfhss-1.0.0/LICENSE +21 -0
- lrfhss-1.0.0/MANIFEST.in +11 -0
- lrfhss-1.0.0/PKG-INFO +203 -0
- lrfhss-1.0.0/README.md +167 -0
- lrfhss-1.0.0/examples/data/capture_39kHz_hdr3.wav +0 -0
- lrfhss-1.0.0/lrfhss/__init__.py +31 -0
- lrfhss-1.0.0/lrfhss/acquire.py +84 -0
- lrfhss-1.0.0/lrfhss/arbitrate.py +123 -0
- lrfhss-1.0.0/lrfhss/config.py +201 -0
- lrfhss-1.0.0/lrfhss/detect.py +451 -0
- lrfhss-1.0.0/lrfhss/dsp.py +150 -0
- lrfhss-1.0.0/lrfhss/encoder.py +256 -0
- lrfhss-1.0.0/lrfhss/fft.py +12 -0
- lrfhss-1.0.0/lrfhss/frontend.py +131 -0
- lrfhss-1.0.0/lrfhss/header.py +358 -0
- lrfhss-1.0.0/lrfhss/payload.py +313 -0
- lrfhss-1.0.0/lrfhss/phy/__init__.py +3 -0
- lrfhss-1.0.0/lrfhss/phy/fec.py +209 -0
- lrfhss-1.0.0/lrfhss/phy/framing.py +181 -0
- lrfhss-1.0.0/lrfhss/phy/gmsk.py +197 -0
- lrfhss-1.0.0/lrfhss/phy/hopping.py +132 -0
- lrfhss-1.0.0/lrfhss/pipeline.py +107 -0
- lrfhss-1.0.0/lrfhss/plotting.py +53 -0
- lrfhss-1.0.0/lrfhss/quality.py +47 -0
- lrfhss-1.0.0/lrfhss/report.py +99 -0
- lrfhss-1.0.0/lrfhss/sdr.py +309 -0
- lrfhss-1.0.0/lrfhss/wavio.py +103 -0
- lrfhss-1.0.0/lrfhss.egg-info/PKG-INFO +203 -0
- lrfhss-1.0.0/lrfhss.egg-info/SOURCES.txt +57 -0
- lrfhss-1.0.0/lrfhss.egg-info/dependency_links.txt +1 -0
- lrfhss-1.0.0/lrfhss.egg-info/requires.txt +8 -0
- lrfhss-1.0.0/lrfhss.egg-info/top_level.txt +1 -0
- lrfhss-1.0.0/pyproject.toml +75 -0
- lrfhss-1.0.0/setup.cfg +4 -0
- lrfhss-1.0.0/setup.py +59 -0
- lrfhss-1.0.0/src/common.hpp +17 -0
- lrfhss-1.0.0/src/crc.cpp +55 -0
- lrfhss-1.0.0/src/crc.hpp +12 -0
- lrfhss-1.0.0/src/crc16_lut.inc +20 -0
- lrfhss-1.0.0/src/crc8_lut.inc +21 -0
- lrfhss-1.0.0/src/deinterleave.cpp +64 -0
- lrfhss-1.0.0/src/deinterleave.hpp +12 -0
- lrfhss-1.0.0/src/demod.cpp +199 -0
- lrfhss-1.0.0/src/demod.hpp +15 -0
- lrfhss-1.0.0/src/filters.cpp +160 -0
- lrfhss-1.0.0/src/filters.hpp +17 -0
- lrfhss-1.0.0/src/module.cpp +35 -0
- lrfhss-1.0.0/src/viterbi.cpp +223 -0
- lrfhss-1.0.0/src/viterbi.hpp +29 -0
- lrfhss-1.0.0/src/viterbi_dp.cpp +96 -0
- lrfhss-1.0.0/src/viterbi_dp.hpp +31 -0
- lrfhss-1.0.0/tests/conftest.py +27 -0
- lrfhss-1.0.0/tests/test_build.py +19 -0
- lrfhss-1.0.0/tests/test_capture.py +100 -0
- lrfhss-1.0.0/tests/test_config.py +98 -0
- lrfhss-1.0.0/tests/test_datarate.py +122 -0
- lrfhss-1.0.0/tests/test_phy.py +101 -0
- 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.
|
lrfhss-1.0.0/MANIFEST.in
ADDED
|
@@ -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
|
+
[](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/lrfhss/)
|
|
41
|
+
[](https://pypi.org/project/lrfhss/)
|
|
42
|
+
[](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
|
+

|
|
49
|
+
|
|
50
|
+

|
|
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
|
+
[](https://github.com/ShayanMajumder/LR-FHSS/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/lrfhss/)
|
|
5
|
+
[](https://pypi.org/project/lrfhss/)
|
|
6
|
+
[](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
|
+

|
|
13
|
+
|
|
14
|
+

|
|
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>
|
|
Binary file
|
|
@@ -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
|