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:
parent
f3efb36435
commit
e8b3600e60
32
docker/Dockerfile.gnuradio-lora
Normal file
32
docker/Dockerfile.gnuradio-lora
Normal 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"]
|
||||||
22
docker/Dockerfile.gnuradio-lora-runtime
Normal file
22
docker/Dockerfile.gnuradio-lora-runtime
Normal 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}"
|
||||||
32
docker/docker-compose.lora-receiver.yml
Normal file
32
docker/docker-compose.lora-receiver.yml
Normal 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
28
docker/entrypoint-lora.sh
Executable 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
29
docker/run-lora-receiver.sh
Executable 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
|
||||||
117
examples/lora_infrastructure_test.py
Normal file
117
examples/lora_infrastructure_test.py
Normal 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
574
examples/lora_receiver.grc
Normal 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
217
examples/lora_receiver.py
Executable 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
732
examples/lora_scanner.py
Normal file
@ -0,0 +1,732 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LoRa Band Scanner — scan 902–928 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 902–928 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()
|
||||||
330
tests/integration/test_lora_scanner.py
Normal file
330
tests/integration/test_lora_scanner.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user