From 56ef86aab67469f0a792e606f4b4451145fb8f1c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 9 May 2019 22:44:29 +0200 Subject: [PATCH] multi-sdr capabilities! --- config_webrx.py | 36 +++++++---- owrx/config.py | 19 +++++- owrx/controllers.py | 129 ++++++++++++++++++++++++------------- owrx/source.py | 151 +++++++++++++++++++++++++++++--------------- server.py | 7 +- 5 files changed, 228 insertions(+), 114 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index ca91905..1ea9b06 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -71,12 +71,6 @@ fft_fps=9 fft_size=4096 #Should be power of 2 fft_voverlap_factor=0.3 #If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram. -# samp_rate = 250000 -samp_rate = 2400000 -center_freq = 144250000 -rf_gain = 5 #in dB. For an RTL-SDR, rf_gain=0 will set the tuner to auto gain mode, else it will be in manual gain mode. -ppm = 0 - audio_compression="adpcm" #valid values: "adpcm", "none" fft_compression="adpcm" #valid values: "adpcm", "none" @@ -104,7 +98,30 @@ Note: if you experience audio underruns while CPU usage is 100%, you can: # You can use other SDR hardware as well, by giving your own command that outputs the I/Q samples... Some examples of configuration are available here (default is RTL-SDR): # valid: "rtl_sdr", "sdrplay", "hackrf" -rtl_type = "rtl_sdr" +#rtl_type = "sdrplay" + +sdrs = { + "rtlsdr": { + "name": "RTL-SDR USB Stick", + "type": "rtl_sdr", + "center_freq": 438800000, + "rf_gain": 30, + "samp_rate": 2400000, + "ppm": 0, + "start_freq": 439275000, + "start_mod": "nfm" + }, + "sdrplay": { + "name": "SDRPlay RSP2", + "type": "sdrplay", + "center_freq": 14150000, + "rf_gain": 30, + "samp_rate": 500000, + "ppm": 0, + "start_freq": 14070000, + "start_mod": "usb" + } +} # >> RTL-SDR via rtl_sdr #start_rtl_command="rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -".format(rf_gain=rf_gain, center_freq=center_freq, samp_rate=samp_rate, ppm=ppm) @@ -168,10 +185,7 @@ client_audio_buffer_size = 5 # - also increase the latency # - decrease the chance of audio underruns -start_freq = center_freq -start_mod = "nfm" #nfm, am, lsb, usb, cw - -iq_server_port = 4951 #TCP port for ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default. +iq_port_range = [4950, 4960] #TCP port for range ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default. #access_log = "~/openwebrx_access.log" diff --git a/owrx/config.py b/owrx/config.py index eb45fc9..d369c30 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -28,7 +28,7 @@ class PropertyManager(object): return PropertyManager.sharedInstance def collect(self, *props): - return PropertyManager(dict((name, self.getProperty(name)) for name in props)) + return PropertyManager(dict((name, self.getProperty(name) if self.hasProperty(name) else Property()) for name in props)) def __init__(self, properties = None): self.properties = {} @@ -52,10 +52,15 @@ class PropertyManager(object): return self.getPropertyValue(name) def __setitem__(self, name, value): + if not self.hasProperty(name): + self.add(name, Property()) self.getProperty(name).setValue(value) + def hasProperty(self, name): + return name in self.properties + def getProperty(self, name): - if not name in self.properties: + if not self.hasProperty(name): self.add(name, Property()) return self.properties[name] @@ -66,6 +71,16 @@ class PropertyManager(object): self.callbacks.append(callback) return self + def unwire(self, callback): + self.callbacks.remove(callback) + return self + + def defaults(self, other_pm): + for (key, p) in self.properties.items(): + if p.getValue() is None: + p.setValue(other_pm[key]) + return self + class RequirementMissingException(Exception): pass diff --git a/owrx/controllers.py b/owrx/controllers.py index f18a43a..8676915 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -1,7 +1,7 @@ import mimetypes from owrx.websocket import WebSocketConnection from owrx.config import PropertyManager -from owrx.source import SpectrumThread, DspManager, CpuUsageThread +from owrx.source import SpectrumThread, DspManager, CpuUsageThread, SdrService import json import os from datetime import datetime @@ -62,8 +62,76 @@ class IndexController(AssetsController): self.serve_file("index.wrx", "text/html") class OpenWebRxClient(object): + config_keys = ["waterfall_colors", "waterfall_min_level", "waterfall_max_level", + "waterfall_auto_level_margin", "lfo_offset", "samp_rate", "fft_size", "fft_fps", + "audio_compression", "fft_compression", "max_clients", "start_mod", + "client_audio_buffer_size", "start_freq", "center_freq"] def __init__(self, conn): self.conn = conn + + self.dsp = None + self.sdr = None + self.configProps = None + + pm = PropertyManager.getSharedInstance() + + self.setSdr() + + # send receiver info + receiver_keys = ["receiver_name", "receiver_location", "receiver_qra", "receiver_asl", "receiver_gps", + "photo_title", "photo_desc"] + receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys) + self.write_receiver_details(receiver_details) + + CpuUsageThread.getSharedInstance().add_client(self) + + def sendConfig(self, key, value): + config = dict((key, self.configProps[key]) for key in OpenWebRxClient.config_keys) + # TODO mathematical properties? hmmmm + config["start_offset_freq"] = self.configProps["start_freq"] - self.configProps["center_freq"] + self.write_config(config) + def setSdr(self, id = None): + self.stopDsp() + + if self.configProps is not None: + self.configProps.unwire(self.sendConfig) + + self.sdr = SdrService.getSource(id) + self.sdr.start() + + # send initial config + self.configProps = self.sdr.getProps().collect(*OpenWebRxClient.config_keys).defaults(PropertyManager.getSharedInstance()) + + self.configProps.wire(self.sendConfig) + self.sendConfig(None, None) + + self.sdr.spectrumThread.add_client(self) + + def startDsp(self): + if self.dsp is None: + self.dsp = DspManager(self, self.sdr) + self.dsp.start() + + def stopDsp(self): + if self.dsp is not None: + self.dsp.stop() + self.dsp = None + if self.sdr is not None: + self.sdr.spectrumThread.remove_client(self) + # TODO: this should be disabled somehow, just not with the dsp + #CpuUsageThread.getSharedInstance().remove_client(self) + + def setParams(self, params): + # only the keys in the protected property manager can be overridden from the web + protected = self.sdr.getProps().collect("samp_rate", "center_freq", "rf_gain", "type") \ + .defaults(PropertyManager.getSharedInstance()) + for key, value in params.items(): + protected[key] = value + + def setDspProperties(self, params): + for key, value in params.items(): + self.dsp.setProperty(key, value) + def write_spectrum_data(self, data): self.conn.send(bytes([0x01]) + data) def write_dsp_data(self, data): @@ -90,8 +158,6 @@ class WebSocketMessageHandler(object): self.dsp = None def handleTextMessage(self, conn, message): - pm = PropertyManager.getSharedInstance() - if (message[:16] == "SERVER DE CLIENT"): # maybe put some more info in there? nothing to store yet. self.handshake = "completed" @@ -99,31 +165,6 @@ class WebSocketMessageHandler(object): self.client = OpenWebRxClient(conn) - config_keys = ["waterfall_colors", "waterfall_min_level", "waterfall_max_level", - "waterfall_auto_level_margin", "lfo_offset", "samp_rate", "fft_size", "fft_fps", - "audio_compression", "fft_compression", "max_clients", "start_mod", - "client_audio_buffer_size", "start_freq", "center_freq"] - - configProps = pm.collect(*config_keys) - - def sendConfig(key, value): - config = dict((key, configProps[key]) for key in config_keys) - config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] - self.client.write_config(config) - - configProps.wire(sendConfig) - sendConfig(None, None) - - receiver_keys = ["receiver_name", "receiver_location", "receiver_qra", "receiver_asl", "receiver_gps", - "photo_title", "photo_desc"] - receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys) - self.client.write_receiver_details(receiver_details) - - SpectrumThread.getSharedInstance().add_client(self.client) - CpuUsageThread.getSharedInstance().add_client(self.client) - - self.dsp = DspManager(self.client) - return if not self.handshake: @@ -132,20 +173,23 @@ class WebSocketMessageHandler(object): try: message = json.loads(message) - if message["type"] == "dspcontrol": - if "params" in message: - params = message["params"] - for key, value in params.items(): - self.dsp.setProperty(key, value) + if "type" in message: + if message["type"] == "dspcontrol": + if "action" in message and message["action"] == "start": + self.client.startDsp() - if "action" in message and message["action"] == "start": - self.dsp.start() + if "params" in message: + params = message["params"] + self.client.setDspProperties(params) - if message["type"] == "config": - for key, value in message["params"].items(): - # only the keys in the protected property manager can be overridden from the web - protected = pm.collect("samp_rate", "center_freq", "rf_gain", "rtl_type") - protected[key] = value + if message["type"] == "config": + if "params" in message: + self.client.setParams(message["params"]) + if message["type"] == "setsdr": + if "params" in message: + self.client.setSdr(message["params"]["sdr"]) + else: + print("received message without type: {0}".format(message)) except json.JSONDecodeError: print("message is not json: {0}".format(message)) @@ -155,10 +199,7 @@ class WebSocketMessageHandler(object): def handleClose(self, conn): if self.client: - SpectrumThread.getSharedInstance().remove_client(self.client) - CpuUsageThread.getSharedInstance().remove_client(self.client) - if self.dsp: - self.dsp.stop() + self.client.stopDsp() class WebSocketController(Controller): def handle_request(self): diff --git a/owrx/source.py b/owrx/source.py index be2521c..974e93a 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -6,47 +6,87 @@ import time import os import signal -class RtlNmuxSource(object): - types = { - "rtl_sdr": { - "command": "rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -", - "format_conversion": "csdr convert_u8_f" - }, - "hackrf": { - "command": "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-", - "format_conversion": "csdr convert_s8_f" - }, - "sdrplay": { - "command": "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -", - "format_conversion": None, - "sleep": 1 - } - } +class SdrService(object): + sdrProps = None + sources = {} + lastPort = None + @staticmethod + def getNextPort(): + pm = PropertyManager.getSharedInstance() + (start, end) = pm["iq_port_range"] + if SdrService.lastPort is None: + SdrService.lastPort = start + else: + SdrService.lastPort += 1 + if SdrService.lastPort > end: + raise IndexError("no more available ports to start more sdrs") + return SdrService.lastPort + @staticmethod + def getSource(id = None): + if SdrService.sdrProps is None: + pm = PropertyManager.getSharedInstance() + def loadIntoPropertyManager(dict: dict): + propertyManager = PropertyManager() + for (name, value) in dict.items(): + propertyManager[name] = value + return propertyManager + SdrService.sdrProps = dict((name, loadIntoPropertyManager(value)) for (name, value) in pm["sdrs"].items()) + print(SdrService.sdrProps) + if id is None: + # TODO: configure default sdr in config? right now it will pick the first one off the list. + id = list(SdrService.sdrProps.keys())[0] + if not id in SdrService.sources: + SdrService.sources[id] = SdrSource(SdrService.sdrProps[id], SdrService.getNextPort()) + return SdrService.sources[id] - def setup(self): - self.props = props = PropertyManager.getSharedInstance().collect( - "rtl_type", "samp_rate", "nmux_memory", "iq_server_port", "center_freq", "ppm", - "rf_gain", "lna_gain", "rf_amp" - ) +sdr_types = { + "rtl_sdr": { + "command": "rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -", + "format_conversion": "csdr convert_u8_f" + }, + "hackrf": { + "command": "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-", + "format_conversion": "csdr convert_s8_f" + }, + "sdrplay": { + "command": "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -", + "format_conversion": None, + "sleep": 1 + } +} + +class SdrSource(object): + def __init__(self, props, port): + self.props = props + self.rtlProps = self.props.collect( + "type", "samp_rate", "nmux_memory", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp" + ).defaults(PropertyManager.getSharedInstance()) def restart(name, value): - print("restarting rtl source due to property change: {0} changed to {1}".format(name, value)) + print("restarting sdr source due to property change: {0} changed to {1}".format(name, value)) self.stop() self.start() - props.wire(restart) + self.rtlProps.wire(restart) + self.port = port + self.monitor = None - self.start() + def getProps(self): + return self.props + + def getPort(self): + return self.port def start(self): + if self.monitor: return - props = self.props + props = self.rtlProps featureDetector = FeatureDetector() - if not featureDetector.is_available(props["rtl_type"]): + if not featureDetector.is_available(props["type"]): print("The RTL source type {0} is not available. please check requirements.".format(props["rtl_type"])) return - self.params = RtlNmuxSource.types[props["rtl_type"]] + self.params = sdr_types[props["type"]] start_sdr_command = self.params["command"].format( samp_rate = props["samp_rate"], @@ -67,7 +107,7 @@ class RtlNmuxSource(object): print("[RtlNmuxSource] Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py") return print("[RtlNmuxSource] nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt)) - cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (nmux_bufsize, nmux_bufcnt, props["iq_server_port"]) + cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (nmux_bufsize, nmux_bufcnt, self.port) self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp) print("[RtlNmuxSource] Started rtl source: " + cmd) @@ -75,38 +115,36 @@ class RtlNmuxSource(object): def wait_for_process_to_end(): rc = self.process.wait() print("[RtlNmuxSource] shut down with RC={0}".format(rc)) + self.monitor = None self.monitor = threading.Thread(target = wait_for_process_to_end) self.monitor.start() + self.spectrumThread = SpectrumThread(self) + def stop(self): os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) self.monitor.join() if "sleep" in self.params: time.sleep(self.params["sleep"]) -class SpectrumThread(threading.Thread): - sharedInstance = None - @staticmethod - def getSharedInstance(): - if SpectrumThread.sharedInstance is None: - SpectrumThread.sharedInstance = SpectrumThread() - SpectrumThread.sharedInstance.start() - return SpectrumThread.sharedInstance - - def __init__(self): +class SpectrumThread(object): + def __init__(self, sdrSource): self.clients = [] - self.doRun = True - super().__init__() + self.doRun = False + self.sdrSource = sdrSource + + def start(self): + threading.Thread(target = self.run).start() def run(self): - props = PropertyManager.getSharedInstance().collect( + props = self.sdrSource.props.collect( "samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor", "fft_compression", - "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", "iq_server_port" - ) + "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through" + ).defaults(PropertyManager.getSharedInstance()) dsp = csdr.dsp() - dsp.nc_port = props["iq_server_port"] + dsp.nc_port = self.sdrSource.getPort() dsp.set_demodulator("fft") props.getProperty("samp_rate").wire(dsp.set_samp_rate) props.getProperty("fft_size").wire(dsp.set_fft_size) @@ -146,26 +184,32 @@ class SpectrumThread(threading.Thread): def add_client(self, c): self.clients.append(c) + if not self.doRun: + self.doRun = True + self.start() def remove_client(self, c): - self.clients.remove(c) + try: + self.clients.remove(c) + except ValueError: + pass if not self.clients: self.shutdown() def shutdown(self): print("shutting down spectrum thread") - SpectrumThread.sharedInstance = None self.doRun = False class DspManager(object): - def __init__(self, handler): + def __init__(self, handler, sdrSource): self.doRun = True self.handler = handler + self.sdrSource = sdrSource - self.localProps = PropertyManager.getSharedInstance().collect( + self.localProps = self.sdrSource.getProps().collect( "audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", - "csdr_print_bufsizes", "csdr_through", "iq_server_port", "digimodes_enable", "samp_rate" - ) + "csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate" + ).defaults(PropertyManager.getSharedInstance()) self.dsp = csdr.dsp() #dsp_initialized=False @@ -175,7 +219,7 @@ class DspManager(object): self.dsp.set_bpf(-4000,4000) self.localProps.getProperty("digimodes_fft_size").wire(self.dsp.set_secondary_fft_size) - self.dsp.nc_port = self.localProps["iq_server_port"] + self.dsp.nc_port = self.sdrSource.getPort() self.dsp.csdr_dynamic_bufsize = self.localProps["csdr_dynamic_bufsize"] self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"] self.dsp.csdr_through = self.localProps["csdr_through"] @@ -322,7 +366,10 @@ class CpuUsageThread(threading.Thread): self.clients.append(c) def remove_client(self, c): - self.clients.remove(c) + try: + self.clients.remove(c) + except ValueError: + pass if not self.clients: self.shutdown() diff --git a/server.py b/server.py index bc182dc..5d2ece6 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,7 @@ from http.server import HTTPServer from owrx.http import RequestHandler from owrx.config import PropertyManager, FeatureDetector, RequirementMissingException -from owrx.source import RtlNmuxSource +from owrx.source import SdrService from socketserver import ThreadingMixIn class ThreadedHttpServer(ThreadingMixIn, HTTPServer): @@ -19,7 +19,7 @@ def main(): pm = PropertyManager.getSharedInstance() for name, value in cfg.__dict__.items(): if (name.startswith("__")): continue - pm.getProperty(name).setValue(value) + pm[name] = value featureDetector = FeatureDetector() if not featureDetector.is_available("core"): @@ -28,9 +28,6 @@ def main(): print(", ".join(featureDetector.get_requirements("core"))) return - if (pm.getPropertyValue("start_rtl_thread")): - RtlNmuxSource().setup() - server = ThreadedHttpServer(('0.0.0.0', pm.getPropertyValue("web_port")), RequestHandler) server.serve_forever()