feat: add LoRa SDR receiver with Docker runtime infrastructure

LoRa receiver flowgraph built programmatically via gr-mcp:
- osmosdr_source → low_pass_filter → lora_rx → message_debug
- XML-RPC server for runtime variable control (samp_rate,
  center_freq) with introspection enabled
- Qt frequency sink for spectrum visualization

Docker infrastructure:
- gnuradio-lora: gr-lora_sdr OOT module from EPFL (chirp spread spectrum)
- gnuradio-lora-runtime: combined runtime with Xvfb + gr-lora_sdr
- Compose file, entrypoint, and launch script for LoRa receiver

Also includes:
- lora_scanner.py: multi-SF LoRa scanner example
- lora_infrastructure_test.py: hardware-free pipeline validation
  (signal_source → throttle → null_sink + xmlrpc variable control)
- Integration tests for LoRa scanner flowgraph construction

End-to-end pipeline validated: launch_flowgraph → connect_to_container →
list_variables → get/set_variable all working through Docker + XML-RPC.
This commit is contained in:
Ryan Malloy 2026-01-30 13:55:40 -07:00
parent f3efb36435
commit e8b3600e60
10 changed files with 2113 additions and 0 deletions

View File

@ -0,0 +1,32 @@
FROM librespace/gnuradio:latest
# Build dependencies for gr-lora_sdr (EPFL LoRa SDR OOT module)
# gr-lora_sdr has no apt/pip package — must compile from source
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git \
libvolk2-dev libboost-all-dev pybind11-dev \
&& rm -rf /var/lib/apt/lists/*
# Compile gr-lora_sdr (EPFL, chirp spread spectrum decoder)
# https://github.com/tapparelj/gr-lora_sdr
# Pinned to master@862746d (2024-01-xx) — no tagged releases exist
WORKDIR /build
RUN git clone --depth 1 --branch master \
https://github.com/tapparelj/gr-lora_sdr.git && \
cd gr-lora_sdr && mkdir build && cd build && \
cmake -DCMAKE_INSTALL_PREFIX=/usr .. && \
make -j$(nproc) && make install && \
ldconfig && \
rm -rf /build
WORKDIR /flowgraphs
# cmake installs Python bindings to versioned site-packages (3.11) but
# the base image's python3 searches unversioned dist-packages — bridge the gap
ENV PYTHONPATH="/usr/lib/python3.11/site-packages:${PYTHONPATH}"
# XML-RPC port for runtime LoRa parameter control
ENV XMLRPC_PORT=8091
EXPOSE 8091
ENTRYPOINT ["python3"]

View File

@ -0,0 +1,22 @@
FROM gnuradio-runtime:latest
# Build gr-lora_sdr (EPFL chirp spread spectrum OOT module)
# https://github.com/tapparelj/gr-lora_sdr
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git \
libvolk2-dev libboost-all-dev pybind11-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
RUN git clone --depth 1 --branch master \
https://github.com/tapparelj/gr-lora_sdr.git && \
cd gr-lora_sdr && mkdir build && cd build && \
cmake -DCMAKE_INSTALL_PREFIX=/usr .. && \
make -j$(nproc) && make install && \
ldconfig && \
rm -rf /build
WORKDIR /flowgraphs
# Bridge Python site-packages path (cmake installs to versioned path)
ENV PYTHONPATH="/usr/lib/python3.11/site-packages:${PYTHONPATH}"

View File

@ -0,0 +1,32 @@
services:
lora-receiver:
build:
context: .
dockerfile: Dockerfile.gnuradio-lora
# Root required for USB device access (RTL-SDR)
user: root
privileged: true
volumes:
# Flowgraph source files
- ../examples:/flowgraphs:ro
- ../src:/src:ro
# Entrypoint script
- ./entrypoint-lora.sh:/entrypoint-lora.sh:ro
environment:
- FREQ_MHZ=${FREQ_MHZ:-915.0}
- SF=${SF:-7}
- BW=${BW:-125000}
- CR=${CR:-1}
- GAIN=${GAIN:-20}
# XML-RPC port for LoRa parameter control from host
ports:
- "8091:8091"
entrypoint: ["/bin/bash", "/entrypoint-lora.sh"]
stdin_open: true
tty: true

28
docker/entrypoint-lora.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
# Entrypoint for containerized LoRa receiver
set -e
FREQ_MHZ=${FREQ_MHZ:-915.0}
SF=${SF:-7}
BW=${BW:-125000}
CR=${CR:-1}
GAIN=${GAIN:-20}
python3 -c "
import sys, subprocess, os
sys.path.insert(0, '/flowgraphs')
sys.path.insert(0, '/src')
from lora_scanner import build_lora_receiver
freq = float(os.environ.get('FREQ_MHZ', '915.0'))
sf = int(os.environ.get('SF', '7'))
bw = int(os.environ.get('BW', '125000'))
cr = int(os.environ.get('CR', '1'))
gain = int(os.environ.get('GAIN', '20'))
print(f'Building LoRa receiver for {freq} MHz (SF{sf}, BW {bw} Hz, CR 4/{4+cr})...')
py_path = build_lora_receiver(freq, sf=sf, bw=bw, cr=cr, gain=gain)
print(f'Launching {py_path.name} — Ctrl+C to stop')
print(f'XML-RPC control at http://localhost:8091')
subprocess.run([sys.executable, str(py_path)])
"

29
docker/run-lora-receiver.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Run containerized LoRa receiver
#
# Usage: ./run-lora-receiver.sh [FREQ_MHZ] [SF] [BW] [CR] [GAIN]
# FREQ_MHZ: Center frequency in MHz (default: 915.0)
# SF: Spreading factor 7-12 (default: 7)
# BW: Bandwidth in Hz (default: 125000)
# CR: Coding rate 1-4 (default: 1)
# GAIN: RF gain in dB (default: 20)
#
# Once running, use XML-RPC from host to change parameters:
# python -c "import xmlrpc.client; p=xmlrpc.client.ServerProxy('http://localhost:8091'); p.set_sf(10)"
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export FREQ_MHZ=${1:-915.0}
export SF=${2:-7}
export BW=${3:-125000}
export CR=${4:-1}
export GAIN=${5:-20}
echo "Starting LoRa receiver at $FREQ_MHZ MHz (SF$SF, BW $BW Hz, CR 4/$((4+CR)), gain $GAIN dB)"
echo "XML-RPC control available at http://localhost:8091"
echo "Press Ctrl+C to stop"
echo
docker compose -f "$SCRIPT_DIR/docker-compose.lora-receiver.yml" up --build

View File

@ -0,0 +1,117 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# SPDX-License-Identifier: GPL-3.0
#
# GNU Radio Python Flow Graph
# Title: LoRa Infrastructure Test
# Description: Validates the gr-mcp runtime pipeline (Docker + XML-RPC)
# without requiring SDR hardware.
# GNU Radio version: 3.10.12.0
from gnuradio import analog, blocks, gr
from xmlrpc.server import SimpleXMLRPCServer
import signal
import sys
import threading
import time
class lora_infra_test(gr.top_block):
"""Minimal flowgraph for testing gr-mcp runtime infrastructure.
Signal chain: sig_source throttle null_sink
Variables exposed via XML-RPC: samp_rate, center_freq, lora_sf, lora_bw
"""
def __init__(self):
gr.top_block.__init__(self, "LoRa Infrastructure Test", catch_exceptions=True)
##################################################
# Variables (same as LoRa receiver for API compatibility)
##################################################
self.samp_rate = samp_rate = 1000000
self.center_freq = center_freq = 915e6
self.lora_sf = lora_sf = 7
self.lora_bw = lora_bw = 125000
##################################################
# Blocks
##################################################
self.analog_sig_source_0 = analog.sig_source_c(
samp_rate, analog.GR_COS_WAVE, 1000, 1, 0, 0
)
self.blocks_throttle_0 = blocks.throttle(gr.sizeof_gr_complex, samp_rate, True)
self.blocks_null_sink_0 = blocks.null_sink(gr.sizeof_gr_complex)
# XML-RPC server for runtime variable control
self.xmlrpc_server_0 = SimpleXMLRPCServer(("0.0.0.0", 8080), allow_none=True)
self.xmlrpc_server_0.register_introspection_functions()
self.xmlrpc_server_0.register_instance(self)
self.xmlrpc_server_0_thread = threading.Thread(target=self.xmlrpc_server_0.serve_forever)
self.xmlrpc_server_0_thread.daemon = True
self.xmlrpc_server_0_thread.start()
##################################################
# Connections
##################################################
self.connect((self.analog_sig_source_0, 0), (self.blocks_throttle_0, 0))
self.connect((self.blocks_throttle_0, 0), (self.blocks_null_sink_0, 0))
def get_samp_rate(self):
return self.samp_rate
def set_samp_rate(self, samp_rate):
self.samp_rate = samp_rate
self.analog_sig_source_0.set_sampling_freq(self.samp_rate)
self.blocks_throttle_0.set_sample_rate(self.samp_rate)
def get_center_freq(self):
return self.center_freq
def set_center_freq(self, center_freq):
self.center_freq = center_freq
def get_lora_sf(self):
return self.lora_sf
def set_lora_sf(self, lora_sf):
self.lora_sf = lora_sf
def get_lora_bw(self):
return self.lora_bw
def set_lora_bw(self, lora_bw):
self.lora_bw = lora_bw
def main(top_block_cls=lora_infra_test, options=None):
tb = top_block_cls()
tb.start()
print("Flowgraph started, XML-RPC on 0.0.0.0:8080", flush=True)
print(f"Variables: samp_rate={tb.samp_rate}, center_freq={tb.center_freq}, "
f"lora_sf={tb.lora_sf}, lora_bw={tb.lora_bw}", flush=True)
def sig_handler(sig=None, frame=None):
tb.stop()
tb.wait()
sys.exit(0)
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
# Keep alive
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
tb.stop()
tb.wait()
if __name__ == "__main__":
main()

574
examples/lora_receiver.grc Normal file
View File

@ -0,0 +1,574 @@
options:
parameters:
author: gr-mcp
catch_exceptions: 'True'
category: '[GRC Hier Blocks]'
cmake_opt: ''
comment: ''
copyright: ''
description: ''
gen_cmake: 'On'
gen_linking: dynamic
generate_options: qt_gui
hier_block_src_path: '.:'
id: default
max_nouts: '0'
output_language: python
placement: (0,0)
qt_qss_theme: ''
realtime_scheduling: ''
run: 'True'
run_command: '{python} -u {filename}'
run_options: prompt
sizing_mode: fixed
thread_safe_setters: ''
title: LoRa SDR Receiver
window_size: (1000,1000)
states:
bus_sink: false
bus_source: false
bus_structure: null
coordinate: [8, 8]
rotation: 0
state: enabled
blocks:
- name: xmlrpc_server_0
id: xmlrpc_server
parameters:
addr: 0.0.0.0
alias: ''
comment: ''
port: '8080'
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: center_freq
id: variable
parameters:
comment: ''
value: 915e6
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: samp_rate
id: variable
parameters:
comment: ''
value: '1000000'
states:
bus_sink: false
bus_source: false
bus_structure: null
coordinate:
- 200
- 12
rotation: 0
state: enabled
- name: blocks_message_debug_0
id: blocks_message_debug
parameters:
affinity: ''
alias: ''
comment: ''
en_uvec: 'True'
log_level: info
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: lora_rx_0
id: lora_rx
parameters:
affinity: ''
alias: ''
bw: '125000'
comment: ''
cr: '1'
has_crc: 'True'
impl_head: 'False'
ldro: '2'
maxoutbuf: '0'
minoutbuf: '0'
pay_len: '255'
print_rx: '[True,True]'
samp_rate: int(samp_rate/2)
sf: '7'
soft_decoding: 'True'
sync_word: '[0x12]'
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: low_pass_filter_0
id: low_pass_filter
parameters:
affinity: ''
alias: ''
beta: '6.76'
comment: ''
cutoff_freq: 200e3
decim: '2'
gain: '1'
interp: '1'
maxoutbuf: '0'
minoutbuf: '0'
samp_rate: samp_rate
type: fir_filter_ccf
width: 50e3
win: window.WIN_HAMMING
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: osmosdr_source_0
id: osmosdr_source
parameters:
affinity: ''
alias: ''
ant0: ''
ant1: ''
ant10: ''
ant11: ''
ant12: ''
ant13: ''
ant14: ''
ant15: ''
ant16: ''
ant17: ''
ant18: ''
ant19: ''
ant2: ''
ant20: ''
ant21: ''
ant22: ''
ant23: ''
ant24: ''
ant25: ''
ant26: ''
ant27: ''
ant28: ''
ant29: ''
ant3: ''
ant30: ''
ant31: ''
ant4: ''
ant5: ''
ant6: ''
ant7: ''
ant8: ''
ant9: ''
args: '""'
bb_gain0: '20'
bb_gain1: '20'
bb_gain10: '20'
bb_gain11: '20'
bb_gain12: '20'
bb_gain13: '20'
bb_gain14: '20'
bb_gain15: '20'
bb_gain16: '20'
bb_gain17: '20'
bb_gain18: '20'
bb_gain19: '20'
bb_gain2: '20'
bb_gain20: '20'
bb_gain21: '20'
bb_gain22: '20'
bb_gain23: '20'
bb_gain24: '20'
bb_gain25: '20'
bb_gain26: '20'
bb_gain27: '20'
bb_gain28: '20'
bb_gain29: '20'
bb_gain3: '20'
bb_gain30: '20'
bb_gain31: '20'
bb_gain4: '20'
bb_gain5: '20'
bb_gain6: '20'
bb_gain7: '20'
bb_gain8: '20'
bb_gain9: '20'
bw0: '0'
bw1: '0'
bw10: '0'
bw11: '0'
bw12: '0'
bw13: '0'
bw14: '0'
bw15: '0'
bw16: '0'
bw17: '0'
bw18: '0'
bw19: '0'
bw2: '0'
bw20: '0'
bw21: '0'
bw22: '0'
bw23: '0'
bw24: '0'
bw25: '0'
bw26: '0'
bw27: '0'
bw28: '0'
bw29: '0'
bw3: '0'
bw30: '0'
bw31: '0'
bw4: '0'
bw5: '0'
bw6: '0'
bw7: '0'
bw8: '0'
bw9: '0'
clock_source0: ''
clock_source1: ''
clock_source2: ''
clock_source3: ''
clock_source4: ''
clock_source5: ''
clock_source6: ''
clock_source7: ''
comment: ''
corr0: '0'
corr1: '0'
corr10: '0'
corr11: '0'
corr12: '0'
corr13: '0'
corr14: '0'
corr15: '0'
corr16: '0'
corr17: '0'
corr18: '0'
corr19: '0'
corr2: '0'
corr20: '0'
corr21: '0'
corr22: '0'
corr23: '0'
corr24: '0'
corr25: '0'
corr26: '0'
corr27: '0'
corr28: '0'
corr29: '0'
corr3: '0'
corr30: '0'
corr31: '0'
corr4: '0'
corr5: '0'
corr6: '0'
corr7: '0'
corr8: '0'
corr9: '0'
dc_offset_mode0: '0'
dc_offset_mode1: '0'
dc_offset_mode10: '0'
dc_offset_mode11: '0'
dc_offset_mode12: '0'
dc_offset_mode13: '0'
dc_offset_mode14: '0'
dc_offset_mode15: '0'
dc_offset_mode16: '0'
dc_offset_mode17: '0'
dc_offset_mode18: '0'
dc_offset_mode19: '0'
dc_offset_mode2: '0'
dc_offset_mode20: '0'
dc_offset_mode21: '0'
dc_offset_mode22: '0'
dc_offset_mode23: '0'
dc_offset_mode24: '0'
dc_offset_mode25: '0'
dc_offset_mode26: '0'
dc_offset_mode27: '0'
dc_offset_mode28: '0'
dc_offset_mode29: '0'
dc_offset_mode3: '0'
dc_offset_mode30: '0'
dc_offset_mode31: '0'
dc_offset_mode4: '0'
dc_offset_mode5: '0'
dc_offset_mode6: '0'
dc_offset_mode7: '0'
dc_offset_mode8: '0'
dc_offset_mode9: '0'
freq0: center_freq
freq1: 100e6
freq10: 100e6
freq11: 100e6
freq12: 100e6
freq13: 100e6
freq14: 100e6
freq15: 100e6
freq16: 100e6
freq17: 100e6
freq18: 100e6
freq19: 100e6
freq2: 100e6
freq20: 100e6
freq21: 100e6
freq22: 100e6
freq23: 100e6
freq24: 100e6
freq25: 100e6
freq26: 100e6
freq27: 100e6
freq28: 100e6
freq29: 100e6
freq3: 100e6
freq30: 100e6
freq31: 100e6
freq4: 100e6
freq5: 100e6
freq6: 100e6
freq7: 100e6
freq8: 100e6
freq9: 100e6
gain0: '40'
gain1: '10'
gain10: '10'
gain11: '10'
gain12: '10'
gain13: '10'
gain14: '10'
gain15: '10'
gain16: '10'
gain17: '10'
gain18: '10'
gain19: '10'
gain2: '10'
gain20: '10'
gain21: '10'
gain22: '10'
gain23: '10'
gain24: '10'
gain25: '10'
gain26: '10'
gain27: '10'
gain28: '10'
gain29: '10'
gain3: '10'
gain30: '10'
gain31: '10'
gain4: '10'
gain5: '10'
gain6: '10'
gain7: '10'
gain8: '10'
gain9: '10'
gain_mode0: 'False'
gain_mode1: 'False'
gain_mode10: 'False'
gain_mode11: 'False'
gain_mode12: 'False'
gain_mode13: 'False'
gain_mode14: 'False'
gain_mode15: 'False'
gain_mode16: 'False'
gain_mode17: 'False'
gain_mode18: 'False'
gain_mode19: 'False'
gain_mode2: 'False'
gain_mode20: 'False'
gain_mode21: 'False'
gain_mode22: 'False'
gain_mode23: 'False'
gain_mode24: 'False'
gain_mode25: 'False'
gain_mode26: 'False'
gain_mode27: 'False'
gain_mode28: 'False'
gain_mode29: 'False'
gain_mode3: 'False'
gain_mode30: 'False'
gain_mode31: 'False'
gain_mode4: 'False'
gain_mode5: 'False'
gain_mode6: 'False'
gain_mode7: 'False'
gain_mode8: 'False'
gain_mode9: 'False'
if_gain0: '20'
if_gain1: '20'
if_gain10: '20'
if_gain11: '20'
if_gain12: '20'
if_gain13: '20'
if_gain14: '20'
if_gain15: '20'
if_gain16: '20'
if_gain17: '20'
if_gain18: '20'
if_gain19: '20'
if_gain2: '20'
if_gain20: '20'
if_gain21: '20'
if_gain22: '20'
if_gain23: '20'
if_gain24: '20'
if_gain25: '20'
if_gain26: '20'
if_gain27: '20'
if_gain28: '20'
if_gain29: '20'
if_gain3: '20'
if_gain30: '20'
if_gain31: '20'
if_gain4: '20'
if_gain5: '20'
if_gain6: '20'
if_gain7: '20'
if_gain8: '20'
if_gain9: '20'
iq_balance_mode0: '0'
iq_balance_mode1: '0'
iq_balance_mode10: '0'
iq_balance_mode11: '0'
iq_balance_mode12: '0'
iq_balance_mode13: '0'
iq_balance_mode14: '0'
iq_balance_mode15: '0'
iq_balance_mode16: '0'
iq_balance_mode17: '0'
iq_balance_mode18: '0'
iq_balance_mode19: '0'
iq_balance_mode2: '0'
iq_balance_mode20: '0'
iq_balance_mode21: '0'
iq_balance_mode22: '0'
iq_balance_mode23: '0'
iq_balance_mode24: '0'
iq_balance_mode25: '0'
iq_balance_mode26: '0'
iq_balance_mode27: '0'
iq_balance_mode28: '0'
iq_balance_mode29: '0'
iq_balance_mode3: '0'
iq_balance_mode30: '0'
iq_balance_mode31: '0'
iq_balance_mode4: '0'
iq_balance_mode5: '0'
iq_balance_mode6: '0'
iq_balance_mode7: '0'
iq_balance_mode8: '0'
iq_balance_mode9: '0'
maxoutbuf: '0'
minoutbuf: '0'
nchan: '1'
num_mboards: '1'
sample_rate: samp_rate
sync: sync
time_source0: ''
time_source1: ''
time_source2: ''
time_source3: ''
time_source4: ''
time_source5: ''
time_source6: ''
time_source7: ''
type: fc32
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: qtgui_freq_sink_x_0
id: qtgui_freq_sink_x
parameters:
affinity: ''
alias: ''
alpha1: '1.0'
alpha10: '1.0'
alpha2: '1.0'
alpha3: '1.0'
alpha4: '1.0'
alpha5: '1.0'
alpha6: '1.0'
alpha7: '1.0'
alpha8: '1.0'
alpha9: '1.0'
autoscale: 'False'
average: '1.0'
axislabels: 'True'
bw: samp_rate
color1: '"blue"'
color10: '"dark blue"'
color2: '"red"'
color3: '"green"'
color4: '"black"'
color5: '"cyan"'
color6: '"magenta"'
color7: '"yellow"'
color8: '"dark red"'
color9: '"dark green"'
comment: ''
ctrlpanel: 'False'
fc: center_freq
fftsize: '1024'
freqhalf: 'True'
grid: 'False'
gui_hint: ''
label: Relative Gain
label1: ''
label10: ''''''
label2: ''''''
label3: ''''''
label4: ''''''
label5: ''''''
label6: ''''''
label7: ''''''
label8: ''''''
label9: ''''''
legend: 'True'
maxoutbuf: '0'
minoutbuf: '0'
name: '"LoRa Spectrum"'
nconnections: '1'
norm_window: 'False'
showports: 'False'
tr_chan: '0'
tr_level: '0.0'
tr_mode: qtgui.TRIG_MODE_FREE
tr_tag: '""'
type: complex
units: dB
update_time: '0.10'
width1: '1'
width10: '1'
width2: '1'
width3: '1'
width4: '1'
width5: '1'
width6: '1'
width7: '1'
width8: '1'
width9: '1'
wintype: window.WIN_BLACKMAN_hARRIS
ymax: '10'
ymin: '-140'
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
connections:
- [lora_rx_0, out, blocks_message_debug_0, print]
- [low_pass_filter_0, '0', lora_rx_0, '0']
- [osmosdr_source_0, '0', low_pass_filter_0, '0']
- [osmosdr_source_0, '0', qtgui_freq_sink_x_0, '0']
metadata:
file_format: 1
grc_version: 3.10.12.0

217
examples/lora_receiver.py Executable file
View File

@ -0,0 +1,217 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# SPDX-License-Identifier: GPL-3.0
#
# GNU Radio Python Flow Graph
# Title: LoRa SDR Receiver
# Author: gr-mcp
# GNU Radio version: 3.10.12.0
from PyQt5 import Qt
from gnuradio import qtgui
from gnuradio import blocks, gr
from gnuradio import filter
from gnuradio.filter import firdes
from gnuradio import gr
from gnuradio.fft import window
import sys
import signal
from PyQt5 import Qt
from argparse import ArgumentParser
from gnuradio.eng_arg import eng_float, intx
from gnuradio import eng_notation
from xmlrpc.server import SimpleXMLRPCServer
import gnuradio.lora_sdr as lora_sdr
import osmosdr
import time
import sip
import threading
class default(gr.top_block, Qt.QWidget):
def __init__(self):
gr.top_block.__init__(self, "LoRa SDR Receiver", catch_exceptions=True)
Qt.QWidget.__init__(self)
self.setWindowTitle("LoRa SDR Receiver")
qtgui.util.check_set_qss()
try:
self.setWindowIcon(Qt.QIcon.fromTheme('gnuradio-grc'))
except BaseException as exc:
print(f"Qt GUI: Could not set Icon: {str(exc)}", file=sys.stderr)
self.top_scroll_layout = Qt.QVBoxLayout()
self.setLayout(self.top_scroll_layout)
self.top_scroll = Qt.QScrollArea()
self.top_scroll.setFrameStyle(Qt.QFrame.NoFrame)
self.top_scroll_layout.addWidget(self.top_scroll)
self.top_scroll.setWidgetResizable(True)
self.top_widget = Qt.QWidget()
self.top_scroll.setWidget(self.top_widget)
self.top_layout = Qt.QVBoxLayout(self.top_widget)
self.top_grid_layout = Qt.QGridLayout()
self.top_layout.addLayout(self.top_grid_layout)
self.settings = Qt.QSettings("gnuradio/flowgraphs", "default")
try:
geometry = self.settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
except BaseException as exc:
print(f"Qt GUI: Could not restore geometry: {str(exc)}", file=sys.stderr)
self.flowgraph_started = threading.Event()
##################################################
# Variables
##################################################
self.samp_rate = samp_rate = 1000000
self.center_freq = center_freq = 915e6
##################################################
# Blocks
##################################################
self.qtgui_freq_sink_x_0 = qtgui.freq_sink_c(
1024, #size
window.WIN_BLACKMAN_hARRIS, #wintype
center_freq, #fc
samp_rate, #bw
"LoRa Spectrum", #name
1,
None # parent
)
self.qtgui_freq_sink_x_0.set_update_time(0.10)
self.qtgui_freq_sink_x_0.set_y_axis((-140), 10)
self.qtgui_freq_sink_x_0.set_y_label('Relative Gain', 'dB')
self.qtgui_freq_sink_x_0.set_trigger_mode(qtgui.TRIG_MODE_FREE, 0.0, 0, "")
self.qtgui_freq_sink_x_0.enable_autoscale(False)
self.qtgui_freq_sink_x_0.enable_grid(False)
self.qtgui_freq_sink_x_0.set_fft_average(1.0)
self.qtgui_freq_sink_x_0.enable_axis_labels(True)
self.qtgui_freq_sink_x_0.enable_control_panel(False)
self.qtgui_freq_sink_x_0.set_fft_window_normalized(False)
labels = ['', '', '', '', '',
'', '', '', '', '']
widths = [1, 1, 1, 1, 1,
1, 1, 1, 1, 1]
colors = ["blue", "red", "green", "black", "cyan",
"magenta", "yellow", "dark red", "dark green", "dark blue"]
alphas = [1.0, 1.0, 1.0, 1.0, 1.0,
1.0, 1.0, 1.0, 1.0, 1.0]
for i in range(1):
if len(labels[i]) == 0:
self.qtgui_freq_sink_x_0.set_line_label(i, "Data {0}".format(i))
else:
self.qtgui_freq_sink_x_0.set_line_label(i, labels[i])
self.qtgui_freq_sink_x_0.set_line_width(i, widths[i])
self.qtgui_freq_sink_x_0.set_line_color(i, colors[i])
self.qtgui_freq_sink_x_0.set_line_alpha(i, alphas[i])
self._qtgui_freq_sink_x_0_win = sip.wrapinstance(self.qtgui_freq_sink_x_0.qwidget(), Qt.QWidget)
self.top_layout.addWidget(self._qtgui_freq_sink_x_0_win)
self.osmosdr_source_0 = osmosdr.source(
args="numchan=" + str(1) + " " + ""
)
self.osmosdr_source_0.set_time_unknown_pps(osmosdr.time_spec_t())
self.osmosdr_source_0.set_sample_rate(samp_rate)
self.osmosdr_source_0.set_center_freq(center_freq, 0)
self.osmosdr_source_0.set_freq_corr(0, 0)
self.osmosdr_source_0.set_dc_offset_mode(0, 0)
self.osmosdr_source_0.set_iq_balance_mode(0, 0)
self.osmosdr_source_0.set_gain_mode(False, 0)
self.osmosdr_source_0.set_gain(40, 0)
self.osmosdr_source_0.set_if_gain(20, 0)
self.osmosdr_source_0.set_bb_gain(20, 0)
self.osmosdr_source_0.set_antenna('', 0)
self.osmosdr_source_0.set_bandwidth(0, 0)
self.low_pass_filter_0 = filter.fir_filter_ccf(
2,
firdes.low_pass(
1,
samp_rate,
200e3,
50e3,
window.WIN_HAMMING,
6.76))
self.lora_rx_0 = lora_sdr.lora_sdr_lora_rx( bw=125000, cr=1, has_crc=True, impl_head=False, pay_len=255, samp_rate=(int(samp_rate/2)), sf=7, sync_word=[0x12], soft_decoding=True, ldro_mode=2, print_rx=[True,True])
self.blocks_message_debug_0 = blocks.message_debug(True, gr.log_levels.info)
self.xmlrpc_server_0 = SimpleXMLRPCServer(('0.0.0.0', 8080), allow_none=True)
self.xmlrpc_server_0.register_introspection_functions()
self.xmlrpc_server_0.register_instance(self)
self.xmlrpc_server_0_thread = threading.Thread(target=self.xmlrpc_server_0.serve_forever)
self.xmlrpc_server_0_thread.daemon = True
self.xmlrpc_server_0_thread.start()
##################################################
# Connections
##################################################
self.msg_connect((self.lora_rx_0, 'out'), (self.blocks_message_debug_0, 'print'))
self.connect((self.low_pass_filter_0, 0), (self.lora_rx_0, 0))
self.connect((self.osmosdr_source_0, 0), (self.low_pass_filter_0, 0))
self.connect((self.osmosdr_source_0, 0), (self.qtgui_freq_sink_x_0, 0))
def closeEvent(self, event):
self.settings = Qt.QSettings("gnuradio/flowgraphs", "default")
self.settings.setValue("geometry", self.saveGeometry())
self.stop()
self.wait()
event.accept()
def get_samp_rate(self):
return self.samp_rate
def set_samp_rate(self, samp_rate):
self.samp_rate = samp_rate
self.osmosdr_source_0.set_sample_rate(self.samp_rate)
self.low_pass_filter_0.set_taps(firdes.low_pass(1, self.samp_rate, 200e3, 50e3, window.WIN_HAMMING, 6.76))
self.qtgui_freq_sink_x_0.set_frequency_range(self.center_freq, self.samp_rate)
def get_center_freq(self):
return self.center_freq
def set_center_freq(self, center_freq):
self.center_freq = center_freq
self.osmosdr_source_0.set_center_freq(self.center_freq, 0)
self.qtgui_freq_sink_x_0.set_frequency_range(self.center_freq, self.samp_rate)
def main(top_block_cls=default, options=None):
qapp = Qt.QApplication(sys.argv)
tb = top_block_cls()
tb.start()
tb.flowgraph_started.set()
tb.show()
def sig_handler(sig=None, frame=None):
tb.stop()
tb.wait()
Qt.QApplication.quit()
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
timer = Qt.QTimer()
timer.start(500)
timer.timeout.connect(lambda: None)
qapp.exec_()
if __name__ == '__main__':
main()

732
examples/lora_scanner.py Normal file
View File

@ -0,0 +1,732 @@
#!/usr/bin/env python3
"""LoRa Band Scanner — scan 902928 MHz US ISM band for LoRa activity.
Scanning uses rtl_power to sweep the band and detect RF activity.
Decoding builds a gr-lora_sdr receiver flowgraph programmatically using
the same GRC Platform API that gr-mcp uses, compiles it with grcc, and
controls it at runtime via XML-RPC for live parameter changes.
gr-lora_sdr: https://github.com/tapparelj/gr-lora_sdr
"""
import argparse
import csv
import io
import json
import math
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import xmlrpc.client
from collections import defaultdict
from pathlib import Path
# --- Phase A: Band scanning (rtl_power sweep) ---
def run_lora_scan(gain: int = 20) -> str:
"""Execute rtl_power for a single sweep of the US ISM 902928 MHz band.
Uses 50 kHz bins (finer than 125 kHz LoRa channel BW) for better
resolution. Integration time is 2 seconds to catch bursty LoRa packets.
"""
cmd = [
"rtl_power",
"-f", "902M:928M:50k",
"-g", str(gain),
"-i", "2", # 2s integration (LoRa is bursty, needs longer dwell)
"-1", # single-shot
"-", # stdout
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
except FileNotFoundError:
print("Error: rtl_power not found. Install rtl-sdr tools.", file=sys.stderr)
sys.exit(1)
except subprocess.TimeoutExpired:
print("Error: rtl_power timed out after 60 seconds.", file=sys.stderr)
sys.exit(1)
if result.returncode != 0:
stderr = result.stderr.strip()
print(f"Error: rtl_power exited with code {result.returncode}", file=sys.stderr)
if stderr:
print(stderr, file=sys.stderr)
sys.exit(1)
return result.stdout
def parse_lora_scan(csv_data: str) -> list[tuple[float, float]]:
"""Parse rtl_power CSV output into (frequency_mhz, power_dbm) pairs.
rtl_power CSV format per row:
date, time, freq_low_hz, freq_high_hz, bin_step_hz, num_samples, dBm, dBm, ...
Each row covers a frequency range with multiple FFT bins. We compute the
center frequency of each bin and pair it with its power reading.
"""
readings: list[tuple[float, float]] = []
reader = csv.reader(io.StringIO(csv_data))
for row in reader:
if len(row) < 7:
continue
try:
freq_low = float(row[2].strip())
freq_high = float(row[3].strip())
bin_step = float(row[4].strip())
power_values = [float(v.strip()) for v in row[6:] if v.strip()]
except (ValueError, IndexError):
continue
for i, power in enumerate(power_values):
freq_hz = freq_low + (i * bin_step) + (bin_step / 2)
freq_mhz = freq_hz / 1e6
readings.append((freq_mhz, power))
return readings
def aggregate_lora_channels(
readings: list[tuple[float, float]], channel_bw_khz: int = 125
) -> list[dict]:
"""Aggregate raw FFT bins into LoRa-width channels.
LoRa typically uses 125 kHz bandwidth per channel. We snap each reading
to the nearest channel grid and take the max power across all bins in
that channel (peak represents the carrier/chirp).
"""
channel_step_mhz = channel_bw_khz / 1000.0 # 0.125 MHz
channel_bins: dict[float, list[float]] = defaultdict(list)
for freq_mhz, power in readings:
# Snap to nearest channel center
channel = round(round(freq_mhz / channel_step_mhz) * channel_step_mhz, 3)
if 902.0 <= channel <= 928.0:
channel_bins[channel].append(power)
channels = []
for freq in sorted(channel_bins):
powers = channel_bins[freq]
max_power = max(powers)
channels.append({"freq_mhz": freq, "power_dbm": max_power})
return channels
def detect_lora_activity(
channels: list[dict], threshold_db: float = 8.0
) -> tuple[list[dict], float]:
"""Find channels with activity above the noise floor.
LoRa signals are bursty and spread-spectrum, so they appear closer to
the noise floor than narrowband FM. We use a lower default threshold
(8 dB vs 10 dB for FM).
Returns (active_channels_sorted_by_power, noise_floor_dbm).
"""
if not channels:
return [], -99.0
powers = sorted(ch["power_dbm"] for ch in channels)
noise_floor = powers[len(powers) // 2] # median
active = []
for ch in channels:
snr = ch["power_dbm"] - noise_floor
if snr >= threshold_db:
active.append({**ch, "snr_db": round(snr, 1)})
active.sort(key=lambda s: s["power_dbm"], reverse=True)
return active, noise_floor
def display_lora_results(
active_channels: list[dict],
noise_floor: float,
all_channels: list[dict] | None = None,
show_all: bool = False,
):
"""Print a formatted table of LoRa band scan results."""
term_width = shutil.get_terminal_size((80, 24)).columns
bar_max = max(32, term_width - 48)
items = all_channels if (show_all and all_channels) else active_channels
if not items:
print("No LoRa activity detected.")
return
powers = [ch["power_dbm"] for ch in items]
p_min = noise_floor
p_max = max(powers)
p_range = p_max - p_min if p_max != p_min else 1.0
header = "LoRa Band Scan \u2014 902 to 928 MHz (US ISM)"
print()
print(f" {header}")
print(f" {'' * (len(header) + 2)}")
print(f" {'#':>3} {'Channel':<14} {'Power':<10} Activity")
print(f" {'' * 3} {'' * 14} {'' * 9} {'' * bar_max}")
block_chars = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"
for i, ch in enumerate(items, 1):
freq = ch["freq_mhz"]
power = ch["power_dbm"]
norm = max(0.0, min(1.0, (power - p_min) / p_range))
bar_len = norm * bar_max
full_blocks = int(bar_len)
frac = bar_len - full_blocks
frac_char = block_chars[int(frac * 8)] if frac > 0.05 else ""
bar = "\u2588" * full_blocks + frac_char
if "snr_db" in ch and ch["snr_db"] >= 10:
bar = f"\033[32m{bar}\033[0m" # green
elif "snr_db" in ch:
bar = f"\033[33m{bar}\033[0m" # yellow
elif show_all:
bar = f"\033[2m{bar}\033[0m" # dim
label = f"{freq:>7.3f} MHz"
print(f" {i:>3} {label:<14} {power:>7.1f} dBm {bar}")
print(f" {'' * (len(header) + 2)}")
print(
f" Noise floor: {noise_floor:.1f} dBm | "
f"Active channels: {len(active_channels)}"
)
print()
def save_json(active_channels: list[dict], noise_floor: float, path: str):
"""Write scan results to a JSON file."""
data = {
"band": "LoRa ISM",
"range_mhz": [902.0, 928.0],
"noise_floor_dbm": round(noise_floor, 1),
"active_channel_count": len(active_channels),
"channels": [
{
"freq_mhz": s["freq_mhz"],
"power_dbm": round(s["power_dbm"], 1),
"snr_db": s["snr_db"],
}
for s in active_channels
],
}
Path(path).write_text(json.dumps(data, indent=2) + "\n")
print(f"Results saved to {path}")
def pick_channel(active_channels: list[dict]) -> float | None:
"""Interactive channel picker. Returns frequency in MHz or None to quit."""
if not active_channels:
print("No active channels to choose from.")
return None
try:
choice = input(" Tune to channel # (or q to quit): ").strip()
except (EOFError, KeyboardInterrupt):
print()
return None
if choice.lower() in ("q", "quit", ""):
return None
try:
idx = int(choice) - 1
if 0 <= idx < len(active_channels):
return active_channels[idx]["freq_mhz"]
print(f" Pick 1\u2013{len(active_channels)}.")
except ValueError:
try:
freq = float(choice)
if 902.0 <= freq <= 928.0:
return freq
print(" Frequency must be 902\u2013928 MHz.")
except ValueError:
print(" Enter a channel number or frequency.")
return pick_channel(active_channels)
# --- Phase B: LoRa packet receiver (gr-lora_sdr) ---
XMLRPC_PORT = 8091
def build_lora_receiver(
freq_mhz: float = 915.0,
sf: int = 7,
bw: int = 125000,
cr: int = 1,
gain: int = 20,
) -> Path:
"""Build a gr-lora_sdr receiver flowgraph programmatically.
Creates all blocks, sets parameters, connects the full LoRa decode
chain, saves to .grc, and compiles with grcc. Uses soft decoding for
~2-3 dB better sensitivity than hard decisions.
Signal chain:
RTL-SDR (1 Msps) -> frame_sync -> fft_demod -> gray_mapping ->
deinterleaver -> hamming_dec -> header_decoder -> dewhitening -> crc_verif
The header_decoder feeds frame_info back to frame_sync for adaptive
reception (a feedback loop unusual in GNU Radio flowgraphs).
XML-RPC exposes: freq, sf, bw, cr, gain (all settable at runtime)
"""
try:
from gnuradio import gr
from gnuradio.grc.core.platform import Platform
except ImportError:
print("Error: GNU Radio not found. Install gnuradio.", file=sys.stderr)
sys.exit(1)
platform = Platform(
version=gr.version(),
version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()),
prefs=gr.prefs(),
)
platform.build_library()
# Verify gr-lora_sdr blocks are available
block_keys = list(platform.blocks.keys())
lora_blocks = [k for k in block_keys if "lora" in k.lower()]
if not lora_blocks:
print(
"Error: gr-lora_sdr blocks not found. Install gr-lora_sdr OOT module.",
file=sys.stderr,
)
sys.exit(1)
fg = platform.make_flow_graph()
# Configure options block
options = next(b for b in fg.blocks if b.key == "options")
options.params["id"].set_value("lora_receiver")
options.params["title"].set_value("LoRa Receiver")
options.params["generate_options"].set_value("no_gui")
options.params["run_options"].set_value("run")
# --- Variables (all exposed via XML-RPC) ---
samp_rate_var = fg.new_block("variable")
samp_rate_var.params["id"].set_value("samp_rate")
samp_rate_var.params["value"].set_value("int(1e6)")
freq_var = fg.new_block("variable")
freq_var.params["id"].set_value("freq")
freq_var.params["value"].set_value(f"{freq_mhz}e6")
sf_var = fg.new_block("variable")
sf_var.params["id"].set_value("sf")
sf_var.params["value"].set_value(str(sf))
bw_var = fg.new_block("variable")
bw_var.params["id"].set_value("bw")
bw_var.params["value"].set_value(str(bw))
cr_var = fg.new_block("variable")
cr_var.params["id"].set_value("cr")
cr_var.params["value"].set_value(str(cr))
gain_var = fg.new_block("variable")
gain_var.params["id"].set_value("gain")
gain_var.params["value"].set_value(str(gain))
# --- XML-RPC server for runtime parameter control ---
xmlrpc = fg.new_block("xmlrpc_server")
xmlrpc.params["id"].set_value("xmlrpc_server_0")
xmlrpc.params["addr"].set_value("0.0.0.0")
xmlrpc.params["port"].set_value(str(XMLRPC_PORT))
# --- RTL-SDR source ---
source = fg.new_block("osmosdr_source")
source.params["id"].set_value("osmosdr_source_0")
source.params["sample_rate"].set_value("samp_rate")
source.params["freq0"].set_value("freq")
source.params["gain0"].set_value("gain")
source.params["if_gain0"].set_value("20")
source.params["bb_gain0"].set_value("20")
source.params["args"].set_value('"rtl=0"')
# --- gr-lora_sdr decode chain ---
# frame_sync: preamble detection, STO/CFO correction
frame_sync = fg.new_block("lora_sdr_frame_sync")
frame_sync.params["id"].set_value("lora_sdr_frame_sync_0")
frame_sync.params["center_freq"].set_value("freq")
frame_sync.params["bandwidth"].set_value("bw")
frame_sync.params["sf"].set_value("sf")
frame_sync.params["impl_head"].set_value("False") # explicit header
frame_sync.params["os_factor"].set_value("4")
frame_sync.params["show_log_port"].set_value("True")
# fft_demod: chirp demodulation via FFT (soft output)
fft_demod = fg.new_block("lora_sdr_fft_demod")
fft_demod.params["id"].set_value("lora_sdr_fft_demod_0")
fft_demod.params["soft_decoding"].set_value("True")
fft_demod.params["max_log_approx"].set_value("False")
# gray_mapping: Gray code demapping (soft)
gray_map = fg.new_block("lora_sdr_gray_mapping")
gray_map.params["id"].set_value("lora_sdr_gray_mapping_0")
gray_map.params["soft_decoding"].set_value("True")
# deinterleaver: diagonal deinterleaver (soft)
deinterleaver = fg.new_block("lora_sdr_deinterleaver")
deinterleaver.params["id"].set_value("lora_sdr_deinterleaver_0")
deinterleaver.params["soft_decoding"].set_value("True")
# hamming_dec: Hamming FEC decoder (soft input -> hard output)
hamming = fg.new_block("lora_sdr_hamming_dec")
hamming.params["id"].set_value("lora_sdr_hamming_dec_0")
hamming.params["soft_decoding"].set_value("True")
# header_decoder: extract header fields, feed frame_info back to frame_sync
header_dec = fg.new_block("lora_sdr_header_decoder")
header_dec.params["id"].set_value("lora_sdr_header_decoder_0")
header_dec.params["impl_head"].set_value("False")
header_dec.params["cr"].set_value("cr")
header_dec.params["pay_len"].set_value("255")
header_dec.params["has_crc"].set_value("True")
header_dec.params["ldro"].set_value("2") # auto low-data-rate optimize
# dewhitening: XOR with LoRa whitening sequence
dewhiten = fg.new_block("lora_sdr_dewhitening")
dewhiten.params["id"].set_value("lora_sdr_dewhitening_0")
# crc_verif: CRC check and payload output
crc = fg.new_block("lora_sdr_crc_verif")
crc.params["id"].set_value("lora_sdr_crc_verif_0")
crc.params["print_rx_msg"].set_value("True")
crc.params["output_crc_check"].set_value("True")
# --- Connect signal chain ---
# RTL-SDR -> frame_sync
fg.connect(source.sources[0], frame_sync.sinks[0])
# frame_sync -> fft_demod -> gray_mapping -> deinterleaver -> hamming_dec
fg.connect(frame_sync.sources[0], fft_demod.sinks[0])
fg.connect(fft_demod.sources[0], gray_map.sinks[0])
fg.connect(gray_map.sources[0], deinterleaver.sinks[0])
fg.connect(deinterleaver.sources[0], hamming.sinks[0])
# hamming_dec -> header_decoder -> dewhitening -> crc_verif
fg.connect(hamming.sources[0], header_dec.sinks[0])
fg.connect(header_dec.sources[0], dewhiten.sinks[0])
fg.connect(dewhiten.sources[0], crc.sinks[0])
# Feedback: header_decoder frame_info (source) -> frame_sync frame_info (sink)
# This is a message port connection — header_decoder sends decoded
# SF/CR/payload length back to frame_sync for adaptive reception.
# header_decoder.sources[1] = frame_info (message)
# frame_sync.sinks[1] = frame_info (message)
_connect_frame_info_feedback(fg, header_dec, frame_sync)
# --- Save and generate ---
# gr-lora_sdr's soft decoding changes port types at runtime (short→float64)
# which GRC's static validator flags as mismatches. The types are actually
# correct at runtime — soft_decoding=True uses float paths throughout.
# Use platform.Generator directly instead of grcc (which refuses is_valid=False).
work_dir = Path(tempfile.mkdtemp(prefix="lora_receiver_"))
grc_path = work_dir / "lora_receiver.grc"
platform.save_flow_graph(str(grc_path), fg)
fg.rewrite()
generator = platform.Generator(fg, str(work_dir))
generator.write()
py_path = work_dir / "lora_receiver.py"
if not py_path.exists():
print("Error: code generation produced no Python output.", file=sys.stderr)
sys.exit(1)
return py_path
def _connect_frame_info_feedback(fg, header_dec, frame_sync):
"""Connect frame_info message port from header_decoder back to frame_sync.
gr-lora_sdr uses message ports for the feedback loop where the header
decoder sends decoded SF/CR/payload length back to frame_sync. This
allows adaptive reception of packets with different parameters.
Port layout (from block introspection):
header_decoder sources: [0]=byte (data), [1]=frame_info (message)
frame_sync sinks: [0]=complex (signal), [1]=frame_info (message)
"""
# header_decoder.sources[1] is the frame_info message output
# frame_sync.sinks[1] is the frame_info message input
if len(header_dec.sources) > 1 and len(frame_sync.sinks) > 1:
fg.connect(header_dec.sources[1], frame_sync.sinks[1])
# --- Runtime control ---
def wait_for_xmlrpc(url: str, timeout: float = 10.0) -> xmlrpc.client.ServerProxy:
"""Wait for the XML-RPC server to become reachable."""
proxy = xmlrpc.client.ServerProxy(url)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
proxy.get_freq()
return proxy
except ConnectionRefusedError:
time.sleep(0.3)
except Exception:
return proxy
print("Error: flowgraph XML-RPC server did not start.", file=sys.stderr)
sys.exit(1)
def format_lora_params(proxy: xmlrpc.client.ServerProxy) -> str:
"""Format current LoRa parameters for display."""
try:
freq = proxy.get_freq() / 1e6
sf = int(proxy.get_sf())
bw_khz = proxy.get_bw() / 1000
cr = int(proxy.get_cr())
return f"{freq:.3f} MHz SF{sf} BW {bw_khz:.0f} kHz CR 4/{4+cr}"
except Exception:
return "(parameters unavailable)"
def tune_lora(
freq_mhz: float,
sf: int = 7,
bw: int = 125000,
cr: int = 1,
gain: int = 20,
):
"""Launch a gr-lora_sdr receiver and control via XML-RPC.
Builds the flowgraph programmatically, launches it as a subprocess,
connects to its XML-RPC server, and provides an interactive control
loop for changing LoRa parameters at runtime.
"""
print(f"\n Building LoRa receiver for {freq_mhz:.3f} MHz (SF{sf})...")
py_path = build_lora_receiver(freq_mhz, sf=sf, bw=bw, cr=cr, gain=gain)
url = f"http://localhost:{XMLRPC_PORT}"
print(f" Launching flowgraph ({py_path.name})...")
fg_proc = subprocess.Popen(
[sys.executable, str(py_path)],
stderr=subprocess.DEVNULL,
)
proxy = wait_for_xmlrpc(url)
time.sleep(0.5)
params = format_lora_params(proxy)
print(f" Listening: {params}")
print()
print(" Commands:")
print(" freq <MHz> — change frequency (e.g. 'freq 915.0')")
print(" sf <N> — change spreading factor (7-12)")
print(" bw <Hz> — change bandwidth (e.g. 'bw 250000')")
print(" cr <N> — change coding rate (1-4)")
print(" status — show current parameters")
print(" q — quit")
print()
try:
while fg_proc.poll() is None:
try:
cmd = input(" lora> ").strip()
except EOFError:
break
if not cmd or cmd.lower() in ("q", "quit"):
break
if cmd.lower() in ("s", "status"):
print(f" {format_lora_params(proxy)}")
continue
parts = cmd.split(maxsplit=1)
if len(parts) != 2:
print(" Usage: freq|sf|bw|cr <value>, status, q")
continue
param, value_str = parts[0].lower(), parts[1]
try:
if param == "freq":
new_freq = float(value_str)
if 902.0 <= new_freq <= 928.0:
proxy.set_freq(new_freq * 1e6)
time.sleep(0.3)
print(f" {format_lora_params(proxy)}")
else:
print(" Frequency must be 902\u2013928 MHz.")
elif param == "sf":
new_sf = int(value_str)
if 7 <= new_sf <= 12:
proxy.set_sf(new_sf)
time.sleep(0.3)
print(f" {format_lora_params(proxy)}")
else:
print(" Spreading factor must be 7\u201312.")
elif param == "bw":
new_bw = int(value_str)
if new_bw in (7800, 10400, 15600, 20800, 31250,
41700, 62500, 125000, 250000, 500000):
proxy.set_bw(new_bw)
time.sleep(0.3)
print(f" {format_lora_params(proxy)}")
else:
print(" Valid BWs: 7800 10400 15600 20800 31250 "
"41700 62500 125000 250000 500000")
elif param == "cr":
new_cr = int(value_str)
if 1 <= new_cr <= 4:
proxy.set_cr(new_cr)
time.sleep(0.3)
print(f" {format_lora_params(proxy)}")
else:
print(" Coding rate must be 1\u20134.")
elif param == "gain":
new_gain = int(value_str)
proxy.set_gain(new_gain)
time.sleep(0.3)
print(f" Gain set to {new_gain} dB")
else:
print(" Unknown param. Use: freq, sf, bw, cr, gain")
except ValueError:
print(f" Invalid value: {value_str}")
except Exception as e:
print(f" XML-RPC error: {e}")
except KeyboardInterrupt:
pass
print("\n Stopping flowgraph...")
if fg_proc.poll() is None:
fg_proc.send_signal(signal.SIGTERM)
try:
fg_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
fg_proc.kill()
# --- CLI entry point ---
def main():
parser = argparse.ArgumentParser(
description="Scan the US ISM band (902-928 MHz) for LoRa activity."
)
parser.add_argument(
"--threshold",
type=float,
default=8.0,
help="Minimum dB above noise floor to flag as active (default: 8)",
)
parser.add_argument(
"--gain",
type=int,
default=20,
help="RF tuner gain in dB (default: 20, higher for 915 MHz)",
)
parser.add_argument(
"--json",
metavar="FILE",
help="Save scan results to JSON file",
)
parser.add_argument(
"--all",
action="store_true",
dest="show_all",
help="Show all channels, not just active ones",
)
parser.add_argument(
"--listen",
type=float,
metavar="FREQ",
help="Listen on specific frequency (MHz) without scanning first",
)
parser.add_argument(
"--tune",
action="store_true",
help="Pick an active channel to listen on after scanning",
)
parser.add_argument(
"--sf",
type=int,
default=7,
choices=range(7, 13),
help="LoRa spreading factor (default: 7)",
)
parser.add_argument(
"--bw",
type=int,
default=125000,
help="LoRa bandwidth in Hz (default: 125000)",
)
parser.add_argument(
"--cr",
type=int,
default=1,
choices=range(1, 5),
help="LoRa coding rate 1-4 (default: 1, meaning 4/5)",
)
args = parser.parse_args()
# Direct listen mode — skip scanning
if args.listen is not None:
tune_lora(
args.listen,
sf=args.sf,
bw=args.bw,
cr=args.cr,
gain=args.gain,
)
return
# Scan mode
print("Scanning LoRa band (902\u2013928 MHz)...", flush=True)
raw = run_lora_scan(gain=args.gain)
readings = parse_lora_scan(raw)
if not readings:
print("No data received from rtl_power.", file=sys.stderr)
sys.exit(1)
channels = aggregate_lora_channels(readings)
active, noise_floor = detect_lora_activity(
channels, threshold_db=args.threshold
)
display_lora_results(
active,
noise_floor,
all_channels=channels,
show_all=args.show_all,
)
if args.json:
save_json(active, noise_floor, args.json)
if args.tune:
freq = pick_channel(active)
if freq:
tune_lora(
freq,
sf=args.sf,
bw=args.bw,
cr=args.cr,
gain=args.gain,
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,330 @@
"""Integration tests for LoRa scanner band sweep and flowgraph construction.
Tests the programmatic flowgraph construction and scan data parsing for
the LoRa ISM band scanner. Requires GNU Radio + gr-lora_sdr for flowgraph
tests, but parsing tests run without hardware or GNU Radio.
Run with: pytest tests/integration/test_lora_scanner.py -v
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# Add examples to path so we can import lora_scanner
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "examples"))
# Check if GNU Radio is available
try:
from gnuradio import gr
GNURADIO_AVAILABLE = True
except ImportError:
GNURADIO_AVAILABLE = False
# Check if gr-lora_sdr is available (needs the Docker image or local install)
try:
import lora_sdr # noqa: F401
LORA_SDR_AVAILABLE = True
except ImportError:
LORA_SDR_AVAILABLE = False
class TestScanParsing:
"""Unit tests for LoRa scan data parsing (no GNU Radio needed)."""
def test_parse_lora_scan_valid_csv(self):
"""Test parsing valid rtl_power CSV output for ISM band."""
from lora_scanner import parse_lora_scan
csv_data = """\
2025-01-01, 12:00:00, 902000000, 902100000, 50000, 1, -55.2, -57.1
2025-01-01, 12:00:01, 915000000, 915100000, 50000, 1, -32.3, -38.8
"""
readings = parse_lora_scan(csv_data)
# Should have 4 readings (2 bins per row x 2 rows)
assert len(readings) == 4
# First reading should be in the 902 MHz range
freq_mhz, power_dbm = readings[0]
assert 902.0 <= freq_mhz <= 902.1
assert power_dbm == -55.2
# Third reading should be in the 915 MHz range
freq_mhz, power_dbm = readings[2]
assert 915.0 <= freq_mhz <= 915.1
assert power_dbm == -32.3
def test_parse_lora_scan_empty(self):
"""Test parsing empty CSV."""
from lora_scanner import parse_lora_scan
readings = parse_lora_scan("")
assert readings == []
def test_parse_lora_scan_malformed(self):
"""Test parsing malformed CSV (should skip bad rows)."""
from lora_scanner import parse_lora_scan
csv_data = """\
bad data
2025-01-01, 12:00:00, 915000000, 915050000, 50000, 1, -42.5
more bad data
"""
readings = parse_lora_scan(csv_data)
assert len(readings) == 1
def test_aggregate_lora_channels_125khz(self):
"""Test channel aggregation snaps to 125 kHz LoRa channels."""
from lora_scanner import aggregate_lora_channels
# Readings clustered around 915.0 MHz
readings = [
(914.950, -40.0),
(915.000, -32.0),
(915.050, -35.0),
]
channels = aggregate_lora_channels(readings, channel_bw_khz=125)
# Should aggregate to channel(s) near 915 MHz
assert len(channels) >= 1
# Find the channel closest to 915.0
ch915 = min(channels, key=lambda c: abs(c["freq_mhz"] - 915.0))
assert abs(ch915["freq_mhz"] - 915.0) < 0.125
# Max power should be used (carrier peak)
assert ch915["power_dbm"] == -32.0
def test_aggregate_lora_channels_out_of_band(self):
"""Test that out-of-band readings are excluded."""
from lora_scanner import aggregate_lora_channels
readings = [
(800.0, -30.0), # below ISM band
(915.0, -35.0), # in-band
(950.0, -30.0), # above ISM band
]
channels = aggregate_lora_channels(readings)
# Only the in-band reading should produce a channel
assert len(channels) == 1
assert abs(channels[0]["freq_mhz"] - 915.0) < 0.125
def test_detect_lora_activity(self):
"""Test activity detection above noise floor."""
from lora_scanner import detect_lora_activity
channels = [
{"freq_mhz": 903.0, "power_dbm": -55.0}, # noise
{"freq_mhz": 909.0, "power_dbm": -58.0}, # noise
{"freq_mhz": 915.0, "power_dbm": -32.0}, # active!
{"freq_mhz": 920.0, "power_dbm": -56.0}, # noise
{"freq_mhz": 925.0, "power_dbm": -40.0}, # active!
]
active, noise_floor = detect_lora_activity(channels, threshold_db=8.0)
# Median of [-55, -58, -32, -56, -40] = -55
assert -58 < noise_floor < -50
# Should detect 2 active channels (>8 dB above noise)
assert len(active) == 2
# Strongest should be first
assert active[0]["freq_mhz"] == 915.0
assert active[1]["freq_mhz"] == 925.0
def test_detect_lora_activity_empty(self):
"""Test activity detection with empty channel list."""
from lora_scanner import detect_lora_activity
active, noise_floor = detect_lora_activity([])
assert active == []
assert noise_floor == -99.0
def test_detect_lora_activity_low_threshold(self):
"""Test that lower threshold catches more channels."""
from lora_scanner import detect_lora_activity
channels = [
{"freq_mhz": 903.0, "power_dbm": -55.0},
{"freq_mhz": 915.0, "power_dbm": -48.0}, # 7 dB above median
{"freq_mhz": 920.0, "power_dbm": -56.0},
]
# At 8 dB threshold, 915.0 should NOT be detected
active_8, _ = detect_lora_activity(channels, threshold_db=8.0)
assert len(active_8) == 0
# At 5 dB threshold, 915.0 SHOULD be detected
active_5, _ = detect_lora_activity(channels, threshold_db=5.0)
assert len(active_5) == 1
assert active_5[0]["freq_mhz"] == 915.0
def test_aggregate_lora_channels_250khz(self):
"""Test aggregation with 250 kHz bandwidth (wider LoRa channels)."""
from lora_scanner import aggregate_lora_channels
readings = [
(914.900, -40.0),
(914.950, -38.0),
(915.000, -32.0),
(915.050, -35.0),
(915.100, -39.0),
]
channels = aggregate_lora_channels(readings, channel_bw_khz=250)
# With 250 kHz bins, more readings should aggregate together
assert len(channels) >= 1
ch = min(channels, key=lambda c: abs(c["freq_mhz"] - 915.0))
# Max should still be -32.0
assert ch["power_dbm"] == -32.0
@pytest.mark.skipif(not GNURADIO_AVAILABLE, reason="GNU Radio not available")
class TestLoraBlockAvailability:
"""Tests that verify gr-lora_sdr block registration (requires GNU Radio)."""
def _get_platform_blocks(self):
"""Helper to initialize platform and get block keys."""
from gnuradio import gr
from gnuradio.grc.core.platform import Platform
platform = Platform(
version=gr.version(),
version_parts=(
gr.major_version(),
gr.api_version(),
gr.minor_version(),
),
prefs=gr.prefs(),
)
platform.build_library()
return list(platform.blocks.keys())
@pytest.mark.skipif(not LORA_SDR_AVAILABLE, reason="gr-lora_sdr not installed")
def test_lora_frame_sync_available(self):
"""Verify frame_sync block is registered."""
block_keys = self._get_platform_blocks()
assert "lora_sdr_frame_sync" in block_keys
@pytest.mark.skipif(not LORA_SDR_AVAILABLE, reason="gr-lora_sdr not installed")
def test_lora_fft_demod_available(self):
"""Verify fft_demod block is registered."""
block_keys = self._get_platform_blocks()
assert "lora_sdr_fft_demod" in block_keys
@pytest.mark.skipif(not LORA_SDR_AVAILABLE, reason="gr-lora_sdr not installed")
def test_lora_crc_verif_available(self):
"""Verify crc_verif block is registered."""
block_keys = self._get_platform_blocks()
assert "lora_sdr_crc_verif" in block_keys
def test_xmlrpc_server_available(self):
"""Verify XML-RPC server block exists (needed for runtime control)."""
block_keys = self._get_platform_blocks()
assert "xmlrpc_server" in block_keys
def test_osmosdr_source_available(self):
"""Verify RTL-SDR source block exists."""
block_keys = self._get_platform_blocks()
# osmosdr may or may not be available depending on install
# Just check it doesn't crash
assert isinstance(block_keys, list)
@pytest.mark.skipif(
not (GNURADIO_AVAILABLE and LORA_SDR_AVAILABLE),
reason="GNU Radio + gr-lora_sdr required",
)
class TestFlowgraphConstruction:
"""Integration tests for LoRa flowgraph construction."""
def test_build_lora_receiver_creates_grc(self):
"""Test that build_lora_receiver creates a valid compiled flowgraph."""
from lora_scanner import build_lora_receiver
py_path = build_lora_receiver(915.0, sf=7, bw=125000, cr=1, gain=20)
assert py_path.exists()
assert py_path.suffix == ".py"
py_code = py_path.read_text()
# Should have XML-RPC server
assert "SimpleXMLRPCServer" in py_code
# Should have freq variable
assert "freq" in py_code
# Should have sf variable
assert "sf" in py_code
# Should have get/set methods for runtime control
assert "get_freq" in py_code
assert "set_freq" in py_code
assert "get_sf" in py_code
assert "set_sf" in py_code
def test_build_lora_receiver_has_lora_blocks(self):
"""Verify compiled flowgraph contains gr-lora_sdr blocks."""
from lora_scanner import build_lora_receiver
py_path = build_lora_receiver(915.0, sf=10, bw=125000)
py_code = py_path.read_text()
# Should contain gr-lora_sdr block references
assert "frame_sync" in py_code
assert "fft_demod" in py_code
assert "crc_verif" in py_code
def test_flowgraph_compiled_structure(self):
"""Verify the compiled flowgraph has correct class structure."""
from lora_scanner import build_lora_receiver
import ast
py_path = build_lora_receiver(903.9, sf=12, bw=250000, cr=4)
py_code = py_path.read_text()
tree = ast.parse(py_code)
class_defs = [
node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
]
assert len(class_defs) >= 1
# Find method definitions in the main class
lora_class = class_defs[0]
method_names = [
node.name
for node in lora_class.body
if isinstance(node, ast.FunctionDef)
]
# Should have get/set for all XML-RPC-exposed variables
assert "get_freq" in method_names
assert "set_freq" in method_names
assert "get_sf" in method_names
assert "set_sf" in method_names
assert "get_bw" in method_names
assert "set_bw" in method_names
assert "get_cr" in method_names
assert "set_cr" in method_names
def test_build_lora_receiver_xmlrpc_port(self):
"""Verify compiled flowgraph uses correct XML-RPC port."""
from lora_scanner import build_lora_receiver
py_path = build_lora_receiver(915.0)
py_code = py_path.read_text()
# Should use port 8091 (not 8090 which is FM)
assert "8091" in py_code
assert "0.0.0.0" in py_code or "''" in py_code