lora-receiver 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShayanMajumder
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,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: lora-receiver
3
+ Version: 0.1.0
4
+ Summary: LoRa satellite receiver - sync, demodulate, and decode
5
+ Author: ShayanMajumder
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ShayanMajumder/lora-sat-receiver
8
+ Project-URL: Repository, https://github.com/ShayanMajumder/lora-sat-receiver
9
+ Project-URL: Bug Tracker, https://github.com/ShayanMajumder/lora-sat-receiver/issues
10
+ Keywords: lora,satellite,sdr,chirp-spread-spectrum,gnuradio
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Communications :: Ham Radio
19
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy
24
+ Requires-Dist: scipy
25
+ Dynamic: license-file
26
+
27
+ # lora-receiver
28
+
29
+ A pure-Python LoRa satellite receiver implementing the 3-stage synchronization algorithm from [Xhonneux, Dallemagne, et al. (2021)](https://arxiv.org/abs/2106.03022).
30
+
31
+ ## Features
32
+
33
+ - **Preamble detection** in complex baseband IQ recordings
34
+ - **3-stage synchronizer**: Carrier Frequency Offset (CFO) and Symbol Timing Offset (STO) estimation and correction
35
+ - **Demodulation** of LoRa symbols from corrected signals
36
+ - **Packet decoding** with both explicit headers (standard LoRa) and implicit headers (no header, forced parameters)
37
+ - **Multi-packet detection** using a sliding-window approach across a single IQ recording
38
+ - **CRC-16 verification** for payload integrity
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install lora-receiver
44
+ ```
45
+
46
+ Requires Python 3.9+.
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ import numpy as np
52
+ from lora_receiver import LoRaDecoder
53
+
54
+ # Load IQ samples (complex64)
55
+ iq = np.fromfile("recording.iq", dtype=np.complex64)
56
+
57
+ # Create decoder for SF=10, BW=125 kHz, fs=125 kHz, fc=437 MHz
58
+ decoder = LoRaDecoder(sf=10, bw=125_000, fs=125_000, fc=437e6)
59
+
60
+ # Run full decode pipeline
61
+ result = decoder.full_decode(iq)
62
+
63
+ # Inspect results
64
+ print("Payload (raw):", result["payload_bytes"])
65
+ print("Payload (text):", result.get("payload_text", ""))
66
+ print("CRC passed:", result["crc_ok"])
67
+ ```
68
+
69
+ ### Multi-packet decoding
70
+
71
+ ```python
72
+ from lora_receiver import detect_packets
73
+
74
+ results = detect_packets(iq, sf=10, bw=125_000, fs=125_000, fc=437e6)
75
+ for i, r in enumerate(results):
76
+ print(f"Packet {i}: CRC={'PASS' if r['crc_ok'] else 'FAIL'}, "
77
+ f"start={r['sample_start']}, SNR={r.get('snr_est', 'N/A')}")
78
+ ```
79
+
80
+ ## API Overview
81
+
82
+ ### `LoRaDecoder(sf, bw, fs, fc, ...)`
83
+
84
+ | Parameter | Description |
85
+ |-----------|-------------|
86
+ | `sf` | Spreading factor (7-12) |
87
+ | `bw` | Bandwidth in Hz |
88
+ | `fs` | Sampling rate in Hz |
89
+ | `fc` | Center frequency in Hz |
90
+ | `N_detect` | Preamble detection windows (default: 4) |
91
+ | `N_preamble_up` | Number of preamble upchirps (default: 8) |
92
+ | `N_netid` | Net ID upchirps (default: 0) |
93
+ | `N_sfd_down` | SFD downchirps (default: 2.25) |
94
+
95
+ ### `LoRaDecoder.full_decode(iq)` -> dict
96
+
97
+ Returns a dict with keys: `sf`, `bw`, `payload_bytes`, `payload_text`, `crc_ok`, `crc_calculated`, `header`, `sync_results`, `snr_est`, `sample_start`.
98
+
99
+ ### `LoRaDecoder.sync(iq)` -> (corrected_iq, sync_results)
100
+
101
+ Runs the 3-stage synchronizer and returns frequency-corrected, time-aligned IQ.
102
+
103
+ ### `detect_packets(iq, sf, bw, fs, fc, ...)` -> list[dict]
104
+
105
+ Sliding-window multi-packet detector. Returns a list of `full_decode` result dicts.
106
+
107
+ ## Dependencies
108
+
109
+ - `numpy` -- array operations and FFT
110
+ - `scipy` -- signal resampling
111
+
112
+ ## License
113
+
114
+ MIT -- see [LICENSE](LICENSE).
@@ -0,0 +1,88 @@
1
+ # lora-receiver
2
+
3
+ A pure-Python LoRa satellite receiver implementing the 3-stage synchronization algorithm from [Xhonneux, Dallemagne, et al. (2021)](https://arxiv.org/abs/2106.03022).
4
+
5
+ ## Features
6
+
7
+ - **Preamble detection** in complex baseband IQ recordings
8
+ - **3-stage synchronizer**: Carrier Frequency Offset (CFO) and Symbol Timing Offset (STO) estimation and correction
9
+ - **Demodulation** of LoRa symbols from corrected signals
10
+ - **Packet decoding** with both explicit headers (standard LoRa) and implicit headers (no header, forced parameters)
11
+ - **Multi-packet detection** using a sliding-window approach across a single IQ recording
12
+ - **CRC-16 verification** for payload integrity
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install lora-receiver
18
+ ```
19
+
20
+ Requires Python 3.9+.
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ import numpy as np
26
+ from lora_receiver import LoRaDecoder
27
+
28
+ # Load IQ samples (complex64)
29
+ iq = np.fromfile("recording.iq", dtype=np.complex64)
30
+
31
+ # Create decoder for SF=10, BW=125 kHz, fs=125 kHz, fc=437 MHz
32
+ decoder = LoRaDecoder(sf=10, bw=125_000, fs=125_000, fc=437e6)
33
+
34
+ # Run full decode pipeline
35
+ result = decoder.full_decode(iq)
36
+
37
+ # Inspect results
38
+ print("Payload (raw):", result["payload_bytes"])
39
+ print("Payload (text):", result.get("payload_text", ""))
40
+ print("CRC passed:", result["crc_ok"])
41
+ ```
42
+
43
+ ### Multi-packet decoding
44
+
45
+ ```python
46
+ from lora_receiver import detect_packets
47
+
48
+ results = detect_packets(iq, sf=10, bw=125_000, fs=125_000, fc=437e6)
49
+ for i, r in enumerate(results):
50
+ print(f"Packet {i}: CRC={'PASS' if r['crc_ok'] else 'FAIL'}, "
51
+ f"start={r['sample_start']}, SNR={r.get('snr_est', 'N/A')}")
52
+ ```
53
+
54
+ ## API Overview
55
+
56
+ ### `LoRaDecoder(sf, bw, fs, fc, ...)`
57
+
58
+ | Parameter | Description |
59
+ |-----------|-------------|
60
+ | `sf` | Spreading factor (7-12) |
61
+ | `bw` | Bandwidth in Hz |
62
+ | `fs` | Sampling rate in Hz |
63
+ | `fc` | Center frequency in Hz |
64
+ | `N_detect` | Preamble detection windows (default: 4) |
65
+ | `N_preamble_up` | Number of preamble upchirps (default: 8) |
66
+ | `N_netid` | Net ID upchirps (default: 0) |
67
+ | `N_sfd_down` | SFD downchirps (default: 2.25) |
68
+
69
+ ### `LoRaDecoder.full_decode(iq)` -> dict
70
+
71
+ Returns a dict with keys: `sf`, `bw`, `payload_bytes`, `payload_text`, `crc_ok`, `crc_calculated`, `header`, `sync_results`, `snr_est`, `sample_start`.
72
+
73
+ ### `LoRaDecoder.sync(iq)` -> (corrected_iq, sync_results)
74
+
75
+ Runs the 3-stage synchronizer and returns frequency-corrected, time-aligned IQ.
76
+
77
+ ### `detect_packets(iq, sf, bw, fs, fc, ...)` -> list[dict]
78
+
79
+ Sliding-window multi-packet detector. Returns a list of `full_decode` result dicts.
80
+
81
+ ## Dependencies
82
+
83
+ - `numpy` -- array operations and FFT
84
+ - `scipy` -- signal resampling
85
+
86
+ ## License
87
+
88
+ MIT -- see [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ from lora_receiver.decoder import LoRaDecoder
2
+ from lora_receiver.multi_packet import detect_packets
3
+
4
+ __all__ = ['LoRaDecoder', 'detect_packets']
@@ -0,0 +1,8 @@
1
+ import numpy as np
2
+
3
+
4
+ def generate_chirps(N):
5
+ n = np.arange(N, dtype=float)
6
+ upchirp = np.exp(1j * 2 * np.pi * (n**2 / (2 * N) - n / 2))
7
+ downchirp = np.exp(-1j * 2 * np.pi * (n**2 / (2 * N) - n / 2))
8
+ return upchirp, downchirp
@@ -0,0 +1,248 @@
1
+ import warnings
2
+ import numpy as np
3
+
4
+
5
+ # ===== GRAY CODING (RX) =====
6
+
7
+ def gray_coding_rx(symbols, sf, is_header=False, ldro=False):
8
+ symbols = np.array(symbols, dtype=float)
9
+ symbols = symbols % (2**sf)
10
+ if is_header:
11
+ symbols = np.floor(symbols / 4)
12
+ elif ldro:
13
+ symbols = np.floor(symbols / 4)
14
+ else:
15
+ symbols = (symbols - 1) % (2**sf)
16
+ s = symbols.astype(np.uint16)
17
+ return np.array([int(x ^ (x >> 1)) for x in s], dtype=np.uint16)
18
+
19
+
20
+ # ===== DEINTERLEAVE =====
21
+
22
+ def deinterleave(symbols, sf, cr, is_header, ldro=False):
23
+ sf_app = (sf - 2) if (is_header or ldro) else sf
24
+ cw_len = 8 if is_header else (cr + 4)
25
+ symbols = np.asarray(symbols, dtype=np.uint16)[:cw_len]
26
+ bits = np.zeros((cw_len, sf_app), dtype=np.uint8)
27
+ for i in range(sf_app):
28
+ bits[:, i] = (symbols >> (sf_app - 1 - i)) & 1
29
+ deinter = np.zeros((sf_app, cw_len), dtype=np.uint8)
30
+ for i in range(cw_len):
31
+ for j in range(sf_app):
32
+ deinter[(i - j - 1) % sf_app, i] = bits[i, j]
33
+ out = np.zeros(sf_app, dtype=np.uint8)
34
+ for i in range(sf_app):
35
+ val = 0
36
+ for b in range(cw_len):
37
+ val = (val << 1) | int(deinter[i, b])
38
+ out[i] = np.uint8(val)
39
+ return out
40
+
41
+
42
+ # ===== HAMMING DECODE =====
43
+
44
+ def _int2bits(value, n_bits):
45
+ return [bool((value >> (n_bits - 1 - i)) & 1) for i in range(n_bits)]
46
+
47
+
48
+ def _bits2int(bits):
49
+ r = 0
50
+ for b in bits:
51
+ r = (r << 1) | int(b)
52
+ return r
53
+
54
+
55
+ def _apply_syndrome(data_nibble, syndrome):
56
+ correction = {5: 3, 7: 2, 3: 1, 6: 0}
57
+ if syndrome in correction:
58
+ idx = correction[syndrome]
59
+ data_nibble[idx] = not data_nibble[idx]
60
+
61
+
62
+ def hamming_decode(codewords, cr, is_header):
63
+ cr_app = 4 if is_header else cr
64
+ cw_len = cr_app + 4
65
+ codewords = np.asarray(codewords, dtype=np.uint8)
66
+ n = len(codewords)
67
+ out = np.zeros(n, dtype=np.uint8)
68
+ for i in range(n):
69
+ cw = _int2bits(int(codewords[i]), cw_len)
70
+ data_nibble = [cw[3], cw[2], cw[1], cw[0]]
71
+ if cr_app == 4:
72
+ if not (sum(cw) % 2):
73
+ out[i] = _bits2int(data_nibble)
74
+ continue
75
+ s0 = cw[0] ^ cw[1] ^ cw[2] ^ cw[4]
76
+ s1 = cw[1] ^ cw[2] ^ cw[3] ^ cw[5]
77
+ s2 = cw[0] ^ cw[1] ^ cw[3] ^ cw[6]
78
+ _apply_syndrome(data_nibble, int(s0) | (int(s1) << 1) | (int(s2) << 2))
79
+ elif cr_app == 3:
80
+ s0 = cw[0] ^ cw[1] ^ cw[2] ^ cw[4]
81
+ s1 = cw[1] ^ cw[2] ^ cw[3] ^ cw[5]
82
+ s2 = cw[0] ^ cw[1] ^ cw[3] ^ cw[6]
83
+ _apply_syndrome(data_nibble, int(s0) | (int(s1) << 1) | (int(s2) << 2))
84
+ out[i] = _bits2int(data_nibble)
85
+ return out
86
+
87
+
88
+ # ===== DEWHITEN =====
89
+
90
+ _WHITEN_LEN = 510
91
+ _WHITEN_SEQ = [
92
+ 0x0102291EA751AAFF, 0xD24B050A8D643A17, 0x5B279B671120B8F4,
93
+ 0x032B37B9F6FB55A2, 0x994E0F87E95E2D16, 0x7CBCFC7631984C26,
94
+ 0x281C8E4F0DAEF7F9, 0x1741886EB7733B15,
95
+ ]
96
+ _OFS = [6, 4, 2, 0]
97
+
98
+
99
+ def _whiten_bit(pos):
100
+ pos = pos % _WHITEN_LEN
101
+ return (_WHITEN_SEQ[pos >> 6] >> (pos & 63)) & 1
102
+
103
+
104
+ def _nibble_whiten(k):
105
+ val = 0
106
+ for b, ofs in enumerate(_OFS):
107
+ val |= _whiten_bit(ofs + k) << b
108
+ return val
109
+
110
+
111
+ _WHITENING_BYTES = [(_nibble_whiten(2*i+1) << 4) | _nibble_whiten(2*i) for i in range(255)]
112
+
113
+
114
+ def dewhiten(nibbles, payload_len, crc_presence=False):
115
+ nibbles = np.asarray(nibbles, dtype=np.uint8)
116
+ needed = 2 * (payload_len + (2 if crc_presence else 0))
117
+ if len(nibbles) < needed:
118
+ raise ValueError(
119
+ f'dewhiten: need {needed} nibbles for payload_len={payload_len}, '
120
+ f'crc={crc_presence}, but only have {len(nibbles)}'
121
+ )
122
+ payload = np.zeros(payload_len, dtype=np.uint8)
123
+ for i in range(payload_len):
124
+ ws = _WHITENING_BYTES[i]
125
+ low = (nibbles[2*i] ^ (ws & 0x0F)) & 0x0F
126
+ high = (nibbles[2*i+1] ^ (ws >> 4)) & 0x0F
127
+ payload[i] = (high << 4) | low
128
+ crc_bytes = np.zeros(0, dtype=np.uint8)
129
+ if crc_presence:
130
+ for j in range(2):
131
+ i = payload_len + j
132
+ low = nibbles[2*i] & 0x0F
133
+ high = nibbles[2*i+1] & 0x0F
134
+ crc_bytes = np.append(crc_bytes, np.uint8((high << 4) | low))
135
+ return payload, crc_bytes
136
+
137
+
138
+ def nibbles_to_bytes(nibbles):
139
+ num_bytes = min(255, len(nibbles) // 2)
140
+ out = np.zeros(num_bytes, dtype=np.uint8)
141
+ for i in range(num_bytes):
142
+ out[i] = nibbles[2*i] | (nibbles[2*i+1] << 4)
143
+ return out
144
+
145
+
146
+ # ===== HEADER CHECKSUM MATRIX =====
147
+
148
+ _HCSM = np.array([
149
+ [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
150
+ [1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1],
151
+ [0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0],
152
+ [0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1],
153
+ [0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1],
154
+ ], dtype=np.uint8)
155
+
156
+
157
+ def decode_symbols(symbols, sf, cr, is_header, ldro=False):
158
+ grayed = gray_coding_rx(symbols, sf, is_header=is_header, ldro=ldro)
159
+ codewords = deinterleave(grayed, sf, cr, is_header, ldro)
160
+ nibbles = hamming_decode(codewords, cr, is_header)
161
+ return nibbles
162
+
163
+
164
+ # ===== PAYLOAD SYMBOL CALC =====
165
+
166
+ def calc_payload_sym_num(payload_len, has_crc, sf, cr, ldro=False, impl_header=False):
167
+ total_bytes = payload_len + (2 if has_crc else 0)
168
+ total_nibbles = total_bytes * 2
169
+ header_data_nibbles = (sf - 2) if impl_header else 3
170
+ sf_eff = (sf - 2) if ldro else sf
171
+ rdd = cr + 4
172
+ remaining = max(0, total_nibbles - header_data_nibbles)
173
+ blocks = (remaining + sf_eff - 1) // sf_eff
174
+ return 8 + blocks * rdd
175
+
176
+
177
+ # ===== HEADER DECODE =====
178
+
179
+ def decode_header(hdr_syms, sf):
180
+ hdr_nib = decode_symbols(hdr_syms, sf, cr=1, is_header=True)
181
+ if len(hdr_nib) < 5:
182
+ raise ValueError('Header decode failed: insufficient nibbles')
183
+ payload_len = int(hdr_nib[0] * 16 + hdr_nib[1])
184
+ has_crc = bool(hdr_nib[2] & 1)
185
+ cr = int(hdr_nib[2] >> 1)
186
+ if payload_len > 255 or payload_len < 1 or cr < 1 or cr > 4:
187
+ raise ValueError(
188
+ f'Invalid header: payload_len={payload_len}, cr={cr}'
189
+ )
190
+ hc = np.zeros(5, dtype=np.uint8)
191
+ hc[0] = hdr_nib[3] & 1
192
+ for i in range(4):
193
+ hc[i+1] = (hdr_nib[4] >> (3-i)) & 1
194
+ nb = np.zeros(12, dtype=np.uint8)
195
+ for i in range(3):
196
+ for j in range(4):
197
+ nb[i*4+j] = (hdr_nib[i] >> (3-j)) & 1
198
+ if not np.array_equal(hc, np.dot(_HCSM, nb) % 2):
199
+ warnings.warn(
200
+ f'Invalid header checksum '
201
+ f'(decoded hc={list(hc)}, '
202
+ f'expected={list(np.dot(_HCSM, nb) % 2)})'
203
+ )
204
+ return payload_len, has_crc, cr, hdr_nib
205
+
206
+
207
+ # ===== FULL DECODE =====
208
+
209
+ def decode(data_symbols, sf=10, impl_header=False,
210
+ forced_payload_len=None, forced_has_crc=True, forced_cr=1):
211
+ if impl_header:
212
+ if forced_payload_len is None or forced_payload_len < 1 or forced_payload_len > 255:
213
+ raise ValueError(f'Invalid forced_payload_len={forced_payload_len}')
214
+ if forced_cr < 1 or forced_cr > 4:
215
+ raise ValueError(f'Invalid forced_cr={forced_cr}')
216
+ first_nibbles = decode_symbols(data_symbols[:8], sf, cr=1, is_header=True)
217
+ data_nibbles = list(first_nibbles)
218
+ payload_len = forced_payload_len
219
+ has_crc = forced_has_crc
220
+ cr = forced_cr
221
+ else:
222
+ payload_len, has_crc, cr, hdr_nib = decode_header(data_symbols[:8], sf)
223
+ data_nibbles = list(hdr_nib[5:])
224
+
225
+ rdd = cr + 4
226
+ for i in range(0, len(data_symbols[8:]) - rdd + 1, rdd):
227
+ block = data_symbols[8+i:8+i+rdd]
228
+ nibs = decode_symbols(block, sf, cr, is_header=False)
229
+ data_nibbles.extend(nibs)
230
+ return dewhiten(np.array(data_nibbles, dtype=np.uint8), payload_len, has_crc) + ({'payload_len': payload_len, 'has_crc': has_crc, 'cr': cr},)
231
+
232
+
233
+ # ===== CRC-16 =====
234
+
235
+ def calc_lora_crc16(payload):
236
+ if len(payload) < 2:
237
+ return np.array([0, 0], dtype=np.uint8)
238
+ crc = 0x0000
239
+ for byte in payload[:-2]:
240
+ b = int(byte)
241
+ for _ in range(8):
242
+ if ((crc & 0x8000) >> 8) ^ (b & 0x80):
243
+ crc = ((crc << 1) ^ 0x1021) & 0xFFFF
244
+ else:
245
+ crc = (crc << 1) & 0xFFFF
246
+ b <<= 1
247
+ crc = crc ^ int(payload[-1]) ^ (int(payload[-2]) << 8)
248
+ return np.array([crc & 0xFF, (crc >> 8) & 0xFF], dtype=np.uint8)