From c3411b88566d99693657529aaa5c945f11bdfc1a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 30 Jun 2019 15:57:32 +0200 Subject: [PATCH 001/118] update readme with recent stuff --- README.md | 81 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d308c36..d505a79 100644 --- a/README.md +++ b/README.md @@ -9,38 +9,28 @@ OpenWebRX is a multi-user SDR receiver software with a web interface. It has the following features: -- csdr based demodulators (AM/FM/SSB/CW/BPSK31), +- [csdr](https://github.com/simonyiszk/csdr) based demodulators (AM/FM/SSB/CW/BPSK31), - filter passband can be set from GUI, -- waterfall display can be shifted back in time, -- it extensively uses HTML5 features like WebSocket, Web Audio API, and <canvas>, -- it works in Google Chrome, Chromium (above version 37) and Mozilla Firefox (above version 28), -- currently supports RTL-SDR, HackRF, SDRplay, AirSpy and many other devices, see the OpenWebRX Wiki, -- it has a 3D waterfall display: +- it extensively uses HTML5 features like WebSocket, Web Audio API, and Canvas +- it works in Google Chrome, Chromium and Mozilla Firefox +- currently supports RTL-SDR, HackRF, SDRplay, AirSpy +- Multiple SDR devices can be used simultaneously +- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF) +- [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN) -![OpenWebRX 3D waterfall](http://blog.sdr.hu/images/openwebrx/screenshot-3d.gif) +**News (2019-06-30 by DD5JFK)** +- I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near future. Please check this place for updates. +- My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official version. +- I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there! +- This version sports the following new and amazing features: + - Support of multiple SDR devices simultaneously + - Support for multiple profiles per SDR that allow the user to listen to different frequencies + - Support for digital voice decoding + - Feature detection that will disable functionality when dependencies are not available (if you're missing the digital buttons, this is probably why) +- Raspbian SD Card Images and Docker builds available (see below) +- I am currently working on the feature set for a stable release, but you are more than welcome to test development versions! -**News (2015-08-18)** -- My BSc. thesis written on OpenWebRX is available here. -- Several bugs were fixed to improve reliability and stability. -- OpenWebRX now supports compression of audio and waterfall stream, so the required network uplink bandwidth has been decreased from 2 Mbit/s to about 200 kbit/s per client! (Measured with the default settings. It is also dependent on `fft_size`.) -- OpenWebRX now uses sdr.js (*libcsdr* compiled to JavaScript) for some client-side DSP tasks. -- Receivers can now be listed on SDR.hu. -- License for OpenWebRX is now Affero GPL v3. - -**News (2016-02-14)** -- The DDC in *csdr* has been manually optimized for ARM NEON, so it runs around 3 times faster on the Raspberry Pi 2 than before. -- Also we use *ncat* instead of *rtl_mus*, and it is 3 times faster in some cases. -- OpenWebRX now supports URLs like: `http://localhost:8073/#freq=145555000,mod=usb` -- UI improvements were made, thanks to John Seamons and Gnoxter. - -**News (2017-04-04)** -- *ncat* has been replaced with a custom implementation called *nmux* due to a bug that caused regular crashes on some machines. The *nmux* tool is part of the *csdr* package. -- Most consumer SDR devices are supported via rx_tools, see the OpenWebRX Wiki on that. - -**News (2017-07-12)** -- OpenWebRX now has a BPSK31 demodulator and a 3D waterfall display. - -> When upgrading OpenWebRX, please make sure that you also upgrade *csdr*! +> When upgrading OpenWebRX, please make sure that you also upgrade *csdr* and *digiham*! ## OpenWebRX servers on SDR.hu @@ -50,12 +40,35 @@ It has the following features: ## Setup -OpenWebRX currently requires Linux and python 2.7 to run. +### Raspberry Pi SD Card Images + +Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-06-21-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+. + +This is based off the Raspbian Lite distribution, so [their installation instructions](https://www.raspberrypi.org/documentation/installation/installing-images/) apply. + +Please note: I have not updated this to include the Raspberry Pi 4 yet. (It seems to be impossible to build Rasbpian Buster images on x86 hardware right now. Stay tuned!) + +Once you have booted a Raspberry with the SD Card, it will appear in your network with the hostname "openwebrx", which should make it available as http://openwebrx:8073/ on most networks. This may vary depending on your specific setup. + +For Digital voice, the minimum requirement right now seems to be a Rasbperry Pi 3B+. I would like to work on optimizing this for lower specs, but at this point I am not sure how much can be done. + +### Docker Images + +For those familiar with docker, I am providing [recent builds and Releases for both x86 and arm processors on the Docker hub](https://hub.docker.com/r/jketterl/openwebrx). You can find a short introduction there. + +### Manual Installation + +OpenWebRX currently requires Linux and python 3 to run. First you will need to install the dependencies: -- libcsdr -- rtl-sdr +- [csdr](https://github.com/simonyiszk/csdr) +- [rtl-sdr](http://sdr.osmocom.org/trac/wiki/rtl-sdr) + +Optional Dependencies if you want to be able to listen do digital voice: + +- [digiham](https://github.com/jketterl/digiham) +- [dsd](https://github.com/f4exb/dsdcc) After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server: @@ -65,7 +78,7 @@ You can now open the GUI at http://localhost:807 Please note that the server is also listening on the following ports (on localhost only): -- port 4951 for the multi-user I/Q server. +- ports 4950 to 4960 for the multi-user I/Q servers. Now the next step is to customize the parameters of your server in `config_webrx.py`. @@ -86,8 +99,6 @@ If you have any problems installing OpenWebRX, you should check out the summary). From 0e205ec1d98d8e2e7316560c3eaba5c2d9c3ffd9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 11:16:05 +0200 Subject: [PATCH 002/118] remove unused html files --- htdocs/inactive.html | 85 --------------------------------------- htdocs/retry.html | 94 ------------------------------------------- htdocs/upgrade.html | 95 -------------------------------------------- 3 files changed, 274 deletions(-) delete mode 100644 htdocs/inactive.html delete mode 100644 htdocs/retry.html delete mode 100644 htdocs/upgrade.html diff --git a/htdocs/inactive.html b/htdocs/inactive.html deleted file mode 100644 index c7214c5..0000000 --- a/htdocs/inactive.html +++ /dev/null @@ -1,85 +0,0 @@ - - -OpenWebRX - - - - -
- -
- Sorry, the receiver is inactive due to internal error. -
-
- - - diff --git a/htdocs/retry.html b/htdocs/retry.html deleted file mode 100644 index 466c7ee..0000000 --- a/htdocs/retry.html +++ /dev/null @@ -1,94 +0,0 @@ - - -OpenWebRX - - - - - - -
- -
- There are no client slots left on this server. -
- Please wait until a client disconnects.
We will try to reconnect in 30 seconds... -
-
-
- - - diff --git a/htdocs/upgrade.html b/htdocs/upgrade.html deleted file mode 100644 index 09b5aab..0000000 --- a/htdocs/upgrade.html +++ /dev/null @@ -1,95 +0,0 @@ - - -OpenWebRX - - - - - - -
- -
- Only the latest Google Chrome browser is supported at the moment.
- Please download and install Google Chrome.
-
- Alternatively, you may proceed to OpenWebRX, but it's not supposed to work as expected.
- Click here if you still want to try OpenWebRX. -
-
-
- - - From f283a1ad68eb9e2faa05f13abe6c449cf9da15f4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 11:47:07 +0200 Subject: [PATCH 003/118] prepare for different types of connections --- htdocs/openwebrx.js | 2 +- owrx/connection.py | 20 ++++++++++++++------ owrx/controllers.py | 1 - 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 27cc8fc..5297549 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1431,7 +1431,7 @@ function waterfall_dequeue() function on_ws_opened() { - ws.send("SERVER DE CLIENT openwebrx.js"); + ws.send("SERVER DE CLIENT client=openwebrx.js type=receiver"); divlog("WebSocket opened to "+ws_url); } diff --git a/owrx/connection.py b/owrx/connection.py index 67ee96b..a6d268a 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -1,12 +1,13 @@ from owrx.config import PropertyManager from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry from owrx.feature import FeatureDetector +from owrx.version import openwebrx_version import json import logging logger = logging.getLogger(__name__) -class OpenWebRxClient(object): +class OpenWebRxReceiverClient(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", @@ -53,10 +54,10 @@ class OpenWebRxClient(object): self.sdr = next # send initial config - configProps = self.sdr.getProps().collect(*OpenWebRxClient.config_keys).defaults(PropertyManager.getSharedInstance()) + configProps = self.sdr.getProps().collect(*OpenWebRxReceiverClient.config_keys).defaults(PropertyManager.getSharedInstance()) def sendConfig(key, value): - config = dict((key, configProps[key]) for key in OpenWebRxClient.config_keys) + config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys) # TODO mathematical properties? hmmmm config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] self.write_config(config) @@ -143,11 +144,18 @@ class WebSocketMessageHandler(object): def handleTextMessage(self, conn, message): if (message[:16] == "SERVER DE CLIENT"): - # maybe put some more info in there? nothing to store yet. - self.handshake = "completed" + meta = message[17:].split(" ") + self.handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)} + + conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version = openwebrx_version)) logger.debug("client connection intitialized") - self.client = OpenWebRxClient(conn) + if "type" in self.handshake: + if self.handshake["type"] == "receiver": + self.client = OpenWebRxReceiverClient(conn) + # backwards compatibility + else: + self.client = OpenWebRxReceiverClient(conn) return diff --git a/owrx/controllers.py b/owrx/controllers.py index 774ba9b..75c2758 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -82,6 +82,5 @@ class IndexController(AssetsController): class WebSocketController(Controller): def handle_request(self): conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) - conn.send("CLIENT DE SERVER openwebrx.py") # enter read loop conn.read_loop() From a4a306374d4e904217cb0196bad96ddf38c6b064 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 16:49:39 +0200 Subject: [PATCH 004/118] add some map basics --- htdocs/map.html | 12 ++++++++++++ htdocs/map.js | 30 ++++++++++++++++++++++++++++++ owrx/controllers.py | 4 ++++ owrx/http.py | 5 +++-- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 htdocs/map.html create mode 100644 htdocs/map.js diff --git a/htdocs/map.html b/htdocs/map.html new file mode 100644 index 0000000..272207d --- /dev/null +++ b/htdocs/map.html @@ -0,0 +1,12 @@ + + + + OpenWebRX | Open Source SDR Web App for Everyone! + + + + + + + + diff --git a/htdocs/map.js b/htdocs/map.js new file mode 100644 index 0000000..c47935a --- /dev/null +++ b/htdocs/map.js @@ -0,0 +1,30 @@ +(function(){ + var protocol = 'ws'; + if (window.location.toString().startsWith('https://')) { + protocol = 'wss'; + } + + var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/"; + if (!("WebSocket" in window)) return; + + var ws = new WebSocket(ws_url); + ws.onopen = function(){ + console.info("onopen"); + ws.send("SERVER DE CLIENT client=map.js type=map"); + }; + ws.onmessage = function(){ + console.info("onmessage"); + }; + ws.onclose = function(){ + console.info("onclose"); + }; + + window.onbeforeunload = function() { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript + ws.onclose = function () {}; + ws.close(); + }; + ws.onerror = function(){ + console.info("onerror"); + }; + +})(); \ No newline at end of file diff --git a/owrx/controllers.py b/owrx/controllers.py index 75c2758..883a403 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -79,6 +79,10 @@ class IndexController(AssetsController): def handle_request(self): self.serve_file("index.html", content_type = "text/html") +class MapController(AssetsController): + def handle_request(self): + self.serve_file("map.html", content_type = "text/html") + class WebSocketController(Controller): def handle_request(self): conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) diff --git a/owrx/http.py b/owrx/http.py index ca7d357..b449fab 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,4 +1,4 @@ -from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController +from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController, MapController from http.server import BaseHTTPRequestHandler import re @@ -20,7 +20,8 @@ class Router(object): {"route": "/ws/", "controller": WebSocketController}, {"regex": "(/favicon.ico)", "controller": AssetsController}, # backwards compatibility for the sdr.hu portal - {"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController} + {"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController}, + {"route": "/map", "controller": MapController} ] def find_controller(self, path): for m in Router.mappings: From 893f69ad18ff7f90aff39a72f11a224f90f5d695 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 30 Jun 2019 16:24:56 +0200 Subject: [PATCH 005/118] chain as list as a first step to better flexibility --- csdr.py | 106 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/csdr.py b/csdr.py index 0ef0250..31ee80a 100755 --- a/csdr.py +++ b/csdr.py @@ -76,62 +76,103 @@ class dsp(object): self.output = output def chain(self,which): - chain ="nc -v 127.0.0.1 {nc_port} | " - if self.csdr_dynamic_bufsize: chain += "csdr setbuf {start_bufsize} | " - if self.csdr_through: chain +="csdr through | " + chain = ["nc -v 127.0.0.1 {nc_port}"] + if self.csdr_dynamic_bufsize: chain += ["csdr setbuf {start_bufsize}"] + if self.csdr_through: chain += ["csdr through"] if which == "fft": - chain += "csdr fft_cc {fft_size} {fft_block_size} | " + \ - ("csdr logpower_cf -70 | " if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages} | ") + \ + chain += [ + "csdr fft_cc {fft_size} {fft_block_size}", + "csdr logpower_cf -70" if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}", "csdr fft_exchange_sides_ff {fft_size}" - if self.fft_compression=="adpcm": - chain += " | csdr compress_fft_adpcm_f_u8 {fft_size}" + ] + if self.fft_compression == "adpcm": + chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"] return chain - chain += "csdr shift_addition_cc --fifo {shift_pipe} | " - chain += "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING | " - chain += "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING | csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every} | " + chain += [ + "csdr shift_addition_cc --fifo {shift_pipe}", + "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING", + "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING", + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}" + ] if self.secondary_demodulator: - chain += "csdr tee {iqtee_pipe} | " - chain += "csdr tee {iqtee2_pipe} | " + chain += [ + "csdr tee {iqtee_pipe}", + "csdr tee {iqtee2_pipe}" + ] # safe some cpu cycles... no need to decimate if decimation factor is 1 - last_decimation_block = "csdr fractional_decimator_ff {last_decimation} | " if self.last_decimation != 1.0 else "" + last_decimation_block = ["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else [] if which == "nfm": - chain += "csdr fmdemod_quadri_cf | csdr limit_ff | " + chain += [ + "csdr fmdemod_quadri_cf", + "csdr limit_ff" + ] chain += last_decimation_block - chain += "csdr deemphasis_nfm_ff {output_rate} | csdr convert_f_s16" + chain += [ + "csdr deemphasis_nfm_ff {output_rate}", + "csdr convert_f_s16" + ] elif self.isDigitalVoice(which): - chain += "csdr fmdemod_quadri_cf | dc_block | " + chain += [ + "csdr fmdemod_quadri_cf", + "dc_block " + ] chain += last_decimation_block # dsd modes if which in [ "dstar", "nxdn" ]: - chain += "csdr limit_ff | csdr convert_f_s16 | " + chain += [ + "csdr limit_ff", + "csdr convert_f_s16" + ] if which == "dstar": - chain += "dsd -fd" + chain += ["dsd -fd -i - -o - -u {unvoiced_quality} -g -1 "] elif which == "nxdn": - chain += "dsd -fi" - chain += " -i - -o - -u {unvoiced_quality} -g -1 | CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f | " + chain += ["dsd -fi -i - -o - -u {unvoiced_quality} -g -1 "] + chain += ["CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f"] max_gain = 5 # digiham modes else: - chain += "rrc_filter | gfsk_demodulator | " + chain += [ + "rrc_filter", + "gfsk_demodulator" + ] if which == "dmr": - chain += "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe} | mbe_synthesizer -f -u {unvoiced_quality} | " + chain += [ + "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}", + "mbe_synthesizer -f -u {unvoiced_quality}" + ] elif which == "ysf": - chain += "ysf_decoder --fifo {meta_pipe} | mbe_synthesizer -y -f -u {unvoiced_quality} | " + chain += [ + "ysf_decoder --fifo {meta_pipe}", + "mbe_synthesizer -y -f -u {unvoiced_quality}" + ] max_gain = 0.0005 - chain += "digitalvoice_filter -f | " - chain += "CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain} | ".format(max_gain=max_gain) - chain += "sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + chain += [ + "digitalvoice_filter -f", + "CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain}".format(max_gain=max_gain), + "sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] elif which == "am": - chain += "csdr amdemod_cf | csdr fastdcblock_ff | " + chain += [ + "csdr amdemod_cf", + "csdr fastdcblock_ff" + ] chain += last_decimation_block - chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16" + chain += [ + "csdr agc_ff", + "csdr limit_ff", + "csdr convert_f_s16" + ] elif which == "ssb": - chain += "csdr realpart_cf | " + chain += ["csdr realpart_cf"] chain += last_decimation_block - chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16" + chain += [ + "csdr agc_ff", + "csdr limit_ff", + "csdr convert_f_s16" + ] if self.audio_compression=="adpcm": - chain += " | csdr encode_ima_adpcm_i16_u8" + chain += ["csdr encode_ima_adpcm_i16_u8"] return chain def secondary_chain(self, which): @@ -402,7 +443,8 @@ class dsp(object): return self.running = True - command_base=self.chain(self.demodulator) + command_base = " | ".join(self.chain(self.demodulator)) + logger.debug(command_base) #create control pipes for csdr self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self)) From 2324a2c837fdc12bfe1190fef2682eb6b6881acc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 19:49:58 +0200 Subject: [PATCH 006/118] add google maps --- htdocs/map.css | 4 +++ htdocs/map.html | 2 +- htdocs/map.js | 33 +++++++++++++++++++++--- owrx/connection.py | 61 +++++++++++++++++++++++++++++++++++---------- owrx/controllers.py | 1 + 5 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 htdocs/map.css diff --git a/htdocs/map.css b/htdocs/map.css new file mode 100644 index 0000000..4b5fb00 --- /dev/null +++ b/htdocs/map.css @@ -0,0 +1,4 @@ +html, body { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/htdocs/map.html b/htdocs/map.html index 272207d..fb8241e 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -4,7 +4,7 @@ OpenWebRX | Open Source SDR Web App for Everyone! - + diff --git a/htdocs/map.js b/htdocs/map.js index c47935a..08e8809 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -9,11 +9,38 @@ var ws = new WebSocket(ws_url); ws.onopen = function(){ - console.info("onopen"); ws.send("SERVER DE CLIENT client=map.js type=map"); }; - ws.onmessage = function(){ - console.info("onmessage"); + + ws.onmessage = function(e){ + if (typeof e.data != 'string') { + console.error("unsupported binary data on websocket; ignoring"); + return + } + if (e.data.substr(0, 16) == "CLIENT DE SERVER") { + console.log("Server acknowledged WebSocket connection."); + return + } + try { + json = JSON.parse(e.data); + switch (json.type) { + case "config": + var config = json.value; + $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ + var map = new google.maps.Map($('body')[0], { + center: { + lat: config.receiver_gps[0], + lng: config.receiver_gps[1] + }, + zoom: 8 + }); + }) + break + } + } catch (e) { + // don't lose exception + console.error(e); + } }; ws.onclose = function(){ console.info("onclose"); diff --git a/owrx/connection.py b/owrx/connection.py index a6d268a..95fcf76 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -7,14 +7,33 @@ import json import logging logger = logging.getLogger(__name__) -class OpenWebRxReceiverClient(object): +class Client(object): + def __init__(self, conn): + self.conn = conn + + def protected_send(self, data): + try: + self.conn.send(data) + # these exception happen when the socket is closed + except OSError: + self.close() + except ValueError: + self.close() + + def close(self): + self.conn.close() + logger.debug("connection closed") + + +class OpenWebRxReceiverClient(Client): 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", "mathbox_waterfall_colors", "mathbox_waterfall_history_length", "mathbox_waterfall_frequency_resolution"] + def __init__(self, conn): - self.conn = conn + super().__init__(conn) self.dsp = None self.sdr = None @@ -79,8 +98,7 @@ class OpenWebRxReceiverClient(object): if self.configSub is not None: self.configSub.cancel() self.configSub = None - self.conn.close() - logger.debug("connection closed") + super().close() def stopDsp(self): if self.dsp is not None: @@ -100,42 +118,57 @@ class OpenWebRxReceiverClient(object): for key, value in params.items(): self.dsp.setProperty(key, value) - def protected_send(self, data): - try: - self.conn.send(data) - # these exception happen when the socket is closed - except OSError: - self.close() - except ValueError: - self.close() - def write_spectrum_data(self, data): self.protected_send(bytes([0x01]) + data) + def write_dsp_data(self, data): self.protected_send(bytes([0x02]) + data) + def write_s_meter_level(self, level): self.protected_send({"type":"smeter","value":level}) + def write_cpu_usage(self, usage): self.protected_send({"type":"cpuusage","value":usage}) + def write_clients(self, clients): self.protected_send({"type":"clients","value":clients}) + def write_secondary_fft(self, data): self.protected_send(bytes([0x03]) + data) + def write_secondary_demod(self, data): self.protected_send(bytes([0x04]) + data) + def write_secondary_dsp_config(self, cfg): self.protected_send({"type":"secondary_config", "value":cfg}) + def write_config(self, cfg): self.protected_send({"type":"config","value":cfg}) + def write_receiver_details(self, details): self.protected_send({"type":"receiver_details","value":details}) + def write_profiles(self, profiles): self.protected_send({"type":"profiles","value":profiles}) + def write_features(self, features): self.protected_send({"type":"features","value":features}) + def write_metadata(self, metadata): self.protected_send({"type":"metadata","value":metadata}) + +class MapConnection(Client): + def __init__(self, conn): + super().__init__(conn) + + pm = PropertyManager.getSharedInstance() + self.write_config(pm.collect("google_maps_api_key", "receiver_gps").__dict__()) + + def write_config(self, cfg): + self.protected_send({"type":"config","value":cfg}) + + class WebSocketMessageHandler(object): def __init__(self): self.handshake = None @@ -153,6 +186,8 @@ class WebSocketMessageHandler(object): if "type" in self.handshake: if self.handshake["type"] == "receiver": self.client = OpenWebRxReceiverClient(conn) + if self.handshake["type"] == "map": + self.client = MapConnection(conn) # backwards compatibility else: self.client = OpenWebRxReceiverClient(conn) diff --git a/owrx/controllers.py b/owrx/controllers.py index 883a403..5a915fa 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -81,6 +81,7 @@ class IndexController(AssetsController): class MapController(AssetsController): def handle_request(self): + #TODO check if we have a google maps api key first? self.serve_file("map.html", content_type = "text/html") class WebSocketController(Controller): From 272caa71007ee0f31c89dc8285aec879c1055e65 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 19:51:31 +0200 Subject: [PATCH 007/118] rename title --- htdocs/map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/map.html b/htdocs/map.html index fb8241e..ee8908c 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -1,7 +1,7 @@ - OpenWebRX | Open Source SDR Web App for Everyone! + OpenWebRX Map From 3b2b51f07c612283b2a8373bc9e4de5c10a6af0f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 21:20:53 +0200 Subject: [PATCH 008/118] display locations parsed from ysf on map --- htdocs/map.js | 33 +++++++++++++++++++++++- owrx/connection.py | 10 ++++++++ owrx/map.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++ owrx/meta.py | 18 +++++++++++++- 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 owrx/map.py diff --git a/htdocs/map.js b/htdocs/map.js index 08e8809..9dfb24d 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -12,6 +12,33 @@ ws.send("SERVER DE CLIENT client=map.js type=map"); }; + var map; + var markers = {}; + var updateQueue = []; + + var processUpdates = function(updates) { + if (!map) { + updateQueue = updateQueue.concat(updates); + return; + } + updates.forEach(function(update){ + // TODO maidenhead locator implementation + if (update.location.type != 'latlon') return; + var pos = new google.maps.LatLng(update.location.lat, update.location.lon) + if (markers[update.callsign]) { + console.info("updating"); + markers[update.callsign].setPosition(pos); + } else { + console.info("initializing"); + markers[update.callsign] = new google.maps.Marker({ + position: pos, + map: map, + title: update.callsign + }); + } + }); + } + ws.onmessage = function(e){ if (typeof e.data != 'string') { console.error("unsupported binary data on websocket; ignoring"); @@ -27,15 +54,19 @@ case "config": var config = json.value; $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ - var map = new google.maps.Map($('body')[0], { + map = new google.maps.Map($('body')[0], { center: { lat: config.receiver_gps[0], lng: config.receiver_gps[1] }, zoom: 8 }); + processUpdates(updateQueue); }) break + case "update": + processUpdates(json.value); + break } } catch (e) { // don't lose exception diff --git a/owrx/connection.py b/owrx/connection.py index 95fcf76..3286975 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -3,10 +3,12 @@ from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry from owrx.feature import FeatureDetector from owrx.version import openwebrx_version import json +from owrx.map import Map import logging logger = logging.getLogger(__name__) + class Client(object): def __init__(self, conn): self.conn = conn @@ -165,9 +167,17 @@ class MapConnection(Client): pm = PropertyManager.getSharedInstance() self.write_config(pm.collect("google_maps_api_key", "receiver_gps").__dict__()) + Map.getSharedInstance().addClient(self) + + def close(self): + Map.getSharedInstance().removeClient(self) + super().close() + def write_config(self, cfg): self.protected_send({"type":"config","value":cfg}) + def write_update(self, update): + self.protected_send({"type":"update","value":update}) class WebSocketMessageHandler(object): def __init__(self): diff --git a/owrx/map.py b/owrx/map.py new file mode 100644 index 0000000..a799c92 --- /dev/null +++ b/owrx/map.py @@ -0,0 +1,62 @@ +from datetime import datetime + + +class Location(object): + def __dict__(self): + return {} + + +class Map(object): + sharedInstance = None + @staticmethod + def getSharedInstance(): + if Map.sharedInstance is None: + Map.sharedInstance = Map() + return Map.sharedInstance + + def __init__(self): + self.clients = [] + self.positions = {} + super().__init__() + + def broadcast(self, update): + for c in self.clients: + c.write_update(update) + + def addClient(self, client): + self.clients.append(client) + client.write_update([{"callsign": callsign, "location": record["loc"].__dict__()} for (callsign, record) in self.positions.items()]) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + + def updateLocation(self, callsign, loc: Location): + self.positions[callsign] = {"loc": loc, "updated": datetime.now()} + self.broadcast([{"callsign": callsign, "location": loc.__dict__()}]) + + +class LatLngLocation(Location): + def __init__(self, lat: float, lon: float): + self.lat = lat + self.lon = lon + + def __dict__(self): + return { + "type":"latlon", + "lat":self.lat, + "lon":self.lon + } + + +class LocatorLocation(Location): + def __init__(self, locator: str): + self.locator = locator + + def __dict__(self): + return { + "type":"locator", + "locator":self.locator + } diff --git a/owrx/meta.py b/owrx/meta.py index ec4966a..ad3f0d3 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -4,6 +4,7 @@ import json from datetime import datetime, timedelta import logging import threading +from owrx.map import Map, LatLngLocation logger = logging.getLogger(__name__) @@ -14,18 +15,22 @@ class DmrCache(object): if DmrCache.sharedInstance is None: DmrCache.sharedInstance = DmrCache() return DmrCache.sharedInstance + def __init__(self): self.cache = {} self.cacheTimeout = timedelta(seconds = 86400) + def isValid(self, key): if not key in self.cache: return False entry = self.cache[key] return entry["timestamp"] + self.cacheTimeout > datetime.now() + def put(self, key, value): self.cache[key] = { "timestamp": datetime.now(), "data": value } + def get(self, key): if not self.isValid(key): return None return self.cache[key]["data"] @@ -34,6 +39,7 @@ class DmrCache(object): class DmrMetaEnricher(object): def __init__(self): self.threads = {} + def downloadRadioIdData(self, id): cache = DmrCache.getSharedInstance() try: @@ -44,6 +50,7 @@ class DmrMetaEnricher(object): except json.JSONDecodeError: cache.put(id, None) del self.threads[id] + def enrich(self, meta): if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: return None if not "source" in meta: return None @@ -60,9 +67,18 @@ class DmrMetaEnricher(object): return None +class YsfMetaEnricher(object): + def enrich(self, meta): + if "source" in meta and "lat" in meta and "lon" in meta: + # TODO parsing the float values should probably happen earlier + Map.getSharedInstance().updateLocation(meta["source"], LatLngLocation(float(meta["lat"]), float(meta["lon"]))) + return None + + class MetaParser(object): enrichers = { - "DMR": DmrMetaEnricher() + "DMR": DmrMetaEnricher(), + "YSF": YsfMetaEnricher() } def __init__(self, handler): From f5f23e6fbca52a35a84adc0d4bff8628f5899a6d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 1 Jul 2019 21:21:26 +0200 Subject: [PATCH 009/118] remove debugging --- htdocs/map.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 9dfb24d..1751089 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -26,10 +26,8 @@ if (update.location.type != 'latlon') return; var pos = new google.maps.LatLng(update.location.lat, update.location.lon) if (markers[update.callsign]) { - console.info("updating"); markers[update.callsign].setPosition(pos); } else { - console.info("initializing"); markers[update.callsign] = new google.maps.Marker({ position: pos, map: map, From e61c0dcc12d218f944b3fde3727f22f5e888ef27 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Jul 2019 19:30:24 +0200 Subject: [PATCH 010/118] add some basic framework for the featurereport --- htdocs/features.html | 6 ++++++ htdocs/features.js | 5 +++++ owrx/controllers.py | 20 +++++++++++++------- owrx/feature.py | 5 +++++ owrx/http.py | 6 ++++-- 5 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 htdocs/features.html create mode 100644 htdocs/features.js diff --git a/htdocs/features.html b/htdocs/features.html new file mode 100644 index 0000000..f4ed3b4 --- /dev/null +++ b/htdocs/features.html @@ -0,0 +1,6 @@ + + OpenWebRX Feature report + + + + \ No newline at end of file diff --git a/htdocs/features.js b/htdocs/features.js new file mode 100644 index 0000000..cb96f09 --- /dev/null +++ b/htdocs/features.js @@ -0,0 +1,5 @@ +$(function(){ + $.ajax('/api/features').done(function(data){ + $('body').html(JSON.stringify(data)); + }); +}); \ No newline at end of file diff --git a/owrx/controllers.py b/owrx/controllers.py index 5a915fa..c4f917a 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -1,11 +1,13 @@ import os import mimetypes +import json from datetime import datetime from owrx.websocket import WebSocketConnection from owrx.config import PropertyManager from owrx.source import ClientRegistry from owrx.connection import WebSocketMessageHandler from owrx.version import openwebrx_version +from owrx.feature import FeatureDetector import logging logger = logging.getLogger(__name__) @@ -26,12 +28,7 @@ class Controller(object): if (type(content) == str): content = content.encode() self.handler.wfile.write(content) - def render_template(self, template, **variables): - f = open('htdocs/' + template) - data = f.read() - f.close() - self.send_response(data) class StatusController(Controller): def handle_request(self): @@ -77,12 +74,21 @@ class AssetsController(Controller): class IndexController(AssetsController): def handle_request(self): - self.serve_file("index.html", content_type = "text/html") + self.serve_file("index.html") class MapController(AssetsController): def handle_request(self): #TODO check if we have a google maps api key first? - self.serve_file("map.html", content_type = "text/html") + self.serve_file("map.html") + +class FeatureController(AssetsController): + def handle_request(self): + self.serve_file("features.html") + +class ApiController(Controller): + def handle_request(self): + data = json.dumps(FeatureDetector().feature_report()) + self.send_response(data, content_type = "application/json") class WebSocketController(Controller): def handle_request(self): diff --git a/owrx/feature.py b/owrx/feature.py index 38b8e27..8a45b10 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -26,6 +26,11 @@ class FeatureDetector(object): def feature_availability(self): return {name: self.is_available(name) for name in FeatureDetector.features} + def feature_report(self): + def feature_details(name): + return self.get_requirements(name) + return {name: feature_details(name) for name in FeatureDetector.features} + def is_available(self, feature): return self.has_requirements(self.get_requirements(feature)) diff --git a/owrx/http.py b/owrx/http.py index b449fab..7e1f578 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,4 +1,4 @@ -from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController, MapController +from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController, MapController, FeatureController, ApiController from http.server import BaseHTTPRequestHandler import re @@ -21,7 +21,9 @@ class Router(object): {"regex": "(/favicon.ico)", "controller": AssetsController}, # backwards compatibility for the sdr.hu portal {"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController}, - {"route": "/map", "controller": MapController} + {"route": "/map", "controller": MapController}, + {"route": "/features", "controller": FeatureController}, + {"route": "/api/features", "controller": ApiController} ] def find_controller(self, path): for m in Router.mappings: From 823a4a35f0ffea95e00afa1230956050593a9840 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Jul 2019 22:31:46 +0200 Subject: [PATCH 011/118] implement feature and requirement details --- htdocs/features.html | 11 ++++ htdocs/features.js | 21 +++++++- owrx/feature.py | 122 +++++++++++++++++++++++++++++++++---------- 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/htdocs/features.html b/htdocs/features.html index f4ed3b4..bcba73c 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -1,6 +1,17 @@ OpenWebRX Feature report + + +

OpenWebRX Feature Report

+ + + + + + + +
FeatureRequirementDescriptionAvailable
\ No newline at end of file diff --git a/htdocs/features.js b/htdocs/features.js index cb96f09..e534bcb 100644 --- a/htdocs/features.js +++ b/htdocs/features.js @@ -1,5 +1,24 @@ $(function(){ + var converter = new showdown.Converter(); $.ajax('/api/features').done(function(data){ - $('body').html(JSON.stringify(data)); + $table = $('table.features'); + $.each(data, function(name, details) { + requirements = $.map(details.requirements, function(r, name){ + return '' + + '' + + '' + name + '' + + '' + converter.makeHtml(r.description) + '' + + '' + (r.available ? 'YES' : 'NO') + '' + + ''; + }); + $table.append( + '' + + '' + name + '' + + '' + converter.makeHtml(details.description) + '' + + '' + (details.available ? 'YES' : 'NO') + '' + + '' + + requirements.join("") + ); + }) }); }); \ No newline at end of file diff --git a/owrx/feature.py b/owrx/feature.py index 8a45b10..d8bcdca 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -4,6 +4,7 @@ from functools import reduce from operator import and_ import re from distutils.version import LooseVersion +import inspect import logging logger = logging.getLogger(__name__) @@ -12,6 +13,7 @@ logger = logging.getLogger(__name__) class UnknownFeatureException(Exception): pass + class FeatureDetector(object): features = { "core": [ "csdr", "nmux", "nc" ], @@ -27,8 +29,22 @@ class FeatureDetector(object): return {name: self.is_available(name) for name in FeatureDetector.features} def feature_report(self): + def requirement_details(name): + available = self.has_requirement(name) + return { + "available": available, + # as of now, features are always enabled as soon as they are available. this may change in the future. + "enabled": available, + "description": self.get_requirement_description(name) + } + def feature_details(name): - return self.get_requirements(name) + return { + "description": "", + "available": self.is_available(name), + "requirements": {name: requirement_details(name) for name in self.get_requirements(name)} + } + return {name: feature_details(name) for name in FeatureDetector.features} def is_available(self, feature): @@ -43,45 +59,84 @@ class FeatureDetector(object): def has_requirements(self, requirements): passed = True for requirement in requirements: - methodname = "has_" + requirement - if hasattr(self, methodname) and callable(getattr(self, methodname)): - passed = passed and getattr(self, methodname)() - else: - logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) + passed = passed and self.has_requirement(requirement) return passed + def _get_requirement_method(self, requirement): + methodname = "has_" + requirement + if hasattr(self, methodname) and callable(getattr(self, methodname)): + return getattr(self, methodname) + return None + + def has_requirement(self, requirement): + method = self._get_requirement_method(requirement) + if method is not None: + return method() + else: + logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) + return False + + def get_requirement_description(self, requirement): + return inspect.getdoc(self._get_requirement_method(requirement)) + def command_is_runnable(self, command): return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512 def has_csdr(self): + """ + OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project + page on github](https://github.com/simonyiszk/csdr) for further details and installation instructions. + """ return self.command_is_runnable("csdr") def has_nmux(self): + """ + Nmux is another tool provided by the csdr project. It is used for internal multiplexing of the IQ data streams. + If you're missing nmux even though you have csdr installed, please update your csdr version. + """ return self.command_is_runnable("nmux --help") def has_nc(self): + """ + Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended + for better performance) or GNU netcat packages. Please check your distribution package manager for options. + """ return self.command_is_runnable('nc --help') def has_rtl_sdr(self): + """ + The rtl-sdr command is required to read I/Q data from an RTL SDR USB-Stick. It is available in most + distribution package managers. + """ return self.command_is_runnable("rtl_sdr --help") def has_rx_tools(self): + """ + The rx_tools package can be used to interface with SDR devices compatible with SoapySDR. It is currently used + to connect to SDRPlay devices. Please check the following pages for more details: + + * [rx_tools GitHub page](https://github.com/rxseger/rx_tools) + * [SoapySDR Project wiki](https://github.com/pothosware/SoapySDR/wiki) + * [SDRPlay homepage](https://www.sdrplay.com/) + """ return self.command_is_runnable("rx_sdr --help") - """ - To use a HackRF, compile the HackRF host tools from its "stdout" branch: - git clone https://github.com/mossmann/hackrf/ - cd hackrf - git fetch - git checkout origin/stdout - cd host - mkdir build - cd build - cmake .. -DINSTALL_UDEV_RULES=ON - make - sudo make install - """ def has_hackrf_transfer(self): + """ + To use a HackRF, compile the HackRF host tools from its "stdout" branch: + ``` + git clone https://github.com/mossmann/hackrf/ + cd hackrf + git fetch + git checkout origin/stdout + cd host + mkdir build + cd build + cmake .. -DINSTALL_UDEV_RULES=ON + make + sudo make install + ``` + """ # TODO i don't have a hackrf, so somebody doublecheck this. # TODO also check if it has the stdout feature return self.command_is_runnable("hackrf_transfer --help") @@ -89,15 +144,15 @@ class FeatureDetector(object): def command_exists(self, command): return os.system("which {0}".format(command)) == 0 - """ - To use DMR and YSF, the digiham package is required. You can find the package and installation instructions here: - https://github.com/jketterl/digiham - - Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. - If you have an older verison of digiham installed, please update it along with openwebrx. - As of now, we require version 0.2 of digiham. - """ def has_digiham(self): + """ + To use digital voice modes, the digiham package is required. You can find the package and installation + instructions [here](https://github.com/jketterl/digiham). + + Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. + If you have an older verison of digiham installed, please update it along with openwebrx. + As of now, we require version 0.2 of digiham. + """ required_version = LooseVersion("0.2") digiham_version_regex = re.compile('^digiham version (.*)$') @@ -118,10 +173,23 @@ class FeatureDetector(object): True) def has_dsd(self): + """ + The digital voice modes NXDN and D-Star can be decoded by the dsd project. Please note that you need the version + modified by F4EXB that provides stdin/stdout support. You can find it [here](https://github.com/f4exb/dsd). + """ return self.command_is_runnable("dsd") def has_sox(self): + """ + The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and + the audio sampling rate requested by the client. + + It is available for most distributions through the respective package manager. + """ return self.command_is_runnable("sox") def has_airspy_rx(self): + """ + In order to use an Airspy Receiver, you need to install the airspy_rx receiver software. + """ return self.command_is_runnable("airspy_rx --help 2> /dev/null") From d0d5dffe7968a9657b8916b16451a9a653b7a3f6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 5 Jul 2019 22:46:43 +0200 Subject: [PATCH 012/118] add some styling --- htdocs/features.css | 4 ++++ htdocs/features.html | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 htdocs/features.css diff --git a/htdocs/features.css b/htdocs/features.css new file mode 100644 index 0000000..cc821b1 --- /dev/null +++ b/htdocs/features.css @@ -0,0 +1,4 @@ +h1 { + text-align: center; + margin: 50px 0; +} \ No newline at end of file diff --git a/htdocs/features.html b/htdocs/features.html index bcba73c..5602567 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -1,17 +1,20 @@ OpenWebRX Feature report + -

OpenWebRX Feature Report

- - - - - - - -
FeatureRequirementDescriptionAvailable
+
+

OpenWebRX Feature Report

+ + + + + + + +
FeatureRequirementDescriptionAvailable
+
\ No newline at end of file From 892c92eb1d4d463faaf9329896f83c747c969be1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 12:41:30 +0200 Subject: [PATCH 013/118] add a link for the map in the top bar --- htdocs/gfx/openwebrx-panel-map.png | Bin 0 -> 3218 bytes htdocs/index.html | 1 + htdocs/openwebrx.css | 5 +++++ 3 files changed, 6 insertions(+) create mode 100644 htdocs/gfx/openwebrx-panel-map.png diff --git a/htdocs/gfx/openwebrx-panel-map.png b/htdocs/gfx/openwebrx-panel-map.png new file mode 100644 index 0000000000000000000000000000000000000000..36cb90e1fe39c49667abaf735aa749995cba5f11 GIT binary patch literal 3218 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
Status

  • Log

  • Receiver
  • +

  • Map
  • diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index 5624d79..9c6db0e 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -656,6 +656,11 @@ img.openwebrx-mirror-img cursor:pointer; } +#openwebrx-main-buttons a { + color: inherit; + text-decoration: inherit; +} + #openwebrx-main-buttons li:hover { background-color: rgba(255, 255, 255, 0.3); From 31b8dd4fd59223f5b0ab788923643b866a24ca58 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 12:53:11 +0200 Subject: [PATCH 014/118] send ysf pins to the map --- htdocs/openwebrx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 5297549..814144d 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1352,7 +1352,7 @@ function update_metadata(meta) { mode = "Mode: " + meta.mode; source = meta.source || ""; if (meta.lat && meta.lon) { - source = "" + source; + source = "" + source; } up = meta.up ? "Up: " + meta.up : ""; down = meta.down ? "Down: " + meta.down : ""; From 089964a5ebdcb82f5adee26d15c7a307890c345f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 13:03:49 +0200 Subject: [PATCH 015/118] query parameter support for the http module --- owrx/controllers.py | 6 +++--- owrx/http.py | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/owrx/controllers.py b/owrx/controllers.py index c4f917a..f979891 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -13,9 +13,9 @@ import logging logger = logging.getLogger(__name__) class Controller(object): - def __init__(self, handler, matches): + def __init__(self, handler, request): self.handler = handler - self.matches = matches + self.request = request def send_response(self, content, code = 200, content_type = "text/html", last_modified: datetime = None, max_age = None): self.handler.send_response(code) if content_type is not None: @@ -69,7 +69,7 @@ class AssetsController(Controller): except FileNotFoundError: self.send_response("file not found", code = 404) def handle_request(self): - filename = self.matches.group(1) + filename = self.request.matches.group(1) self.serve_file(filename) class IndexController(AssetsController): diff --git a/owrx/http.py b/owrx/http.py index 7e1f578..ce821b9 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,6 +1,7 @@ from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController, MapController, FeatureController, ApiController from http.server import BaseHTTPRequestHandler import re +from urllib.parse import urlparse, parse_qs import logging logger = logging.getLogger(__name__) @@ -12,6 +13,11 @@ class RequestHandler(BaseHTTPRequestHandler): def do_GET(self): self.router.route(self) +class Request(object): + def __init__(self, query = None, matches = None): + self.query = query + self.matches = matches + class Router(object): mappings = [ {"route": "/", "controller": IndexController}, @@ -36,10 +42,13 @@ class Router(object): if matches: return (m["controller"], matches) def route(self, handler): - res = self.find_controller(handler.path) + url = urlparse(handler.path) + res = self.find_controller(url.path) if res is not None: (controller, matches) = res - logger.debug("path: {0}, controller: {1}, matches: {2}".format(handler.path, controller, matches)) - controller(handler, matches).handle_request() + query = parse_qs(url.query) + logger.debug("path: {0}, controller: {1}, query: {2}, matches: {3}".format(handler.path, controller, query, matches)) + request = Request(query, matches) + controller(handler, request).handle_request() else: handler.send_error(404, "Not Found", "The page you requested could not be found.") From 3f05565b7b70f14db42b49fbe7c874ab6f104d0f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 15:04:39 +0200 Subject: [PATCH 016/118] show selected callsign on the map --- htdocs/map.js | 18 ++++++++++++++++++ htdocs/openwebrx.js | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 1751089..902c554 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -4,6 +4,18 @@ protocol = 'wss'; } + var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ + var s = v.split('='); + r = {} + r[s[0]] = s.slice(1).join('=') + return r; + }).reduce(function(a, b){ + return a.assign(b); + }); + + var expectedCallsign; + if (query.callsign) expectedCallsign = query.callsign; + var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/"; if (!("WebSocket" in window)) return; @@ -34,6 +46,12 @@ title: update.callsign }); } + + // TODO the trim should happen on the server side + if (expectedCallsign && expectedCallsign == update.callsign.trim()) { + map.panTo(pos); + delete(expectedCallsign); + } }); } diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 814144d..399a435 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1351,8 +1351,8 @@ function update_metadata(meta) { if (meta.mode && meta.mode != "") { mode = "Mode: " + meta.mode; source = meta.source || ""; - if (meta.lat && meta.lon) { - source = "" + source; + if (meta.lat && meta.lon && meta.source) { + source = "" + source; } up = meta.up ? "Up: " + meta.up : ""; down = meta.down ? "Down: " + meta.down : ""; From 284646ee6cfb3a4b3aed998c6d5274f90d086942 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 18:21:43 +0200 Subject: [PATCH 017/118] first stab at ft8 decoding: chop up audio, call jt9 binary to decode --- csdr.py | 21 +++++++++--- htdocs/index.html | 1 + htdocs/openwebrx.js | 4 +++ owrx/wsjt.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 owrx/wsjt.py diff --git a/csdr.py b/csdr.py index 31ee80a..066d2de 100755 --- a/csdr.py +++ b/csdr.py @@ -21,11 +21,11 @@ OpenWebRX csdr plugin: do the signal processing with csdr """ import subprocess -import time import os import signal import threading from functools import partial +from owrx.wsjt import Ft8Chopper import logging logger = logging.getLogger(__name__) @@ -186,6 +186,12 @@ class dsp(object): "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \ "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \ "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8" + elif which == "ft8": + chain = secondary_chain_base + "csdr realpart_cf | " + if self.last_decimation != 1.0 : + chain += "csdr fractional_decimator_ff {last_decimation} | " + chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16" + return chain def set_secondary_demodulator(self, what): if self.get_secondary_demodulator() == what: @@ -238,7 +244,8 @@ class dsp(object): secondary_samples_per_bits=self.secondary_samples_per_bits(), secondary_bpf_cutoff=self.secondary_bpf_cutoff(), secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), - if_samp_rate=self.if_samp_rate() + if_samp_rate=self.if_samp_rate(), + last_decimation=self.last_decimation ) logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (fft) = %s", secondary_command_fft) @@ -253,7 +260,11 @@ class dsp(object): self.secondary_processes_running = True self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read()))) - self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) + if self.get_secondary_demodulator() == "ft8": + chopper = Ft8Chopper(self.secondary_process_demod.stdout) + chopper.start() + else: + self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) #open control pipes for csdr and send initialization data if self.secondary_shift_pipe != None: #TODO digimodes @@ -262,7 +273,7 @@ class dsp(object): def set_secondary_offset_freq(self, value): self.secondary_offset_freq=value - if self.secondary_processes_running: + if self.secondary_processes_running and hasattr(self, "secondary_shift_pipe_file"): self.secondary_shift_pipe_file.write("%g\n"%(-float(self.secondary_offset_freq)/self.if_samp_rate())) self.secondary_shift_pipe_file.flush() @@ -332,6 +343,8 @@ class dsp(object): def get_audio_rate(self): if self.isDigitalVoice(): return 48000 + elif self.secondary_demodulator == "ft8": + return 12000 return self.get_output_rate() def isDigitalVoice(self, demodulator = None): diff --git a/htdocs/index.html b/htdocs/index.html index 07b219e..5373f49 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -109,6 +109,7 @@
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 399a435..280b380 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -2633,6 +2633,7 @@ function demodulator_digital_replace(subtype) { case "bpsk31": case "rtty": + case "ft8": secondary_demod_start(subtype); demodulator_analog_replace('usb', true); demodulator_buttons_update(); @@ -2809,6 +2810,9 @@ function secondary_demod_listbox_changed() case "rtty": demodulator_digital_replace('rtty'); break; + case "ft8": + demodulator_digital_replace('ft8'); + break; } } diff --git a/owrx/wsjt.py b/owrx/wsjt.py new file mode 100644 index 0000000..c9fc319 --- /dev/null +++ b/owrx/wsjt.py @@ -0,0 +1,79 @@ +import threading +import wave +from datetime import datetime, timedelta +import time +import sched +import subprocess + +import logging +logger = logging.getLogger(__name__) + + +class Ft8Chopper(threading.Thread): + def __init__(self, source): + self.source = source + (self.wavefilename, self.wavefile) = self.getWaveFile() + self.scheduler = sched.scheduler(time.time, time.sleep) + self.queue = [] + self.doRun = True + super().__init__() + + def getWaveFile(self): + filename = "/tmp/openwebrx-ft8chopper-{0}.wav".format(datetime.now().strftime("%Y%m%d-%H%M%S")) + wavefile = wave.open(filename, "wb") + wavefile.setnchannels(1) + wavefile.setsampwidth(2) + wavefile.setframerate(12000) + return (filename, wavefile) + + def getNextDecodingTime(self): + t = datetime.now() + seconds = (int(t.second / 15) + 1) * 15 + if seconds >= 60: + t = t + timedelta(minutes = 1) + seconds = 0 + t = t.replace(second = seconds, microsecond = 0) + logger.debug("scheduling: {0}".format(t)) + return t.timestamp() + + def startScheduler(self): + self._scheduleNextSwitch() + threading.Thread(target = self.scheduler.run).start() + + def emptyScheduler(self): + for event in self.scheduler.queue: + self.scheduler.cancel(event) + + def _scheduleNextSwitch(self): + self.scheduler.enterabs(self.getNextDecodingTime(), 1, self.switchFiles) + + def switchFiles(self): + file = self.wavefile + filename = self.wavefilename + (self.wavefilename, self.wavefile) = self.getWaveFile() + + file.close() + self.queue.append(filename) + self._scheduleNextSwitch() + + def decode(self): + if self.queue: + file = self.queue.pop() + logger.debug("processing file {0}".format(file)) + #TODO expose decoding quality parameters through config + self.decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file]) + + def run(self) -> None: + logger.debug("FT8 chopper starting up") + self.startScheduler() + while self.doRun: + data = self.source.read(256) + if data is None or (isinstance(data, bytes) and len(data) == 0): + logger.warning("zero read on ft8 chopper") + self.doRun = False + else: + self.wavefile.writeframes(data) + + self.decode() + logger.debug("FT8 chopper shutting down") + self.emptyScheduler() From fa2d82ac130d9cc952aeda354195b40d5ce5dee5 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 20:03:17 +0200 Subject: [PATCH 018/118] ft8 message parsing --- csdr.py | 1 + owrx/connection.py | 3 +++ owrx/wsjt.py | 64 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/csdr.py b/csdr.py index 066d2de..c9d17c7 100755 --- a/csdr.py +++ b/csdr.py @@ -263,6 +263,7 @@ class dsp(object): if self.get_secondary_demodulator() == "ft8": chopper = Ft8Chopper(self.secondary_process_demod.stdout) chopper.start() + self.output.add_output("wsjt_demod", chopper.read) else: self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) diff --git a/owrx/connection.py b/owrx/connection.py index 3286975..a782cfc 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -159,6 +159,9 @@ class OpenWebRxReceiverClient(Client): def write_metadata(self, metadata): self.protected_send({"type":"metadata","value":metadata}) + def write_wsjt_message(self, message): + self.protected_send({"type": "wsjt_message", "value": message}) + class MapConnection(Client): def __init__(self, conn): diff --git a/owrx/wsjt.py b/owrx/wsjt.py index c9fc319..cc8a7f8 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -1,9 +1,11 @@ import threading import wave -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date import time import sched import subprocess +import os +from multiprocessing.connection import Pipe import logging logger = logging.getLogger(__name__) @@ -14,7 +16,8 @@ class Ft8Chopper(threading.Thread): self.source = source (self.wavefilename, self.wavefile) = self.getWaveFile() self.scheduler = sched.scheduler(time.time, time.sleep) - self.queue = [] + self.fileQueue = [] + (self.outputReader, self.outputWriter) = Pipe() self.doRun = True super().__init__() @@ -53,15 +56,28 @@ class Ft8Chopper(threading.Thread): (self.wavefilename, self.wavefile) = self.getWaveFile() file.close() - self.queue.append(filename) + self.fileQueue.append(filename) self._scheduleNextSwitch() def decode(self): - if self.queue: - file = self.queue.pop() - logger.debug("processing file {0}".format(file)) + def decode_and_unlink(file): #TODO expose decoding quality parameters through config - self.decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file]) + decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE) + while True: + line = decoder.stdout.readline() + if line is None or (isinstance(line, bytes) and len(line) == 0): + break + self.outputWriter.send(line) + rc = decoder.wait() + logger.debug("decoder return code: %i", rc) + os.unlink(file) + + self.decoder = decoder + + if self.fileQueue: + file = self.fileQueue.pop() + logger.debug("processing file {0}".format(file)) + threading.Thread(target=decode_and_unlink, args=[file]).start() def run(self) -> None: logger.debug("FT8 chopper starting up") @@ -76,4 +92,38 @@ class Ft8Chopper(threading.Thread): self.decode() logger.debug("FT8 chopper shutting down") + self.outputReader.close() + self.outputWriter.close() self.emptyScheduler() + + def read(self): + try: + return self.outputReader.recv() + except EOFError: + return None + + +class WsjtParser(object): + def __init__(self, handler): + self.handler = handler + + def parse(self, data): + try: + msg = data.decode().rstrip() + # known debug messages we know to skip + if msg.startswith(""): + return + if msg.startswith(" EOF on input file"): + return + + out = {} + time = datetime.strptime(msg[0:6], "%H%M%S") + out["timestamp"] = datetime.combine(date.today(), time.time()).timestamp() + out["db"] = float(msg[7:10]) + out["dt"] = float(msg[11:15]) + out["freq"] = int(msg[16:20]) + out["msg"] = msg[24:] + + self.handler.write_wsjt_message(out) + except ValueError: + logger.exception("error while parsing wsjt message") From d8a7dfbdbd68401f4cc9dc2ffaba2e0591016dcf Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 21:04:18 +0200 Subject: [PATCH 019/118] ft8 messages panel --- htdocs/index.html | 12 +++++++++++- htdocs/openwebrx.css | 27 +++++++++++++++++++++++++++ htdocs/openwebrx.js | 16 ++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/htdocs/index.html b/htdocs/index.html index 5373f49..3e8ca70 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -159,7 +159,7 @@ Under construction
    We're working on the code right now, so the application might fail.
    -
    +
    @@ -170,6 +170,16 @@
    + + + + + + + + + +
    UTCdBDTFreqMessage
    diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index 9c6db0e..f9135ae 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -1018,3 +1018,30 @@ img.openwebrx-mirror-img background-size: contain; display: inline-block; } + +#openwebrx-panel-wsjt-message { + height: 180px; +} + +#openwebrx-panel-wsjt-message tbody { + display: block; + overflow: auto; + height: 150px; + width: 100%; +} + +#openwebrx-panel-wsjt-message thead tr { + display: block; +} + +#openwebrx-panel-wsjt-message th, +#openwebrx-panel-wsjt-message td { + width: 50px; + text-align: left; +} + +#openwebrx-panel-wsjt-message .message { + width: 400px; +} + + diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 280b380..66a0045 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1245,6 +1245,22 @@ function on_ws_recv(evt) case "metadata": update_metadata(json.value); break; + case "wsjt_message": + var msg = json.value; + var $b = $('#openwebrx-panel-wsjt-message tbody'); + var t = new Date(msg['timestamp'] * 1000); + var pad = function(i) { return ('' + i).padStart(2, "0"); } + $b.append($( + '' + + '' + pad(t.getHours()) + pad(t.getMinutes()) + pad(t.getSeconds()) + '' + + '' + msg['db'] + '' + + '' + msg['dt'] + '' + + '' + msg['freq'] + '' + + '' + msg['msg'] + '' + + '' + )); + $b.scrollTop($b[0].scrollHeight); + break; default: console.warn('received message of unknown type: ' + json.type); } From eb1b1ba22fc937605fa69c7e62b8aa53ee4d43e0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 21:26:35 +0200 Subject: [PATCH 020/118] fix utc timestamps --- htdocs/openwebrx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 66a0045..5103c85 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1252,7 +1252,7 @@ function on_ws_recv(evt) var pad = function(i) { return ('' + i).padStart(2, "0"); } $b.append($( '' + - '' + pad(t.getHours()) + pad(t.getMinutes()) + pad(t.getSeconds()) + '' + + '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + '' + msg['db'] + '' + '' + msg['dt'] + '' + '' + msg['freq'] + '' + From a6d7209a4537030a5e0b423dbbbf860b20ab78a3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 21:29:49 +0200 Subject: [PATCH 021/118] explicit timezone information --- owrx/wsjt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index cc8a7f8..5197f7d 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -117,8 +117,8 @@ class WsjtParser(object): return out = {} - time = datetime.strptime(msg[0:6], "%H%M%S") - out["timestamp"] = datetime.combine(date.today(), time.time()).timestamp() + ts = datetime.strptime(msg[0:6], "%H%M%S") + out["timestamp"] = datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) From 48baea33046c8d4599d8a39747b24ee345b6f490 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 22:21:47 +0200 Subject: [PATCH 022/118] parse locators and send to map --- owrx/wsjt.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 5197f7d..0081aed 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -6,6 +6,8 @@ import sched import subprocess import os from multiprocessing.connection import Pipe +from owrx.map import Map, LocatorLocation +import re import logging logger = logging.getLogger(__name__) @@ -106,6 +108,7 @@ class Ft8Chopper(threading.Thread): class WsjtParser(object): def __init__(self, handler): self.handler = handler + self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$") def parse(self, data): try: @@ -122,8 +125,20 @@ class WsjtParser(object): out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) - out["msg"] = msg[24:] + wsjt_msg = msg[24:] + self.getLocator(wsjt_msg) + out["msg"] = wsjt_msg self.handler.write_wsjt_message(out) except ValueError: logger.exception("error while parsing wsjt message") + + def getLocator(self, msg): + m = self.locator_pattern.match(msg) + if m is None: + return + # this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very + # likely this just means roger roger goodbye. + if m.group(2) == "RR73": + return + Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2))) From 25bc78859521e778220945e2845a5f13ea83fd91 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 22:43:36 +0200 Subject: [PATCH 023/118] parse and show locators on the map --- htdocs/map.js | 62 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 902c554..f5f9a71 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -26,6 +26,7 @@ var map; var markers = {}; + var rectangles = {}; var updateQueue = []; var processUpdates = function(updates) { @@ -34,23 +35,52 @@ return; } updates.forEach(function(update){ - // TODO maidenhead locator implementation - if (update.location.type != 'latlon') return; - var pos = new google.maps.LatLng(update.location.lat, update.location.lon) - if (markers[update.callsign]) { - markers[update.callsign].setPosition(pos); - } else { - markers[update.callsign] = new google.maps.Marker({ - position: pos, - map: map, - title: update.callsign - }); - } - // TODO the trim should happen on the server side - if (expectedCallsign && expectedCallsign == update.callsign.trim()) { - map.panTo(pos); - delete(expectedCallsign); + switch (update.location.type) { + case 'latlon': + var pos = new google.maps.LatLng(update.location.lat, update.location.lon) + if (markers[update.callsign]) { + markers[update.callsign].setPosition(pos); + } else { + markers[update.callsign] = new google.maps.Marker({ + position: pos, + map: map, + title: update.callsign + }); + } + + // TODO the trim should happen on the server side + if (expectedCallsign && expectedCallsign == update.callsign.trim()) { + map.panTo(pos); + delete(expectedCallsign); + } + break; + case 'locator': + var loc = update.location.locator; + var lat = (loc.charCodeAt(1) - 65 - 9) * 10 + Number(loc[3]); + var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]); + var rectangle; + if (rectangles[update.callsign]) { + rectangle = rectangles[update.callsign]; + } else { + rectangle = new google.maps.Rectangle(); + rectangles[update.callsign] = rectangle; + } + rectangle.setOptions({ + strokeColor: '#FF0000', + strokeOpacity: 0.8, + strokeWeight: 2, + fillColor: '#FF0000', + fillOpacity: 0.35, + map: map, + bounds:{ + north: lat, + south: lat + 1, + west: lon, + east: lon + 1 + } + }); + break; } }); } From 849337c55de5e6f038d1d0daafaafa882f3097c8 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 6 Jul 2019 23:15:33 +0200 Subject: [PATCH 024/118] fix locator calculation --- htdocs/map.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index f5f9a71..e268946 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -58,7 +58,7 @@ case 'locator': var loc = update.location.locator; var lat = (loc.charCodeAt(1) - 65 - 9) * 10 + Number(loc[3]); - var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]); + var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2; var rectangle; if (rectangles[update.callsign]) { rectangle = rectangles[update.callsign]; @@ -77,7 +77,7 @@ north: lat, south: lat + 1, west: lon, - east: lon + 1 + east: lon + 2 } }); break; From c22d10d0de4edb814bab7a724d6a1ac370a51b9e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 00:52:11 +0200 Subject: [PATCH 025/118] add day/night overlay --- htdocs/map.js | 4 ++ htdocs/nite-overlay.js | 143 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 htdocs/nite-overlay.js diff --git a/htdocs/map.js b/htdocs/map.js index e268946..a946291 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -108,6 +108,10 @@ zoom: 8 }); processUpdates(updateQueue); + $.getScript("/static/nite-overlay.js").done(function(){ + nite.init(map); + setInterval(function() { nite.refresh() }, 10000); // every 10s + }); }) break case "update": diff --git a/htdocs/nite-overlay.js b/htdocs/nite-overlay.js new file mode 100644 index 0000000..4a8bdd2 --- /dev/null +++ b/htdocs/nite-overlay.js @@ -0,0 +1,143 @@ +/* Nite v1.7 + * A tiny library to create a night overlay over the map + * Author: Rossen Georgiev @ https://github.com/rossengeorgiev + * Requires: GMaps API 3 + */ + + +var nite = { + map: null, + date: null, + sun_position: null, + earth_radius_meters: 6371008, + marker_twilight_civil: null, + marker_twilight_nautical: null, + marker_twilight_astronomical: null, + marker_night: null, + + init: function(map) { + if(typeof google === 'undefined' + || typeof google.maps === 'undefined') throw "Nite Overlay: no google.maps detected"; + + this.map = map; + this.sun_position = this.calculatePositionOfSun(); + + this.marker_twilight_civil = new google.maps.Circle({ + map: this.map, + center: this.getShadowPosition(), + radius: this.getShadowRadiusFromAngle(0.566666), + fillColor: "#000", + fillOpacity: 0.1, + strokeOpacity: 0, + clickable: false, + editable: false + }); + this.marker_twilight_nautical = new google.maps.Circle({ + map: this.map, + center: this.getShadowPosition(), + radius: this.getShadowRadiusFromAngle(6), + fillColor: "#000", + fillOpacity: 0.1, + strokeOpacity: 0, + clickable: false, + editable: false + }); + this.marker_twilight_astronomical = new google.maps.Circle({ + map: this.map, + center: this.getShadowPosition(), + radius: this.getShadowRadiusFromAngle(12), + fillColor: "#000", + fillOpacity: 0.1, + strokeOpacity: 0, + clickable: false, + editable: false + }); + this.marker_night = new google.maps.Circle({ + map: this.map, + center: this.getShadowPosition(), + radius: this.getShadowRadiusFromAngle(18), + fillColor: "#000", + fillOpacity: 0.1, + strokeOpacity: 0, + clickable: false, + editable: false + }); + }, + getShadowRadiusFromAngle: function(angle) { + var shadow_radius = this.earth_radius_meters * Math.PI * 0.5; + var twilight_dist = ((this.earth_radius_meters * 2 * Math.PI) / 360) * angle; + return shadow_radius - twilight_dist; + }, + getSunPosition: function() { + return this.sun_position; + }, + getShadowPosition: function() { + return (this.sun_position) ? new google.maps.LatLng(-this.sun_position.lat(), this.sun_position.lng() + 180) : null; + }, + refresh: function() { + if(!this.isVisible()) return; + this.sun_position = this.calculatePositionOfSun(this.date); + var shadow_position = this.getShadowPosition(); + this.marker_twilight_civil.setCenter(shadow_position); + this.marker_twilight_nautical.setCenter(shadow_position); + this.marker_twilight_astronomical.setCenter(shadow_position); + this.marker_night.setCenter(shadow_position); + }, + jday: function(date) { + return (date.getTime() / 86400000.0) + 2440587.5; + }, + calculatePositionOfSun: function(date) { + date = (date instanceof Date) ? date : new Date(); + + var rad = 0.017453292519943295; + + // based on NOAA solar calculations + var ms_past_midnight = ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + date.getUTCMilliseconds(); + var jc = (this.jday(date) - 2451545)/36525; + var mean_long_sun = (280.46646+jc*(36000.76983+jc*0.0003032)) % 360; + var mean_anom_sun = 357.52911+jc*(35999.05029-0.0001537*jc); + var sun_eq = Math.sin(rad*mean_anom_sun)*(1.914602-jc*(0.004817+0.000014*jc))+Math.sin(rad*2*mean_anom_sun)*(0.019993-0.000101*jc)+Math.sin(rad*3*mean_anom_sun)*0.000289; + var sun_true_long = mean_long_sun + sun_eq; + var sun_app_long = sun_true_long - 0.00569 - 0.00478*Math.sin(rad*125.04-1934.136*jc); + var mean_obliq_ecliptic = 23+(26+((21.448-jc*(46.815+jc*(0.00059-jc*0.001813))))/60)/60; + var obliq_corr = mean_obliq_ecliptic + 0.00256*Math.cos(rad*125.04-1934.136*jc); + + var lat = Math.asin(Math.sin(rad*obliq_corr)*Math.sin(rad*sun_app_long)) / rad; + + var eccent = 0.016708634-jc*(0.000042037+0.0000001267*jc); + var y = Math.tan(rad*(obliq_corr/2))*Math.tan(rad*(obliq_corr/2)); + var rq_of_time = 4*((y*Math.sin(2*rad*mean_long_sun)-2*eccent*Math.sin(rad*mean_anom_sun)+4*eccent*y*Math.sin(rad*mean_anom_sun)*Math.cos(2*rad*mean_long_sun)-0.5*y*y*Math.sin(4*rad*mean_long_sun)-1.25*eccent*eccent*Math.sin(2*rad*mean_anom_sun))/rad); + var true_solar_time_in_deg = ((ms_past_midnight+rq_of_time*60000) % 86400000) / 240000; + + var lng = -((true_solar_time_in_deg < 0) ? true_solar_time_in_deg + 180 : true_solar_time_in_deg - 180); + + return new google.maps.LatLng(lat, lng); + }, + setDate: function(date) { + this.date = date; + this.refresh(); + }, + setMap: function(map) { + this.map = map; + this.marker_twilight_civil.setMap(this.map); + this.marker_twilight_nautical.setMap(this.map); + this.marker_twilight_astronomical.setMap(this.map); + this.marker_night.setMap(this.map); + }, + show: function() { + this.marker_twilight_civil.setVisible(true); + this.marker_twilight_nautical.setVisible(true); + this.marker_twilight_astronomical.setVisible(true); + this.marker_night.setVisible(true); + this.refresh(); + }, + hide: function() { + this.marker_twilight_civil.setVisible(false); + this.marker_twilight_nautical.setVisible(false); + this.marker_twilight_astronomical.setVisible(false); + this.marker_night.setVisible(false); + }, + isVisible: function() { + return this.marker_night.getVisible(); + } +} From ceea2475a11d351e53553702528cbcb1f5bf65a3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 00:52:28 +0200 Subject: [PATCH 026/118] get rid of the extra flags at the end --- owrx/wsjt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 0081aed..b16b5e6 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -125,7 +125,7 @@ class WsjtParser(object): out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) - wsjt_msg = msg[24:] + wsjt_msg = msg[24:61].strip() self.getLocator(wsjt_msg) out["msg"] = wsjt_msg From af315e1671571b54fe95be2976eb8333be0ebd3d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 01:22:45 +0200 Subject: [PATCH 027/118] let's zoom out a little, seems appropriate for now --- htdocs/map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/map.js b/htdocs/map.js index a946291..7435f39 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -105,7 +105,7 @@ lat: config.receiver_gps[0], lng: config.receiver_gps[1] }, - zoom: 8 + zoom: 5 }); processUpdates(updateQueue); $.getScript("/static/nite-overlay.js").done(function(){ From 182a8af57f9864f3a43211d7e490a9d9970b03c9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 14:09:24 +0200 Subject: [PATCH 028/118] deliver better timestamps --- htdocs/openwebrx.js | 2 +- owrx/wsjt.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 5103c85..2caf7e1 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1248,7 +1248,7 @@ function on_ws_recv(evt) case "wsjt_message": var msg = json.value; var $b = $('#openwebrx-panel-wsjt-message tbody'); - var t = new Date(msg['timestamp'] * 1000); + var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } $b.append($( '' + diff --git a/owrx/wsjt.py b/owrx/wsjt.py index b16b5e6..8c5a7af 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -121,7 +121,7 @@ class WsjtParser(object): out = {} ts = datetime.strptime(msg[0:6], "%H%M%S") - out["timestamp"] = datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() + out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) From 1a257064f7ffea581b25e801f3b1210de5695a5a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 14:10:03 +0200 Subject: [PATCH 029/118] add missing parser integration --- owrx/source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/owrx/source.py b/owrx/source.py index 7d62388..3f1b2ac 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -2,6 +2,7 @@ import subprocess from owrx.config import PropertyManager from owrx.feature import FeatureDetector, UnknownFeatureException from owrx.meta import MetaParser +from owrx.wsjt import WsjtParser import threading import csdr import time @@ -346,6 +347,7 @@ class DspManager(csdr.output): self.handler = handler self.sdrSource = sdrSource self.metaParser = MetaParser(self.handler) + self.wsjtParser = WsjtParser(self.handler) self.localProps = self.sdrSource.getProps().collect( "audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", @@ -417,7 +419,8 @@ class DspManager(csdr.output): "smeter": self.handler.write_s_meter_level, "secondary_fft": self.handler.write_secondary_fft, "secondary_demod": self.handler.write_secondary_demod, - "meta": self.metaParser.parse + "meta": self.metaParser.parse, + "wsjt_demod": self.wsjtParser.parse } write = writers[t] From d0cecbdfd78e1a5cc0ba9424d21942f081b366a2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 14:31:12 +0200 Subject: [PATCH 030/118] implement removal of old messages in the gui --- htdocs/openwebrx.js | 46 +++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 2caf7e1..7a1619c 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1246,20 +1246,7 @@ function on_ws_recv(evt) update_metadata(json.value); break; case "wsjt_message": - var msg = json.value; - var $b = $('#openwebrx-panel-wsjt-message tbody'); - var t = new Date(msg['timestamp']); - var pad = function(i) { return ('' + i).padStart(2, "0"); } - $b.append($( - '' + - '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + - '' + msg['db'] + '' + - '' + msg['dt'] + '' + - '' + msg['freq'] + '' + - '' + msg['msg'] + '' + - '' - )); - $b.scrollTop($b[0].scrollHeight); + update_wsjt_panel(json.value); break; default: console.warn('received message of unknown type: ' + json.type); @@ -1388,6 +1375,36 @@ function update_metadata(meta) { } +function update_wsjt_panel(msg) { + var $b = $('#openwebrx-panel-wsjt-message tbody'); + var t = new Date(msg['timestamp']); + var pad = function(i) { return ('' + i).padStart(2, "0"); } + $b.append($( + '' + + '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + + '' + msg['db'] + '' + + '' + msg['dt'] + '' + + '' + msg['freq'] + '' + + '' + msg['msg'] + '' + + '' + )); + $b.scrollTop($b[0].scrollHeight); +} + +var wsjt_removal_interval; + +// remove old wsjt messages in fixed intervals +function init_wsjt_removal_timer() { + if (wsjt_removal_interval) clearInterval(wsjt_removal_interval); + setInterval(function(){ + // let's keep 2 hours that should be plenty for most users + var cutoff = new Date().getTime()- 2 * 60 * 60 * 1000; + $('#openwebrx-panel-wsjt-message tbody tr').filter(function(_, e){ + return $(e).data('timestamp') < cutoff; + }).remove(); + }, 15000); +} + function hide_digitalvoice_panels() { $(".openwebrx-meta-panel").each(function(_, p){ toggle_panel(p.id, false); @@ -2717,6 +2734,7 @@ function secondary_demod_init() .mousedown(secondary_demod_canvas_container_mousedown) .mouseenter(secondary_demod_canvas_container_mousein) .mouseleave(secondary_demod_canvas_container_mouseout); + init_wsjt_removal_timer(); } function secondary_demod_start(subtype) From d1f46c8f55836ae987e0fecc408446bbc0927786 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 15:52:24 +0200 Subject: [PATCH 031/118] server-side removal of map positions --- config_webrx.py | 7 +++++++ owrx/map.py | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 0613278..12f9c14 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -230,3 +230,10 @@ csdr_print_bufsizes = False # This prints the buffer sizes used for csdr proces csdr_through = False # Setting this True will print out how much data is going into the DSP chains. nmux_memory = 50 #in megabytes. This sets the approximate size of the circular buffer used by nmux. + +google_maps_api_key = "" + +# how long should positions be visible on the map? +# they will start fading out after half of that +# in seconds; default: 2 hours +map_position_retention_time = 2 * 60 * 60 \ No newline at end of file diff --git a/owrx/map.py b/owrx/map.py index a799c92..2819a3e 100644 --- a/owrx/map.py +++ b/owrx/map.py @@ -1,4 +1,9 @@ -from datetime import datetime +from datetime import datetime, timedelta +import threading, time +from owrx.config import PropertyManager + +import logging +logger = logging.getLogger(__name__) class Location(object): @@ -17,6 +22,16 @@ class Map(object): def __init__(self): self.clients = [] self.positions = {} + + def removeLoop(): + while True: + try: + self.removeOldPositions() + except Exception: + logger.exception("error while removing old map positions") + time.sleep(60) + + threading.Thread(target=removeLoop, daemon=True).start() super().__init__() def broadcast(self, update): @@ -25,7 +40,14 @@ class Map(object): def addClient(self, client): self.clients.append(client) - client.write_update([{"callsign": callsign, "location": record["loc"].__dict__()} for (callsign, record) in self.positions.items()]) + client.write_update([ + { + "callsign": callsign, + "location": record["location"].__dict__(), + "lastseen": record["updated"].timestamp() * 1000 + } + for (callsign, record) in self.positions.items() + ]) def removeClient(self, client): try: @@ -34,9 +56,28 @@ class Map(object): pass def updateLocation(self, callsign, loc: Location): - self.positions[callsign] = {"loc": loc, "updated": datetime.now()} - self.broadcast([{"callsign": callsign, "location": loc.__dict__()}]) + ts = datetime.now() + self.positions[callsign] = {"location": loc, "updated": ts} + self.broadcast([ + { + "callsign": callsign, + "location": loc.__dict__(), + "lastseen": ts.timestamp() * 1000 + } + ]) + def removeLocation(self, callsign): + self.positions.pop(callsign, None) + # TODO broadcast removal to clients + + def removeOldPositions(self): + pm = PropertyManager.getSharedInstance() + retention = timedelta(seconds=pm["map_position_retention_time"]) + cutoff = datetime.now() - retention + + to_be_removed = [callsign for (callsign, pos) in self.positions.items() if pos["updated"] < cutoff] + for callsign in to_be_removed: + self.removeLocation(callsign) class LatLngLocation(Location): def __init__(self, lat: float, lon: float): From 8b5dc8b3ad2ef7ab1ea3309ed7f16f730d2f1e48 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 20:46:12 +0200 Subject: [PATCH 032/118] fade out markers on the map over time --- htdocs/gfx/openwebrx-top-photo.jpg | Bin 127786 -> 70244 bytes htdocs/map.js | 78 +++++++++++++++++++++++++---- owrx/connection.py | 2 +- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/htdocs/gfx/openwebrx-top-photo.jpg b/htdocs/gfx/openwebrx-top-photo.jpg index cf521c75251ca17ab939a00643212f61358c662d..afc8e7e02166621e9f91bf56faa542584cc72db5 100644 GIT binary patch literal 70244 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 literal 127786 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 bj3C80$+^hSuPWmrA+u(Yd$JCpVs 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%0D?Fj}AxQ(pz80c|9I#45>jf|#DElLQ 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 l=&-tnE52p1?}}Jfmqrj-rV5LnI3@`c<`R= retention_time / 2) { + scale = (retention_time - age) / (retention_time / 2); + } + return Math.max(0, Math.min(1, scale)); + } + + var getRectangleOpacityOptions = function(lastseen) { + var scale = getScale(lastseen); + return { + strokeOpacity: strokeOpacity * scale, + fillOpacity: fillOpacity * scale + }; + } + + var getMarkerOpacityOptions = function(lastseen) { + var scale = getScale(lastseen); + return { + opacity: scale + }; + } + + // fade out / remove positions after time + setInterval(function(){ + var now = new Date().getTime(); + $.each(rectangles, function(callsign, m) { + var age = now - m.lastseen; + if (age > retention_time) { + delete rectangles[callsign]; + m.setMap(); + return; + } + m.setOptions(getRectangleOpacityOptions(m.lastseen)); + }); + $.each(markers, function(callsign, m) { + var age = now - m.lastseen; + if (age > retention_time) { + delete markers[callsign]; + m.setMap(); + return; + } + m.setOptions(getMarkerOpacityOptions(m.lastseen)); + }); + }, 1000); + })(); \ No newline at end of file diff --git a/owrx/connection.py b/owrx/connection.py index a782cfc..d83dacd 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -168,7 +168,7 @@ class MapConnection(Client): super().__init__(conn) pm = PropertyManager.getSharedInstance() - self.write_config(pm.collect("google_maps_api_key", "receiver_gps").__dict__()) + self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__()) Map.getSharedInstance().addClient(self) From 30b56c553e1d9f403c1e973cb7600c4453dadacb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 20:46:31 +0200 Subject: [PATCH 033/118] strip one more character; seen weird stuff at the end. --- owrx/wsjt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 8c5a7af..b05a7c1 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -125,7 +125,7 @@ class WsjtParser(object): out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) - wsjt_msg = msg[24:61].strip() + wsjt_msg = msg[24:60].strip() self.getLocator(wsjt_msg) out["msg"] = wsjt_msg From 83273636f62605db390e0bccd077494195030ab9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 21:24:56 +0200 Subject: [PATCH 034/118] add a quick infowindow to show who's in a grid square --- htdocs/map.css | 10 ++++++++++ htdocs/map.js | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/htdocs/map.css b/htdocs/map.css index 4b5fb00..55cb4aa 100644 --- a/htdocs/map.css +++ b/htdocs/map.css @@ -1,4 +1,14 @@ html, body { width: 100%; height: 100%; +} + +h3 { + margin: 10px 0; +} + +ul { + margin-block-start: 5px; + margin-block-end: 5px; + padding-inline-start: 25px; } \ No newline at end of file diff --git a/htdocs/map.js b/htdocs/map.js index 9ed13f2..875060f 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -73,6 +73,8 @@ rectangle = rectangles[update.callsign]; } else { rectangle = new google.maps.Rectangle(); + var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); + rectangle.addListener('click', buildRectangleClick(update.location.locator, center)); rectangles[update.callsign] = rectangle; } rectangle.setOptions($.extend({ @@ -88,6 +90,7 @@ } }, getRectangleOpacityOptions(update.lastseen) )); rectangle.lastseen = update.lastseen; + rectangle.locator = update.location.locator; break; } }); @@ -144,6 +147,28 @@ console.info("onerror"); }; + var infowindow; + + var buildRectangleClick = function(locator, pos) { + if (!infowindow) infowindow = new google.maps.InfoWindow(); + return function() { + var inLocator = $.map(rectangles, function(r, callsign) { + return {callsign: callsign, locator: r.locator} + }).filter(function(d) { + return d.locator == locator; + }); + infowindow.setContent( + '

    Locator: ' + locator + '

    ' + + '
    Active Callsigns:
    ' + + '
      ' + + inLocator.map(function(i){ return '
    • ' + i.callsign + '
    • ' }).join("") + + '
    ' + ); + infowindow.setPosition(pos); + infowindow.open(map); + }; + } + var getScale = function(lastseen) { var age = new Date().getTime() - lastseen; var scale = 1; From 94afa94428b608e7fe57ab89538a564fbb77c2a0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 21:44:42 +0200 Subject: [PATCH 035/118] add a link to the map --- htdocs/openwebrx.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 7a1619c..7e936a7 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1379,13 +1379,14 @@ function update_wsjt_panel(msg) { var $b = $('#openwebrx-panel-wsjt-message tbody'); var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } + var linkedmsg = msg['msg'].replace(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/, '$1
    $2'); $b.append($( '' + '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + '' + msg['db'] + '' + '' + msg['dt'] + '' + '' + msg['freq'] + '' + - '' + msg['msg'] + '' + + '' + linkedmsg + '' + '' )); $b.scrollTop($b[0].scrollHeight); From 2201daaa20f53eb032c9a53cf8cd586a012f0c96 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 7 Jul 2019 22:36:34 +0200 Subject: [PATCH 036/118] click-through to selected locator on the map --- htdocs/map.js | 47 ++++++++++++++++++++++++++------------------- htdocs/openwebrx.js | 6 +++++- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 875060f..4a3309e 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -15,6 +15,8 @@ var expectedCallsign; if (query.callsign) expectedCallsign = query.callsign; + var expectedLocator; + if (query.locator) expectedLocator = query.locator; var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/"; if (!("WebSocket" in window)) return; @@ -68,13 +70,15 @@ var loc = update.location.locator; var lat = (loc.charCodeAt(1) - 65 - 9) * 10 + Number(loc[3]); var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2; + var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); var rectangle; if (rectangles[update.callsign]) { rectangle = rectangles[update.callsign]; } else { rectangle = new google.maps.Rectangle(); - var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); - rectangle.addListener('click', buildRectangleClick(update.location.locator, center)); + rectangle.addListener('click', function(){ + showInfoWindow(update.location.locator, center); + }); rectangles[update.callsign] = rectangle; } rectangle.setOptions($.extend({ @@ -91,6 +95,12 @@ }, getRectangleOpacityOptions(update.lastseen) )); rectangle.lastseen = update.lastseen; rectangle.locator = update.location.locator; + + if (expectedLocator && expectedLocator == update.location.locator) { + map.panTo(center); + showInfoWindow(expectedLocator, center); + delete(expectedLocator); + } break; } }); @@ -148,25 +158,22 @@ }; var infowindow; - - var buildRectangleClick = function(locator, pos) { + var showInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); - return function() { - var inLocator = $.map(rectangles, function(r, callsign) { - return {callsign: callsign, locator: r.locator} - }).filter(function(d) { - return d.locator == locator; - }); - infowindow.setContent( - '

    Locator: ' + locator + '

    ' + - '
    Active Callsigns:
    ' + - '
      ' + - inLocator.map(function(i){ return '
    • ' + i.callsign + '
    • ' }).join("") + - '
    ' - ); - infowindow.setPosition(pos); - infowindow.open(map); - }; + var inLocator = $.map(rectangles, function(r, callsign) { + return {callsign: callsign, locator: r.locator} + }).filter(function(d) { + return d.locator == locator; + }); + infowindow.setContent( + '

    Locator: ' + locator + '

    ' + + '
    Active Callsigns:
    ' + + '
      ' + + inLocator.map(function(i){ return '
    • ' + i.callsign + '
    • ' }).join("") + + '
    ' + ); + infowindow.setPosition(pos); + infowindow.open(map); } var getScale = function(lastseen) { diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 7e936a7..fa36e68 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1379,7 +1379,11 @@ function update_wsjt_panel(msg) { var $b = $('#openwebrx-panel-wsjt-message tbody'); var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } - var linkedmsg = msg['msg'].replace(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/, '$1$2'); + var linkedmsg = msg['msg']; + var matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); + if (matches && matches[2] != 'RR73') { + linkedmsg = matches[1] + '' + matches[2] + ''; + } $b.append($( '' + '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + From 561ff954362bdd9fa41ff5d342365fc96a2084ea Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 8 Jul 2019 20:16:29 +0200 Subject: [PATCH 037/118] make wsjt feature available (not used yet) --- owrx/feature.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/owrx/feature.py b/owrx/feature.py index d8bcdca..fcc13c4 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -22,7 +22,8 @@ class FeatureDetector(object): "hackrf": [ "hackrf_transfer" ], "airspy": [ "airspy_rx" ], "digital_voice_digiham": [ "digiham", "sox" ], - "digital_voice_dsd": [ "dsd", "sox", "digiham" ] + "digital_voice_dsd": [ "dsd", "sox", "digiham" ], + "wsjt-x": [ "wsjtx" ] } def feature_availability(self): @@ -193,3 +194,11 @@ class FeatureDetector(object): In order to use an Airspy Receiver, you need to install the airspy_rx receiver software. """ return self.command_is_runnable("airspy_rx --help 2> /dev/null") + + def has_wsjtx(self): + """ + To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the + [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions + on how to build from source. + """ + return self.command_is_runnable("jt9") From c7503f87d71d70505d4b4d58aa04eb526d0e51eb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 8 Jul 2019 20:31:34 +0200 Subject: [PATCH 038/118] show ft8 panel only when ft8 is active --- htdocs/openwebrx.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index fa36e68..3e380af 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -2384,7 +2384,9 @@ function openwebrx_init() } function digimodes_init() { - hide_digitalvoice_panels(); + $(".openwebrx-meta-panel").each(function(_, p){ + p.openwebrxHidden = true; + }); // initialze DMR timeslot muting $('.openwebrx-dmr-timeslot-panel').click(function(e) { @@ -2678,6 +2680,7 @@ function demodulator_digital_replace(subtype) break; } toggle_panel("openwebrx-panel-digimodes", true); + if (subtype == 'ft8') toggle_panel("openwebrx-panel-wsjt-message", true); } function secondary_demod_create_canvas() @@ -2732,6 +2735,7 @@ function secondary_demod_swap_canvases() function secondary_demod_init() { $("#openwebrx-panel-digimodes")[0].openwebrxHidden = true; + $("#openwebrx-panel-wsjt-message")[0].openwebrxHidden = true; secondary_demod_canvas_container = $("#openwebrx-digimode-canvas-container")[0]; $(secondary_demod_canvas_container) .mousemove(secondary_demod_canvas_container_mousemove) @@ -2797,6 +2801,7 @@ function secondary_demod_close_window() { secondary_demod_stop(); toggle_panel("openwebrx-panel-digimodes", false); + toggle_panel("openwebrx-panel-wsjt-message", false); } secondary_demod_fft_offset_db=30; //need to calculate that later From c6aa5c3a3c9f8d5cb7b0f67241a40cdf769ee895 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 8 Jul 2019 20:45:09 +0200 Subject: [PATCH 039/118] make the interface pretty --- htdocs/index.html | 6 +++--- htdocs/openwebrx.css | 13 +++++++++++++ htdocs/openwebrx.js | 9 +++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/htdocs/index.html b/htdocs/index.html index 3e8ca70..60f00cd 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -173,9 +173,9 @@ - - - + + + diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index f9135ae..e857450 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -1038,10 +1038,23 @@ img.openwebrx-mirror-img #openwebrx-panel-wsjt-message td { width: 50px; text-align: left; + padding: 1px 3px; } #openwebrx-panel-wsjt-message .message { width: 400px; } +#openwebrx-panel-wsjt-message .decimal { + text-align: right; + width: 35px; +} +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container { + display: none; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container { + height: 200px; + margin: -10px; +} \ No newline at end of file diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 3e380af..1b4fe11 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1387,9 +1387,9 @@ function update_wsjt_panel(msg) { $b.append($( '' + '' + - '' + - '' + - '' + + '' + + '' + + '' + '' + '' )); @@ -2679,8 +2679,9 @@ function demodulator_digital_replace(subtype) demodulator_buttons_update(); break; } + $('#openwebrx-panel-digimodes').attr('data-mode', subtype); toggle_panel("openwebrx-panel-digimodes", true); - if (subtype == 'ft8') toggle_panel("openwebrx-panel-wsjt-message", true); + toggle_panel("openwebrx-panel-wsjt-message", subtype == 'ft8'); } function secondary_demod_create_canvas() From bab8ec1eaaef607693a6188fe625443ce11e0032 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 8 Jul 2019 20:47:50 +0200 Subject: [PATCH 040/118] even prettier --- htdocs/openwebrx.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index e857450..a419b8d 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -1057,4 +1057,8 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container { height: 200px; margin: -10px; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel { + display: none; } \ No newline at end of file From 58e819606a0cce739f8476f26d36829147e4ad30 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 8 Jul 2019 21:01:30 +0200 Subject: [PATCH 041/118] use moment.js to display a pretty time since last activity --- htdocs/features.html | 2 +- htdocs/index.html | 6 +++--- htdocs/{ => lib}/jquery-3.2.1.min.js | 0 htdocs/{ => lib}/jquery.nanoscroller.js | 0 htdocs/{ => lib}/nanoscroller.css | 0 htdocs/{ => lib}/nite-overlay.js | 0 htdocs/map.html | 3 ++- htdocs/map.js | 9 ++++++--- 8 files changed, 12 insertions(+), 8 deletions(-) rename htdocs/{ => lib}/jquery-3.2.1.min.js (100%) rename htdocs/{ => lib}/jquery.nanoscroller.js (100%) rename htdocs/{ => lib}/nanoscroller.css (100%) rename htdocs/{ => lib}/nite-overlay.js (100%) diff --git a/htdocs/features.html b/htdocs/features.html index 5602567..a4d2279 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -3,7 +3,7 @@ - +
    diff --git a/htdocs/index.html b/htdocs/index.html index 60f00cd..e945f3f 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -25,9 +25,9 @@ - - - + + + diff --git a/htdocs/jquery-3.2.1.min.js b/htdocs/lib/jquery-3.2.1.min.js similarity index 100% rename from htdocs/jquery-3.2.1.min.js rename to htdocs/lib/jquery-3.2.1.min.js diff --git a/htdocs/jquery.nanoscroller.js b/htdocs/lib/jquery.nanoscroller.js similarity index 100% rename from htdocs/jquery.nanoscroller.js rename to htdocs/lib/jquery.nanoscroller.js diff --git a/htdocs/nanoscroller.css b/htdocs/lib/nanoscroller.css similarity index 100% rename from htdocs/nanoscroller.css rename to htdocs/lib/nanoscroller.css diff --git a/htdocs/nite-overlay.js b/htdocs/lib/nite-overlay.js similarity index 100% rename from htdocs/nite-overlay.js rename to htdocs/lib/nite-overlay.js diff --git a/htdocs/map.html b/htdocs/map.html index ee8908c..056cc14 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -2,8 +2,9 @@ OpenWebRX Map - + + diff --git a/htdocs/map.js b/htdocs/map.js index 4a3309e..8b268cd 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -129,7 +129,7 @@ zoom: 5 }); processUpdates(updateQueue); - $.getScript("/static/nite-overlay.js").done(function(){ + $.getScript("/static/lib/nite-overlay.js").done(function(){ nite.init(map); setInterval(function() { nite.refresh() }, 10000); // every 10s }); @@ -161,7 +161,7 @@ var showInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); var inLocator = $.map(rectangles, function(r, callsign) { - return {callsign: callsign, locator: r.locator} + return {callsign: callsign, locator: r.locator, lastseen: r.lastseen} }).filter(function(d) { return d.locator == locator; }); @@ -169,7 +169,10 @@ '

    Locator: ' + locator + '

    ' + '
    Active Callsigns:
    ' + '
      ' + - inLocator.map(function(i){ return '
    • ' + i.callsign + '
    • ' }).join("") + + inLocator.map(function(i){ + var timestring = moment(i.lastseen).fromNow(); + return '
    • ' + i.callsign + ' (' + timestring + ')
    • ' + }).join("") + '
    ' ); infowindow.setPosition(pos); From ad9855a79160ad2f1bc27c9551d6e7ca82aa1479 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 9 Jul 2019 17:28:41 +0200 Subject: [PATCH 042/118] pretty logo --- htdocs/openwebrx.css | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index a419b8d..3c3ffcd 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -212,6 +212,7 @@ input[type=range]:focus::-ms-fill-upper width: 46px; height: 46px; padding: 4px; + border-radius: 8px; } #webrx-top-photo-clip From 438efa655fd49ef737ca0bf5de04ba27066367f4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 9 Jul 2019 17:32:49 +0200 Subject: [PATCH 043/118] fix javascript issues --- htdocs/map.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 8b268cd..56aa29b 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -6,8 +6,8 @@ var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ var s = v.split('='); - r = {} - r[s[0]] = s.slice(1).join('=') + var r = {}; + r[s[0]] = s.slice(1).join('='); return r; }).reduce(function(a, b){ return a.assign(b); @@ -33,8 +33,8 @@ // reasonable default; will be overriden by server var retention_time = 2 * 60 * 60 * 1000; - strokeOpacity = 0.8; - fillOpacity = 0.35; + var strokeOpacity = 0.8; + var fillOpacity = 0.35; var processUpdates = function(updates) { if (!map) { @@ -45,7 +45,7 @@ switch (update.location.type) { case 'latlon': - var pos = new google.maps.LatLng(update.location.lat, update.location.lon) + var pos = new google.maps.LatLng(update.location.lat, update.location.lon); var marker; if (markers[update.callsign]) { marker = markers[update.callsign]; @@ -104,7 +104,7 @@ break; } }); - } + }; ws.onmessage = function(e){ if (typeof e.data != 'string') { @@ -116,7 +116,7 @@ return } try { - json = JSON.parse(e.data); + var json = JSON.parse(e.data); switch (json.type) { case "config": var config = json.value; @@ -135,10 +135,10 @@ }); }); retention_time = config.map_position_retention_time * 1000; - break + break; case "update": processUpdates(json.value); - break + break; } } catch (e) { // don't lose exception @@ -177,7 +177,7 @@ ); infowindow.setPosition(pos); infowindow.open(map); - } + }; var getScale = function(lastseen) { var age = new Date().getTime() - lastseen; @@ -186,7 +186,7 @@ scale = (retention_time - age) / (retention_time / 2); } return Math.max(0, Math.min(1, scale)); - } + }; var getRectangleOpacityOptions = function(lastseen) { var scale = getScale(lastseen); @@ -194,14 +194,14 @@ strokeOpacity: strokeOpacity * scale, fillOpacity: fillOpacity * scale }; - } + }; var getMarkerOpacityOptions = function(lastseen) { var scale = getScale(lastseen); return { opacity: scale }; - } + }; // fade out / remove positions after time setInterval(function(){ From 2536d9f74705702edd7d65577f62d88b0023a4b1 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 9 Jul 2019 17:34:24 +0200 Subject: [PATCH 044/118] more javascript issues --- htdocs/features.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/features.js b/htdocs/features.js index e534bcb..6da77c8 100644 --- a/htdocs/features.js +++ b/htdocs/features.js @@ -1,9 +1,9 @@ $(function(){ var converter = new showdown.Converter(); $.ajax('/api/features').done(function(data){ - $table = $('table.features'); + var $table = $('table.features'); $.each(data, function(name, details) { - requirements = $.map(details.requirements, function(r, name){ + var requirements = $.map(details.requirements, function(r, name){ return '
    ' + '' + '' + From cb0b950d34c9505dc774db59dc25dd58874b5596 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Jul 2019 22:09:31 +0200 Subject: [PATCH 045/118] protect the wave file switchover with a lock, since race conditions have occured --- owrx/wsjt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index b05a7c1..9fe3bbc 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -17,6 +17,7 @@ class Ft8Chopper(threading.Thread): def __init__(self, source): self.source = source (self.wavefilename, self.wavefile) = self.getWaveFile() + self.switchingLock = threading.Lock() self.scheduler = sched.scheduler(time.time, time.sleep) self.fileQueue = [] (self.outputReader, self.outputWriter) = Pipe() @@ -53,9 +54,11 @@ class Ft8Chopper(threading.Thread): self.scheduler.enterabs(self.getNextDecodingTime(), 1, self.switchFiles) def switchFiles(self): + self.switchingLock.acquire() file = self.wavefile filename = self.wavefilename (self.wavefilename, self.wavefile) = self.getWaveFile() + self.switchingLock.release() file.close() self.fileQueue.append(filename) @@ -90,7 +93,9 @@ class Ft8Chopper(threading.Thread): logger.warning("zero read on ft8 chopper") self.doRun = False else: + self.switchingLock.acquire() self.wavefile.writeframes(data) + self.switchingLock.release() self.decode() logger.debug("FT8 chopper shutting down") From 32c76beaa27ff42c7a47c0193fe8e301683c0d19 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Jul 2019 22:18:16 +0200 Subject: [PATCH 046/118] improved fullscreen layout --- htdocs/map.css | 3 ++- htdocs/map.html | 1 + htdocs/map.js | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/htdocs/map.css b/htdocs/map.css index 55cb4aa..a982e1b 100644 --- a/htdocs/map.css +++ b/htdocs/map.css @@ -1,6 +1,7 @@ -html, body { +html, body, .openwebrx-map { width: 100%; height: 100%; + margin: 0; } h3 { diff --git a/htdocs/map.html b/htdocs/map.html index 056cc14..9e4b25b 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -9,5 +9,6 @@ +
    diff --git a/htdocs/map.js b/htdocs/map.js index 56aa29b..6ca108c 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -121,7 +121,7 @@ case "config": var config = json.value; $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ - map = new google.maps.Map($('body')[0], { + map = new google.maps.Map($('.openwebrx-map')[0], { center: { lat: config.receiver_gps[0], lng: config.receiver_gps[1] From 8a8768ed1d4da72447abcc3de934f3efb796b8e7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Jul 2019 22:31:06 +0200 Subject: [PATCH 047/118] fix ft8 audio sample rate issues with sox --- csdr.py | 13 ++++++++++--- owrx/feature.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/csdr.py b/csdr.py index c9d17c7..9203d17 100755 --- a/csdr.py +++ b/csdr.py @@ -167,9 +167,15 @@ class dsp(object): chain += last_decimation_block chain += [ "csdr agc_ff", - "csdr limit_ff", - "csdr convert_f_s16" + "csdr limit_ff" ] + # fixed sample rate necessary for the wsjt-x tools. fix with sox... + if self.get_secondary_demodulator() == "ft8" and self.get_audio_rate() != self.get_output_rate(): + chain += [ + "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] + else: + chain += ["csdr convert_f_s16"] if self.audio_compression=="adpcm": chain += ["csdr encode_ima_adpcm_i16_u8"] @@ -472,7 +478,8 @@ class dsp(object): flowcontrol=int(self.samp_rate*2), start_bufsize=self.base_bufsize*self.decimation, nc_port=self.nc_port, squelch_pipe=self.squelch_pipe, smeter_pipe=self.smeter_pipe, meta_pipe=self.meta_pipe, iqtee_pipe=self.iqtee_pipe, iqtee2_pipe=self.iqtee2_pipe, output_rate = self.get_output_rate(), smeter_report_every = int(self.if_samp_rate()/6000), - unvoiced_quality = self.get_unvoiced_quality(), dmr_control_pipe = self.dmr_control_pipe) + unvoiced_quality = self.get_unvoiced_quality(), dmr_control_pipe = self.dmr_control_pipe, + audio_rate = self.get_audio_rate()) logger.debug("[openwebrx-dsp-plugin:csdr] Command = %s", command) my_env=os.environ.copy() diff --git a/owrx/feature.py b/owrx/feature.py index fcc13c4..9009e13 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -23,7 +23,7 @@ class FeatureDetector(object): "airspy": [ "airspy_rx" ], "digital_voice_digiham": [ "digiham", "sox" ], "digital_voice_dsd": [ "dsd", "sox", "digiham" ], - "wsjt-x": [ "wsjtx" ] + "wsjt-x": [ "wsjtx", "sox" ] } def feature_availability(self): From 596c868b9da5477da79fadd8f97d4c36f602f7e0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Jul 2019 22:56:32 +0200 Subject: [PATCH 048/118] improved map logo --- htdocs/gfx/openwebrx-panel-map.png | Bin 3218 -> 3027 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/htdocs/gfx/openwebrx-panel-map.png b/htdocs/gfx/openwebrx-panel-map.png index 36cb90e1fe39c49667abaf735aa749995cba5f11..81ec9e2f8aad25e5954a259c41686ae6b862b425 100644 GIT binary patch delta 3021 zcmV;;3o`VQ8PgY#B!7cxLqkwWLqi~Na&Km7Y-IodD3N`UJxIeq9K~N-rBW&mW)N`* zSe-10ia2T&iclfc3avVrT>1q~8j=(jN5Qq=;KyRs!Nplu2UkH5`~Y!rby9SZ691PJ zTEuv8+>dwn9(V76V57n`t7{z4blXfP6Jj>EDu!PXMg#(e0e_iU#+)Rj;5)wV5#Z}x zoM-u;`*ZZFd5Zx7k$9FFrcJy-JiTcfocD3L#-SS)t1(!s1`YQ$5-F;&wkKbZAc<-EmNtJGNQp8SQ;yuPx`b(%v+Vi8M_AVNV6 zWmI4zPODCeg?}{dCw%;au3sXTLas6xITlcb2HEw4|H1EWt-|DlmlRC^-7k*wF#?2l zfo9!tzK;u>0~^Y6gbvGw$^ZZW z24YJ`L;$Y00144aL_t(o!Zs+)#<=Z&1#-I!s3;>i&jU=Xg z8epW1**G2)5`)7MP{P6r08j#eiWdzpIsnkFt*!M1gF!d{ULvo&^2&jYjg7%XBJuN` zot$L%MR z$#;f_hg|^BF)}jJ8I49SGYs9+vD0ssacvyM!`QCf)Szdhc#nI~O z>QRJ{A{-7sLI_b7i^ZNwr4qHZwe6WqW`8o9&8jRG%lCu9;6EoOCcOE4o&^9v2*J?M zP-kan=V&gMi|+01{kx{7=5+u-p-^ba;czhdeEx2Ab@d+rAP)dE05C|T!V>d&z1|L& z%k?vzPWNMmVS<4`VB7EaKPnUoq`$wv!)CKJT)A>(%HePrPM$p3u2d?Wb8~a=tbeVo zZN2o;OT$*H^*I2bQmNGa8#iuTqbLepxNzZ@6h%?@@8AE-@Ap4oS(Y6c8EGsO3d=^L z@s9wI1AqbmFj8k%EG;dy=kxhL_w@8!M+jNGUhmY0AAab)d-razP$&Q)gd&7s0055T zs8A@h4*)0}4o~}hzWAj}mtHp-jem#N*4C~rFE9UzvMlBE`TmX&(tQ5; z=MkEwi<^y~uVlw7L_3TE0II2}X&7T%bK}O1f5c+3$Al0p768D65JU(;0Dx>ZTa{X^ zZkw8#`s?Y_r(J5b+B!Bi_Pg!v?JNMm^z`)lYp=cb`rzQ;sj;!K@n@fXc7MCQz5N-V z&-bM;4Jd;YL0`Yh} zFD%HiEX3pS&kh_o;2?ycTrL+h7z_u*^T-8lX++0@CIk?ocMwfhtQm&k5JD;dK!rjf zr&g=2(wL^#>#a0RX8-_UjDK|u!?5D-yv9HZtOT{NxCEeFjsyUJP$;yEG1ebDcI@E7 z!h%n$)z)9Uc(D&-i~s=6oH^5GwOUWCuCC4-3{chS}TOTj=ZSJNxdt@BZc9y?gH;KYo1l^5x5?IF6&0N~QVX!+(eWnxCKlYIJn; zG{Z2-_4W0*5K1ZmD|fOe86`HO)Ia9u=Tjb!r?#I*zIyzIF5sFDGC|QluPPG^YR4~LJ&fTY;A32wzs#(dwO~X7Zw)A zc6WCRAAkJu7YHFDjDPWXJm1Ub^Bc>{%h5`jlJl`AGcO?^HJwhUQ#hT@mg?&28n@eh zRM6h7Tenz^M&oq5-JKFYg~NvrTPzmKk3RkM(=B+)g5|(SwI$vN=gyt$R;$%jxm+$| zx7(Y}o;~XV0F+9l=tv}T#pQB&6bc2!hZsZ%K?4H=o^(3Buz$Y3zFh%JiM4PgZAo@3 z^!N9-m`o;HI2;b-a=HDPnVJ7M9FAtU+x=aH5SpEx)nklR=g*(-5n^9&Z?Ds6G`eoz zzP%^eT+)qHIb4)EMuXGoG_|+4AM^YDiu9?x@k?%b)TY5JQNUU*@^?RIx+G@72x|}Utix3Ha9mn{CeZ{ag&z_1|2B~arqyb@G#ZWi=FOYqG)=Q& ztxEn*TqAyQA!C#*CV)Z+m59uo#a;Qt%@|{RV0Cr%_Sav3ot8|-35$!g_=G0-cTNOW z+J9csS5A`(=>-%a1i4)9j?d@&3IH$ zjiuLhgb>GZTt+llEUZSOL3+JjuTUsdCX-27rXNQL;dsE}b}=Zm$cli0 zLUL6v87wHnXf$ft+S(j-b#)Gd!C)tZFnZ4R(vg;?U`IR+eQ0ySux0M8G(~ltk3PVvIS25UH=P*Ecse zI}aW_*jQCnRmXAMRwk2~3j_kQZ@lrw;UJ7pU>y>kAIWN zWF#C8hcU(k0Ep3Oq*_~B>ufe#o!M+|peRa3)AV9Go&J;8>%D&F%$cu*yQBmhsQ7ES z0RXJ&bownq2tx>A3x$F|nN0qEVPWBi0MH5mEdX!`0GfF@#J9D5@WBVYo12@z$!4>+ zIF8E^LKu$Y@`MnUrs*&D_V(Tn1b+fQef8B>j{rau05kwV9RSqultdLzQ78d`Li~fk zUnrNBmYNzH8wVE`7iXV;{`o{19t&hxq9Q5t=9_QoE?v5GQlrsWR#sN#hlYkiyf#=7 zyTgduq}t-7+yy1ax|9ooGJG1A;n1XXlXwTRlA1_;tc1-I@l~h-za`GCxJC^7D942n zNobJPC6S+#`8!mBrX&?;(Q;JiQ>P=4IjxqPxE!$yWzKVzSVbtK{gV2>#d~ci4o+pp P00000NkvXXu0mjfU_zPl delta 3214 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 AbN~PV From d57f9de21efb2d3a3f8961a69759200d070eda7e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Wed, 10 Jul 2019 23:13:03 +0200 Subject: [PATCH 049/118] automatic map reconnection --- htdocs/map.js | 121 ++++++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 6ca108c..b104a0a 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -21,11 +21,6 @@ var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/"; if (!("WebSocket" in window)) return; - var ws = new WebSocket(ws_url); - ws.onopen = function(){ - ws.send("SERVER DE CLIENT client=map.js type=map"); - }; - var map; var markers = {}; var rectangles = {}; @@ -106,57 +101,79 @@ }); }; - ws.onmessage = function(e){ - if (typeof e.data != 'string') { - console.error("unsupported binary data on websocket; ignoring"); - return - } - if (e.data.substr(0, 16) == "CLIENT DE SERVER") { - console.log("Server acknowledged WebSocket connection."); - return - } - try { - var json = JSON.parse(e.data); - switch (json.type) { - case "config": - var config = json.value; - $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ - map = new google.maps.Map($('.openwebrx-map')[0], { - center: { - lat: config.receiver_gps[0], - lng: config.receiver_gps[1] - }, - zoom: 5 - }); - processUpdates(updateQueue); - $.getScript("/static/lib/nite-overlay.js").done(function(){ - nite.init(map); - setInterval(function() { nite.refresh() }, 10000); // every 10s - }); - }); - retention_time = config.map_position_retention_time * 1000; - break; - case "update": - processUpdates(json.value); - break; - } - } catch (e) { - // don't lose exception - console.error(e); - } - }; - ws.onclose = function(){ - console.info("onclose"); + var clearMap = function(){ + var reset = function(callsign, item) { item.setMap(); }; + $.each(markers, reset); + $.each(rectangles, reset); + markers = {}; + rectangles = {}; }; - window.onbeforeunload = function() { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript - ws.onclose = function () {}; - ws.close(); - }; - ws.onerror = function(){ - console.info("onerror"); + var connect = function(){ + var ws = new WebSocket(ws_url); + ws.onopen = function(){ + ws.send("SERVER DE CLIENT client=map.js type=map"); + }; + + ws.onmessage = function(e){ + if (typeof e.data != 'string') { + console.error("unsupported binary data on websocket; ignoring"); + return + } + if (e.data.substr(0, 16) == "CLIENT DE SERVER") { + console.log("Server acknowledged WebSocket connection."); + return + } + try { + var json = JSON.parse(e.data); + switch (json.type) { + case "config": + var config = json.value; + if (!map) $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ + map = new google.maps.Map($('.openwebrx-map')[0], { + center: { + lat: config.receiver_gps[0], + lng: config.receiver_gps[1] + }, + zoom: 5 + }); + processUpdates(updateQueue); + updateQueue = []; + $.getScript("/static/lib/nite-overlay.js").done(function(){ + nite.init(map); + setInterval(function() { nite.refresh() }, 10000); // every 10s + }); + }); + retention_time = config.map_position_retention_time * 1000; + break; + case "update": + processUpdates(json.value); + break; + } + } catch (e) { + // don't lose exception + console.error(e); + } + }; + ws.onclose = function(){ + clearMap(); + setTimeout(connect, 5000); + }; + + window.onbeforeunload = function() { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript + ws.onclose = function () {}; + ws.close(); + }; + + /* + ws.onerror = function(){ + console.info("websocket error"); + }; + */ }; + connect(); + var infowindow; var showInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); From 2bf2fcd6850081b8635090dce6ff87890449632b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 13:40:12 +0200 Subject: [PATCH 050/118] implement header on map page (not fully functional yet) --- htdocs/{ => css}/map.css | 8 ++ htdocs/css/openwebrx-globals.css | 8 ++ htdocs/css/openwebrx-header.css | 201 ++++++++++++++++++++++++++ htdocs/include/header.include.html | 30 ++++ htdocs/index.html | 31 +--- htdocs/map.html | 3 +- htdocs/openwebrx.css | 224 +---------------------------- owrx/controllers.py | 33 ++++- 8 files changed, 280 insertions(+), 258 deletions(-) rename htdocs/{ => css}/map.css (53%) create mode 100644 htdocs/css/openwebrx-globals.css create mode 100644 htdocs/css/openwebrx-header.css create mode 100644 htdocs/include/header.include.html diff --git a/htdocs/map.css b/htdocs/css/map.css similarity index 53% rename from htdocs/map.css rename to htdocs/css/map.css index a982e1b..7595ab7 100644 --- a/htdocs/map.css +++ b/htdocs/css/map.css @@ -1,3 +1,11 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +/* expandable photo not implemented on map page */ +#webrx-top-photo-clip { + max-height: 67px; +} + html, body, .openwebrx-map { width: 100%; height: 100%; diff --git a/htdocs/css/openwebrx-globals.css b/htdocs/css/openwebrx-globals.css new file mode 100644 index 0000000..a01982d --- /dev/null +++ b/htdocs/css/openwebrx-globals.css @@ -0,0 +1,8 @@ +html, body +{ + margin: 0; + padding: 0; + height: 100%; + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; + overflow: hidden; +} diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css new file mode 100644 index 0000000..9cfcc0c --- /dev/null +++ b/htdocs/css/openwebrx-header.css @@ -0,0 +1,201 @@ +#webrx-top-container +{ + position: relative; + z-index:1000; +} + +#webrx-top-photo +{ + width: 100%; + display: block; +} + +#webrx-top-photo-clip +{ + min-height: 67px; + max-height: 350px; + overflow: hidden; + position: relative; +} + +.webrx-top-bar-parts +{ + height:67px; +} + +#webrx-top-bar +{ + background: rgba(128, 128, 128, 0.15); + margin:0; + padding:0; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + overflow: hidden; + position: absolute; + left: 0; + top: 0; + right: 0; +} + +#webrx-top-logo +{ + padding: 12px; + float: left; +} + +#webrx-ha5kfu-top-logo +{ + float: right; + padding: 15px; +} + +#webrx-rx-avatar-background +{ + cursor:pointer; + background-image: url(../gfx/openwebrx-avatar-background.png); + background-origin: content-box; + background-repeat: no-repeat; + float: left; + width: 54px; + height: 54px; + padding: 7px; +} + +#webrx-rx-avatar +{ + cursor:pointer; + width: 46px; + height: 46px; + padding: 4px; + border-radius: 8px; +} + +#webrx-rx-texts { + float: left; + padding: 10px; +} + +#webrx-rx-texts div { + padding: 3px; +} + +#webrx-rx-title +{ + white-space:nowrap; + overflow: hidden; + cursor:pointer; + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; + color: #909090; + font-size: 11pt; + font-weight: bold; +} + +#webrx-rx-desc +{ + white-space:nowrap; + overflow: hidden; + cursor:pointer; + font-size: 10pt; + color: #909090; +} + +#webrx-rx-desc a +{ + color: #909090; +} + +#openwebrx-rx-details-arrow +{ + cursor:pointer; + position: absolute; + left: 470px; + top: 51px; +} + +#openwebrx-rx-details-arrow a +{ + margin: 0; + padding: 0; +} + +#openwebrx-rx-details-arrow-down +{ + display:none; +} + +#openwebrx-main-buttons ul +{ + display: table; + margin:0; +} + + +#openwebrx-main-buttons ul li +{ + display: table-cell; + padding-left: 5px; + padding-right: 5px; + cursor:pointer; +} + +#openwebrx-main-buttons a { + color: inherit; + text-decoration: inherit; +} + +#openwebrx-main-buttons li:hover +{ + background-color: rgba(255, 255, 255, 0.3); +} + +#openwebrx-main-buttons li:active +{ + background-color: rgba(255, 255, 255, 0.55); +} + + +#openwebrx-main-buttons +{ + float: right; + margin:0; + color: white; + text-shadow: 0px 0px 4px #000000; + text-align: center; + font-size: 9pt; + font-weight: bold; +} + +#webrx-rx-photo-title +{ + position: absolute; + left: 15px; + top: 78px; + color: White; + font-size: 16pt; + text-shadow: 1px 1px 4px #444; + opacity: 1; +} + +#webrx-rx-photo-desc +{ + position: absolute; + left: 15px; + top: 109px; + color: White; + font-size: 10pt; + font-weight: bold; + text-shadow: 0px 0px 6px #444; + opacity: 1; + line-height: 1.5em; +} + +#webrx-rx-photo-desc a +{ + color: #5ca8ff; + text-shadow: none; +} + diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html new file mode 100644 index 0000000..b2f3bc9 --- /dev/null +++ b/htdocs/include/header.include.html @@ -0,0 +1,30 @@ +
    +
    + +
    + + +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
      +

    • Status
    • +

    • Log
    • +

    • Receiver
    • +

    • Map
    • +
    +
    +
    +
    +
    +
    +
    diff --git a/htdocs/index.html b/htdocs/index.html index e945f3f..c17d83b 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -33,36 +33,7 @@
    -
    -
    - -
    - - -
    - -
    -
    -
    -
    -
    -
    - - -
    -
    -
      -

    • Status
    • -

    • Log
    • -

    • Receiver
    • -

    • Map
    • -
    -
    -
    -
    -
    -
    -
    + ${header}
    diff --git a/htdocs/map.html b/htdocs/map.html index 9e4b25b..f09a44a 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -5,10 +5,11 @@ - + + ${header}
    diff --git a/htdocs/openwebrx.css b/htdocs/openwebrx.css index 3c3ffcd..39a5b95 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/openwebrx.css @@ -18,15 +18,8 @@ along with this program. If not, see . */ - -html, body -{ - margin: 0; - padding: 0; - height: 100%; - font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; - overflow: hidden; -} +@import url("css/openwebrx-header.css"); +@import url("css/openwebrx-globals.css"); select { @@ -147,179 +140,12 @@ input[type=range]:focus::-ms-fill-upper background: #B6B6B6; } -#webrx-top-container -{ - position: relative; - z-index:1000; -} - -.webrx-top-bar-parts -{ - height:67px; -} - -#webrx-top-bar -{ - background: rgba(128, 128, 128, 0.15); - margin:0; - padding:0; - user-select: none; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - overflow: hidden; - position: absolute; - left: 0; - top: 0; - right: 0; -} - -#webrx-top-logo -{ - padding: 12px; - float: left; -} - -#webrx-ha5kfu-top-logo -{ - float: right; - padding: 15px; -} - -#webrx-top-photo -{ - width: 100%; - display: block; -} - -#webrx-rx-avatar-background -{ - cursor:pointer; - background-image: url(gfx/openwebrx-avatar-background.png); - background-origin: content-box; - background-repeat: no-repeat; - float: left; - width: 54px; - height: 54px; - padding: 7px; -} - -#webrx-rx-avatar -{ - cursor:pointer; - width: 46px; - height: 46px; - padding: 4px; - border-radius: 8px; -} - -#webrx-top-photo-clip -{ - min-height: 67px; - max-height: 350px; - overflow: hidden; - position: relative; -} - #webrx-page-container { min-height:100%; position:relative; } -#webrx-rx-photo-title -{ - position: absolute; - left: 15px; - top: 78px; - color: White; - font-size: 16pt; - text-shadow: 1px 1px 4px #444; - opacity: 1; -} - -#webrx-rx-photo-desc -{ - position: absolute; - left: 15px; - top: 109px; - color: White; - font-size: 10pt; - font-weight: bold; - text-shadow: 0px 0px 6px #444; - opacity: 1; - line-height: 1.5em; -} - -#webrx-rx-photo-desc a -{ - color: #5ca8ff; - text-shadow: none; -} - -#webrx-rx-texts { - float: left; - padding: 10px; -} - -#webrx-rx-texts div { - padding: 3px; -} - -#webrx-rx-title -{ - white-space:nowrap; - overflow: hidden; - cursor:pointer; - font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; - color: #909090; - font-size: 11pt; - font-weight: bold; -} - -#webrx-rx-desc -{ - white-space:nowrap; - overflow: hidden; - cursor:pointer; - font-size: 10pt; - color: #909090; -} - -#webrx-rx-desc a -{ - color: #909090; -} - -#openwebrx-rx-details-arrow -{ - cursor:pointer; - position: absolute; - left: 470px; - top: 51px; -} - -#openwebrx-rx-details-arrow a -{ - margin: 0; - padding: 0; -} - -#openwebrx-rx-details-arrow-down -{ - display:none; -} - -/*canvas#waterfall-canvas -{ - border-style: none; - border-width: 1px; - height: 150px; - width: 100%; -}*/ - #openwebrx-scale-container { height: 47px; @@ -638,52 +464,6 @@ img.openwebrx-mirror-img height: 20px; } -#openwebrx-main-buttons img -{ -} - -#openwebrx-main-buttons ul -{ - display: table; - margin:0; -} - - -#openwebrx-main-buttons ul li -{ - display: table-cell; - padding-left: 5px; - padding-right: 5px; - cursor:pointer; -} - -#openwebrx-main-buttons a { - color: inherit; - text-decoration: inherit; -} - -#openwebrx-main-buttons li:hover -{ - background-color: rgba(255, 255, 255, 0.3); -} - -#openwebrx-main-buttons li:active -{ - background-color: rgba(255, 255, 255, 0.55); -} - - -#openwebrx-main-buttons -{ - float: right; - margin:0; - color: white; - text-shadow: 0px 0px 4px #000000; - text-align: center; - font-size: 9pt; - font-weight: bold; -} - #openwebrx-panel-receiver { width:110px; diff --git a/owrx/controllers.py b/owrx/controllers.py index f979891..0732f2f 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -2,6 +2,7 @@ import os import mimetypes import json from datetime import datetime +from string import Template from owrx.websocket import WebSocketConnection from owrx.config import PropertyManager from owrx.source import ClientRegistry @@ -72,14 +73,36 @@ class AssetsController(Controller): filename = self.request.matches.group(1) self.serve_file(filename) -class IndexController(AssetsController): - def handle_request(self): - self.serve_file("index.html") +class TemplateController(Controller): + def render_template(self, file, **vars): + f = open('htdocs/' + file, 'r') + template = Template(f.read()) + f.close() -class MapController(AssetsController): + return template.safe_substitute(**vars) + + def serve_template(self, file, **vars): + self.send_response(self.render_template(file, **vars), content_type = 'text/html') + + def default_variables(self): + return {} + + +class WebpageController(TemplateController): + def template_variables(self): + header = self.render_template('include/header.include.html') + return { "header": header } + + +class IndexController(WebpageController): + def handle_request(self): + self.serve_template("index.html", **self.template_variables()) + + +class MapController(WebpageController): def handle_request(self): #TODO check if we have a google maps api key first? - self.serve_file("map.html") + self.serve_template("map.html", **self.template_variables()) class FeatureController(AssetsController): def handle_request(self): From 649450a24c1d97bbdfad87fae708429861a81e86 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 13:44:04 +0200 Subject: [PATCH 051/118] move css --- htdocs/{ => css}/openwebrx.css | 24 ++++++++++++------------ htdocs/index.html | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) rename htdocs/{ => css}/openwebrx.css (95%) diff --git a/htdocs/openwebrx.css b/htdocs/css/openwebrx.css similarity index 95% rename from htdocs/openwebrx.css rename to htdocs/css/openwebrx.css index 39a5b95..13bb3fd 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -18,8 +18,8 @@ along with this program. If not, see . */ -@import url("css/openwebrx-header.css"); -@import url("css/openwebrx-globals.css"); +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); select { @@ -149,7 +149,7 @@ input[type=range]:focus::-ms-fill-upper #openwebrx-scale-container { height: 47px; - background-image: url("gfx/openwebrx-scale-background.png"); + background-image: url("../gfx/openwebrx-scale-background.png"); background-repeat: repeat-x; overflow: hidden; z-index:1000; @@ -158,14 +158,14 @@ input[type=range]:focus::-ms-fill-upper #webrx-canvas-container { - /*background-image:url('gfx/openwebrx-blank-background-1.jpg');*/ + /*background-image:url('../gfx/openwebrx-blank-background-1.jpg');*/ position: relative; height: 2000px; overflow-y: scroll; overflow-x: hidden; /*background-color: #646464;*/ /*background-image: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(0,0,0,1) 100%);*/ - background-image: url('gfx/openwebrx-background-cool-blue.png'); + background-image: url('../gfx/openwebrx-background-cool-blue.png'); background-repeat: no-repeat; background-color: #1e5f7f; cursor: crosshair; @@ -255,15 +255,15 @@ input[type=range]:focus::-ms-fill-upper /* removed non-free fonts like that: */ /*@font-face { font-family: 'unibody_8_pro_regregular'; - src: url('gfx/unibody8pro-regular-webfont.eot'); - src: url('gfx/unibody8pro-regular-webfont.ttf'); + src: url('../gfx/unibody8pro-regular-webfont.eot'); + src: url('../gfx/unibody8pro-regular-webfont.ttf'); font-weight: normal; font-style: normal; }*/ @font-face { font-family: 'expletus-sans-medium'; - src: url('gfx/font-expletus-sans/ExpletusSans-Medium.ttf'); + src: url('../gfx/font-expletus-sans/ExpletusSans-Medium.ttf'); font-weight: normal; font-style: normal; } @@ -737,7 +737,7 @@ img.openwebrx-mirror-img .openwebrx-meta-slot.muted:before { display: block; content: ""; - background-image: url("gfx/openwebrx-mute.png"); + background-image: url("../gfx/openwebrx-mute.png"); width:100%; height:133px; background-position: center; @@ -779,11 +779,11 @@ img.openwebrx-mirror-img } .openwebrx-meta-slot.active .openwebrx-meta-user-image { - background-image: url("gfx/openwebrx-directcall.png"); + background-image: url("../gfx/openwebrx-directcall.png"); } .openwebrx-meta-slot.active .openwebrx-meta-user-image.group { - background-image: url("gfx/openwebrx-groupcall.png"); + background-image: url("../gfx/openwebrx-groupcall.png"); } .openwebrx-dmr-timeslot-panel * { @@ -791,7 +791,7 @@ img.openwebrx-mirror-img } .openwebrx-maps-pin { - background-image: url("gfx/google_maps_pin.svg"); + background-image: url("../gfx/google_maps_pin.svg"); background-position: center; background-repeat: no-repeat; width: 15px; diff --git a/htdocs/index.html b/htdocs/index.html index c17d83b..ee688c5 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -28,7 +28,7 @@ - + From 688bd769dd90eaab0918815968c7e2cd4e9df47a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 13:44:41 +0200 Subject: [PATCH 052/118] move css --- htdocs/{ => css}/features.css | 0 htdocs/features.html | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename htdocs/{ => css}/features.css (100%) diff --git a/htdocs/features.css b/htdocs/css/features.css similarity index 100% rename from htdocs/features.css rename to htdocs/css/features.css diff --git a/htdocs/features.html b/htdocs/features.html index a4d2279..cfcfe66 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -1,7 +1,7 @@ OpenWebRX Feature report - + From 5887522dce12ed90f6cfc2a104f74f1781f34913 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 16:44:33 +0200 Subject: [PATCH 053/118] header for feature report --- htdocs/css/features.css | 8 ++++++++ htdocs/css/openwebrx-header.css | 2 ++ htdocs/features.html | 1 + owrx/controllers.py | 4 ++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/htdocs/css/features.css b/htdocs/css/features.css index cc821b1..7b0b008 100644 --- a/htdocs/css/features.css +++ b/htdocs/css/features.css @@ -1,3 +1,11 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +/* expandable photo not implemented on features page */ +#webrx-top-photo-clip { + max-height: 67px; +} + h1 { text-align: center; margin: 50px 0; diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index 9cfcc0c..ef0a129 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -63,6 +63,7 @@ width: 54px; height: 54px; padding: 7px; + box-sizing: content-box; } #webrx-rx-avatar @@ -72,6 +73,7 @@ height: 46px; padding: 4px; border-radius: 8px; + box-sizing: content-box; } #webrx-rx-texts { diff --git a/htdocs/features.html b/htdocs/features.html index cfcfe66..6e1eb55 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -6,6 +6,7 @@ + ${header}

    OpenWebRX Feature Report

    UTCdBDTFreqdBDTFreq Message
    ' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + msg['db'] + '' + msg['dt'] + '' + msg['freq'] + '' + msg['db'] + '' + msg['dt'] + '' + msg['freq'] + '' + linkedmsg + '
    ' + name + '
    diff --git a/owrx/controllers.py b/owrx/controllers.py index 0732f2f..c011677 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -104,9 +104,9 @@ class MapController(WebpageController): #TODO check if we have a google maps api key first? self.serve_template("map.html", **self.template_variables()) -class FeatureController(AssetsController): +class FeatureController(WebpageController): def handle_request(self): - self.serve_file("features.html") + self.serve_template("features.html", **self.template_variables()) class ApiController(Controller): def handle_request(self): From d2f524bf90e188bda1386ab4b8a6368b33794459 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 16:49:06 +0200 Subject: [PATCH 054/118] fix scrolling on feature report --- htdocs/css/openwebrx-globals.css | 2 +- htdocs/css/openwebrx.css | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/htdocs/css/openwebrx-globals.css b/htdocs/css/openwebrx-globals.css index a01982d..41ef284 100644 --- a/htdocs/css/openwebrx-globals.css +++ b/htdocs/css/openwebrx-globals.css @@ -4,5 +4,5 @@ html, body padding: 0; height: 100%; font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; - overflow: hidden; } + diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 13bb3fd..b23c50b 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -21,6 +21,10 @@ @import url("openwebrx-header.css"); @import url("openwebrx-globals.css"); +html, body { + overflow: hidden; +} + select { font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; From fdd2dd1b40ddad469da633f40fc52de5d9e6eca6 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 17:38:53 +0200 Subject: [PATCH 055/118] use flexbox since the header breaks the map height --- htdocs/css/map.css | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 7595ab7..57a7836 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -6,10 +6,17 @@ max-height: 67px; } -html, body, .openwebrx-map { - width: 100%; - height: 100%; - margin: 0; +body { + display: flex; + flex-direction: column; +} + +#webrx-top-container { + flex: none; +} + +.openwebrx-map { + flex: 1 1 auto; } h3 { From 5ada234f64196cd3b6751291067836d701adb133 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 19:37:00 +0200 Subject: [PATCH 056/118] remove javascript from the header --- htdocs/include/header.include.html | 16 ++++++++-------- htdocs/openwebrx.js | 9 +++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index b2f3bc9..c7efe13 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -5,21 +5,21 @@
    - +
    -
    -
    +
    +
    - - + +
      -

    • Status
    • -

    • Log
    • -

    • Receiver
    • +

    • Status
    • +

    • Log
    • +

    • Receiver

    • Map
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 1b4fe11..e004ae4 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -86,6 +86,7 @@ function init_rx_photo() window.setTimeout(function() { animate(e("webrx-rx-photo-title"),"opacity","",1,0,1,500,30); },1000); window.setTimeout(function() { animate(e("webrx-rx-photo-desc"),"opacity","",1,0,1,500,30); },1500); window.setTimeout(function() { close_rx_photo() },2500); + $('#webrx-top-container .openwebrx-photo-trigger').click(toggle_rx_photo); } dont_toggle_rx_photo_flag=0; @@ -2365,6 +2366,13 @@ function openwebrx_resize() check_top_bar_congestion(); } +function init_header() +{ + $('#openwebrx-main-buttons li[data-toggle-panel]').click(function() { + toggle_panel($(this).data('toggle-panel')); + }); +} + function openwebrx_init() { if(ios||is_chrome) e("openwebrx-big-grey").style.display="table-cell"; @@ -2377,6 +2385,7 @@ function openwebrx_init() window.setTimeout(function(){window.setInterval(debug_audio,1000);},1000); window.addEventListener("resize",openwebrx_resize); check_top_bar_congestion(); + init_header(); //Synchronise volume with slider updateVolume(); From d606c854436d9b2a71e53ddf101c94ee88a5d8e7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 20:48:02 +0200 Subject: [PATCH 057/118] separate decoder files --- owrx/wsjt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 9fe3bbc..1e8c271 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -25,7 +25,10 @@ class Ft8Chopper(threading.Thread): super().__init__() def getWaveFile(self): - filename = "/tmp/openwebrx-ft8chopper-{0}.wav".format(datetime.now().strftime("%Y%m%d-%H%M%S")) + filename = "/tmp/openwebrx-ft8chopper-{id}-{timestamp}.wav".format( + id = id(self), + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + ) wavefile = wave.open(filename, "wb") wavefile.setnchannels(1) wavefile.setsampwidth(2) From 8edc7c1374ec6a4b8e97525308fafc07cd9b0001 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 20:53:59 +0200 Subject: [PATCH 058/118] sort by lastseen --- htdocs/map.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/htdocs/map.js b/htdocs/map.js index b104a0a..4079312 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -181,6 +181,8 @@ return {callsign: callsign, locator: r.locator, lastseen: r.lastseen} }).filter(function(d) { return d.locator == locator; + }).sort(function(a, b){ + return b.lastseen - a.lastseen; }); infowindow.setContent( '

    Locator: ' + locator + '

    ' + From acbf2939c97671db042806b8e546ca5aa7ef2691 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 21:21:01 +0200 Subject: [PATCH 059/118] infowindow for ysf markers --- htdocs/css/map.css | 1 + htdocs/map.js | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 57a7836..4b27afe 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -21,6 +21,7 @@ body { h3 { margin: 10px 0; + text-align: center; } ul { diff --git a/htdocs/map.js b/htdocs/map.js index 4079312..6354604 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -46,6 +46,9 @@ marker = markers[update.callsign]; } else { marker = new google.maps.Marker(); + marker.addListener('click', function(){ + showMarkerInfoWindow(update.callsign, pos); + }); markers[update.callsign] = marker; } marker.setOptions($.extend({ @@ -58,6 +61,7 @@ // TODO the trim should happen on the server side if (expectedCallsign && expectedCallsign == update.callsign.trim()) { map.panTo(pos); + showMarkerInfoWindow(update.callsign, pos); delete(expectedCallsign); } break; @@ -72,7 +76,7 @@ } else { rectangle = new google.maps.Rectangle(); rectangle.addListener('click', function(){ - showInfoWindow(update.location.locator, center); + showLocatorInfoWindow(update.location.locator, center); }); rectangles[update.callsign] = rectangle; } @@ -93,7 +97,7 @@ if (expectedLocator && expectedLocator == update.location.locator) { map.panTo(center); - showInfoWindow(expectedLocator, center); + showLocatorInfoWindow(expectedLocator, center); delete(expectedLocator); } break; @@ -175,7 +179,7 @@ connect(); var infowindow; - var showInfoWindow = function(locator, pos) { + var showLocatorInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); var inLocator = $.map(rectangles, function(r, callsign) { return {callsign: callsign, locator: r.locator, lastseen: r.lastseen} @@ -198,6 +202,17 @@ infowindow.open(map); }; + var showMarkerInfoWindow = function(callsign, pos) { + if (!infowindow) infowindow = new google.maps.InfoWindow(); + var marker = markers[callsign]; + var timestring = moment(marker.lastseen).fromNow(); + infowindow.setContent( + '

    ' + callsign + '

    ' + + '
    ' + timestring + '
    ' + ); + infowindow.open(map, marker); + } + var getScale = function(lastseen) { var age = new Date().getTime() - lastseen; var scale = 1; From 2470c2bfa6ba63ed02dee3d5071c5a5d078f6dab Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Thu, 11 Jul 2019 23:40:09 +0200 Subject: [PATCH 060/118] pass through the mode on the map --- htdocs/map.js | 8 +++++--- owrx/map.py | 10 ++++++---- owrx/meta.py | 3 ++- owrx/wsjt.py | 14 +++++++++++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index 6354604..d2253ad 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -57,6 +57,7 @@ title: update.callsign }, getMarkerOpacityOptions(update.lastseen) )); marker.lastseen = update.lastseen; + marker.mode = update.mode; // TODO the trim should happen on the server side if (expectedCallsign && expectedCallsign == update.callsign.trim()) { @@ -94,6 +95,7 @@ }, getRectangleOpacityOptions(update.lastseen) )); rectangle.lastseen = update.lastseen; rectangle.locator = update.location.locator; + rectangle.mode = update.mode; if (expectedLocator && expectedLocator == update.location.locator) { map.panTo(center); @@ -182,7 +184,7 @@ var showLocatorInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); var inLocator = $.map(rectangles, function(r, callsign) { - return {callsign: callsign, locator: r.locator, lastseen: r.lastseen} + return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode} }).filter(function(d) { return d.locator == locator; }).sort(function(a, b){ @@ -194,7 +196,7 @@ '
      ' + inLocator.map(function(i){ var timestring = moment(i.lastseen).fromNow(); - return '
    • ' + i.callsign + ' (' + timestring + ')
    • ' + return '
    • ' + i.callsign + ' (' + timestring + ' via ' + i.mode + ')
    • ' }).join("") + '
    ' ); @@ -208,7 +210,7 @@ var timestring = moment(marker.lastseen).fromNow(); infowindow.setContent( '

    ' + callsign + '

    ' + - '
    ' + timestring + '
    ' + '
    ' + timestring + ' via ' + marker.mode + '
    ' ); infowindow.open(map, marker); } diff --git a/owrx/map.py b/owrx/map.py index 2819a3e..4d65a49 100644 --- a/owrx/map.py +++ b/owrx/map.py @@ -44,7 +44,8 @@ class Map(object): { "callsign": callsign, "location": record["location"].__dict__(), - "lastseen": record["updated"].timestamp() * 1000 + "lastseen": record["updated"].timestamp() * 1000, + "mode" : record["mode"] } for (callsign, record) in self.positions.items() ]) @@ -55,14 +56,15 @@ class Map(object): except ValueError: pass - def updateLocation(self, callsign, loc: Location): + def updateLocation(self, callsign, loc: Location, mode: str): ts = datetime.now() - self.positions[callsign] = {"location": loc, "updated": ts} + self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode} self.broadcast([ { "callsign": callsign, "location": loc.__dict__(), - "lastseen": ts.timestamp() * 1000 + "lastseen": ts.timestamp() * 1000, + "mode" : mode } ]) diff --git a/owrx/meta.py b/owrx/meta.py index ad3f0d3..8a85bad 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -71,7 +71,8 @@ class YsfMetaEnricher(object): def enrich(self, meta): if "source" in meta and "lat" in meta and "lon" in meta: # TODO parsing the float values should probably happen earlier - Map.getSharedInstance().updateLocation(meta["source"], LatLngLocation(float(meta["lat"]), float(meta["lon"]))) + loc = LatLngLocation(float(meta["lat"]), float(meta["lon"])) + Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF") return None diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 1e8c271..6bb6dfe 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -118,9 +118,15 @@ class WsjtParser(object): self.handler = handler self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$") + modes = { + "~": "FT8" + } + def parse(self, data): try: msg = data.decode().rstrip() + # sample + # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' # known debug messages we know to skip if msg.startswith(""): return @@ -133,15 +139,17 @@ class WsjtParser(object): out["db"] = float(msg[7:10]) out["dt"] = float(msg[11:15]) out["freq"] = int(msg[16:20]) + modeChar = msg[21:22] + out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" wsjt_msg = msg[24:60].strip() - self.getLocator(wsjt_msg) + self.parseLocator(wsjt_msg, mode) out["msg"] = wsjt_msg self.handler.write_wsjt_message(out) except ValueError: logger.exception("error while parsing wsjt message") - def getLocator(self, msg): + def parseLocator(self, msg, mode): m = self.locator_pattern.match(msg) if m is None: return @@ -149,4 +157,4 @@ class WsjtParser(object): # likely this just means roger roger goodbye. if m.group(2) == "RR73": return - Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2))) + Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode) From c19337d65cead6b2712b6f8e688bf1e3473ef9c9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Jul 2019 19:28:40 +0200 Subject: [PATCH 061/118] fix ft8/usb switchover --- csdr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csdr.py b/csdr.py index 9203d17..4f72c98 100755 --- a/csdr.py +++ b/csdr.py @@ -203,6 +203,7 @@ class dsp(object): if self.get_secondary_demodulator() == what: return self.secondary_demodulator = what + self.calculate_decimation() self.restart() def secondary_fft_block_size(self): From efc5b936f83c34583071e576fa099a59d07803ac Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 12 Jul 2019 19:34:04 +0200 Subject: [PATCH 062/118] clean up after use --- owrx/wsjt.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 6bb6dfe..7bb10cc 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -105,6 +105,10 @@ class Ft8Chopper(threading.Thread): self.outputReader.close() self.outputWriter.close() self.emptyScheduler() + try: + os.unlink(self.wavefilename) + except Exception: + logger.exception("error removing undecoded file") def read(self): try: From 935e79c9c2f1be4ec291ed549baafbdba8cad3c9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 17:16:38 +0200 Subject: [PATCH 063/118] use a temporary directory to avoid permission problems --- config_webrx.py | 4 +++- csdr.py | 6 +++++- owrx/source.py | 8 +++++--- owrx/wsjt.py | 7 +++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index 12f9c14..eaba803 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -236,4 +236,6 @@ google_maps_api_key = "" # how long should positions be visible on the map? # they will start fading out after half of that # in seconds; default: 2 hours -map_position_retention_time = 2 * 60 * 60 \ No newline at end of file +map_position_retention_time = 2 * 60 * 60 + +temporary_directory = "/tmp" diff --git a/csdr.py b/csdr.py index 4f72c98..43bd0ac 100755 --- a/csdr.py +++ b/csdr.py @@ -74,6 +74,10 @@ class dsp(object): self.unvoiced_quality = 1 self.modification_lock = threading.Lock() self.output = output + self.temporary_directory = "/tmp" + + def set_temporary_directory(self, what): + self.temporary_directory = what def chain(self,which): chain = ["nc -v 127.0.0.1 {nc_port}"] @@ -468,7 +472,7 @@ class dsp(object): logger.debug(command_base) #create control pipes for csdr - self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self)) + self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self)) self.try_create_pipes(self.pipe_names, command_base) diff --git a/owrx/source.py b/owrx/source.py index 3f1b2ac..7a37ef6 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -275,7 +275,7 @@ class SpectrumThread(csdr.output): self.props = props = self.sdrSource.props.collect( "samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor", "fft_compression", - "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through" + "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", "temporary_directory" ).defaults(PropertyManager.getSharedInstance()) self.dsp = dsp = csdr.dsp(self) @@ -295,6 +295,7 @@ class SpectrumThread(csdr.output): props.getProperty("fft_size").wire(dsp.set_fft_size), props.getProperty("fft_fps").wire(dsp.set_fft_fps), props.getProperty("fft_compression").wire(dsp.set_fft_compression), + props.getProperty("temporary_directory").wire(dsp.set_temporary_directory), props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages) ] @@ -352,7 +353,7 @@ class DspManager(csdr.output): self.localProps = self.sdrSource.getProps().collect( "audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", - "dmr_filter" + "dmr_filter", "temporary_directory" ).defaults(PropertyManager.getSharedInstance()) self.dsp = csdr.dsp(self) @@ -380,7 +381,8 @@ class DspManager(csdr.output): self.localProps.getProperty("high_cut").wire(set_high_cut), self.localProps.getProperty("mod").wire(self.dsp.set_demodulator), self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality), - self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter) + self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter), + self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory) ] self.dsp.set_offset_freq(0) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 7bb10cc..fc07b10 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -8,6 +8,7 @@ import os from multiprocessing.connection import Pipe from owrx.map import Map, LocatorLocation import re +from owrx.config import PropertyManager import logging logger = logging.getLogger(__name__) @@ -16,6 +17,7 @@ logger = logging.getLogger(__name__) class Ft8Chopper(threading.Thread): def __init__(self, source): self.source = source + self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"] (self.wavefilename, self.wavefile) = self.getWaveFile() self.switchingLock = threading.Lock() self.scheduler = sched.scheduler(time.time, time.sleep) @@ -25,7 +27,8 @@ class Ft8Chopper(threading.Thread): super().__init__() def getWaveFile(self): - filename = "/tmp/openwebrx-ft8chopper-{id}-{timestamp}.wav".format( + filename = "{tmp_dir}/openwebrx-ft8chopper-{id}-{timestamp}.wav".format( + tmp_dir = self.tmp_dir, id = id(self), timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") ) @@ -70,7 +73,7 @@ class Ft8Chopper(threading.Thread): def decode(self): def decode_and_unlink(file): #TODO expose decoding quality parameters through config - decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE) + decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE, cwd=self.tmp_dir) while True: line = decoder.stdout.readline() if line is None or (isinstance(line, bytes) and len(line) == 0): From 9a25c68d9a4b0c0a03baf181cb3c34d89c59c949 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 17:20:03 +0200 Subject: [PATCH 064/118] wording change --- htdocs/map.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/map.js b/htdocs/map.js index d2253ad..82f4a59 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -196,7 +196,7 @@ '
      ' + inLocator.map(function(i){ var timestring = moment(i.lastseen).fromNow(); - return '
    • ' + i.callsign + ' (' + timestring + ' via ' + i.mode + ')
    • ' + return '
    • ' + i.callsign + ' (' + timestring + ' using ' + i.mode + ')
    • ' }).join("") + '
    ' ); @@ -210,7 +210,7 @@ var timestring = moment(marker.lastseen).fromNow(); infowindow.setContent( '

    ' + callsign + '

    ' + - '
    ' + timestring + ' via ' + marker.mode + '
    ' + '
    ' + timestring + ' using ' + marker.mode + '
    ' ); infowindow.open(map, marker); } From 95c117973f58c9d25ed202c3998bad7d010c5bd7 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 18:59:06 +0200 Subject: [PATCH 065/118] update readme with new image --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d505a79..493f71c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ It has the following features: - [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF) - [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN) +**News (2019-07-13 by DD5JFK)** +- Latest Features: + - FT8 Integration (using wsjt-x demodulators) + - New Map Feature that shows both decoded grid squares from FT8 and Locations decoded from YSF digital voice + - New Feature report that will show what functionality is available +- There's a new Raspbian SD Card image available (see below) + **News (2019-06-30 by DD5JFK)** - I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near future. Please check this place for updates. - My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official version. @@ -42,7 +49,7 @@ It has the following features: ### Raspberry Pi SD Card Images -Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-06-21-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+. +Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-07-13-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+. This is based off the Raspbian Lite distribution, so [their installation instructions](https://www.raspberrypi.org/documentation/installation/installing-images/) apply. From f490fbc2c9c19df4282c215a280982aa802f6c0d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 21:35:57 +0200 Subject: [PATCH 066/118] update dependencies add wsjt-x to build for ft8 capabilities --- docker/scripts/install-dependencies-hackrf.sh | 4 ++-- docker/scripts/install-dependencies-sdrplay.sh | 4 ++-- docker/scripts/install-dependencies-soapysdr.sh | 4 +++- docker/scripts/install-dependencies.sh | 14 +++++++++++--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docker/scripts/install-dependencies-hackrf.sh b/docker/scripts/install-dependencies-hackrf.sh index 1a460cc..4786644 100755 --- a/docker/scripts/install-dependencies-hackrf.sh +++ b/docker/scripts/install-dependencies-hackrf.sh @@ -14,8 +14,8 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb fftw" -BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev fftw-dev" +STATIC_PACKAGES="libusb fftw udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev fftw-dev" apk add --no-cache $STATIC_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES diff --git a/docker/scripts/install-dependencies-sdrplay.sh b/docker/scripts/install-dependencies-sdrplay.sh index 3ac29cc..fba8598 100755 --- a/docker/scripts/install-dependencies-sdrplay.sh +++ b/docker/scripts/install-dependencies-sdrplay.sh @@ -14,8 +14,8 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb" -BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev" +STATIC_PACKAGES="libusb udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev" apk add --no-cache $STATIC_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES diff --git a/docker/scripts/install-dependencies-soapysdr.sh b/docker/scripts/install-dependencies-soapysdr.sh index 9e598c7..1731ed8 100755 --- a/docker/scripts/install-dependencies-soapysdr.sh +++ b/docker/scripts/install-dependencies-soapysdr.sh @@ -14,8 +14,10 @@ function cmakebuild() { cd /tmp -BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++" +STATIC_PACKAGES="udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++" +apk add --no-cache $STATIC_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES git clone https://github.com/pothosware/SoapySDR diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index 48136b2..46d698c 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -14,8 +14,8 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack" -BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers" +STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack libusb qt5-qtbase qt5-qtmultimedia qt5-qtserialport" +BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers autoconf automake libtool texinfo gfortran libusb-dev qt5-qtbase-dev qt5-qtmultimedia-dev qt5-qtserialport-dev asciidoctor asciidoc" apk add --no-cache $STATIC_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES @@ -23,7 +23,7 @@ apk add --no-cache --virtual .build-deps $BUILD_PACKAGES git clone https://git.code.sf.net/p/itpp/git itpp cmakebuild itpp -git clone https://github.com/simonyiszk/csdr.git +git clone https://github.com/jketterl/csdr.git -b 48khz_filter cd csdr patch -Np1 <<'EOF' --- a/csdr.c @@ -68,6 +68,8 @@ rm -rf csdr git clone https://github.com/szechyjs/mbelib.git cmakebuild mbelib +# no idea why it's put into there now. alpine does not handle it correctly, so move it. +mv /usr/local/lib64/libmbe* /usr/local/lib git clone https://github.com/jketterl/digiham.git cmakebuild digiham @@ -75,4 +77,10 @@ cmakebuild digiham git clone https://github.com/f4exb/dsd.git cmakebuild dsd +WSJT_DIR=wsjtx-2.0.1 +WSJT_TGZ=${WSJT_DIR}.tgz +wget http://physics.princeton.edu/pulsar/k1jt/$WSJT_TGZ +tar xvfz $WSJT_TGZ +cmakebuild $WSJT_DIR + apk del .build-deps From 9f2b715d9ffbecbfeec1799f7137e2af6c34f013 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 21:40:48 +0200 Subject: [PATCH 067/118] exponential backoff --- htdocs/openwebrx.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index e004ae4..6917541 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1472,6 +1472,7 @@ function on_ws_opened() { ws.send("SERVER DE CLIENT client=openwebrx.js type=receiver"); divlog("WebSocket opened to "+ws_url); + reconnect_timeout = false; } var was_error=0; @@ -1852,6 +1853,8 @@ function audio_init() } +var reconnect_timeout = false; + function on_ws_closed() { try @@ -1860,9 +1863,16 @@ function on_ws_closed() } catch (dont_care) {} audio_initialized = 0; - divlog("WebSocket has closed unexpectedly. Attempting to reconnect in 5 seconds...", 1); + if (reconnect_timeout) { + // max value: roundabout 8 and a half minutes + reconnect_timeout = Math.min(reconnect_timeout * 2, 512000); + } else { + // initial value: 1s + reconnect_timeout = 1000; + } + divlog("WebSocket has closed unexpectedly. Attempting to reconnect in " + reconnect_timeout / 1000 + " seconds...", 1); - setTimeout(open_websocket, 5000); + setTimeout(open_websocket, reconnect_timeout); } function on_ws_error(event) From 420b0c60d75241f92f56dc8d8311a49c86bb4092 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 21:44:48 +0200 Subject: [PATCH 068/118] exponential backoff, part 2 --- htdocs/map.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/htdocs/map.js b/htdocs/map.js index 82f4a59..b5f905a 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -115,10 +115,13 @@ rectangles = {}; }; + var reconnect_timeout = false; + var connect = function(){ var ws = new WebSocket(ws_url); ws.onopen = function(){ ws.send("SERVER DE CLIENT client=map.js type=map"); + reconnect_timeout = false }; ws.onmessage = function(e){ @@ -163,7 +166,14 @@ }; ws.onclose = function(){ clearMap(); - setTimeout(connect, 5000); + if (reconnect_timeout) { + // max value: roundabout 8 and a half minutes + reconnect_timeout = Math.min(reconnect_timeout * 2, 512000); + } else { + // initial value: 1s + reconnect_timeout = 1000; + } + setTimeout(connect, reconnect_timeout); }; window.onbeforeunload = function() { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript From 6d5c8491e4de7b3e5990cc9c1ede4c2665dc382e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 23:16:25 +0200 Subject: [PATCH 069/118] implement wspr --- csdr.py | 23 +++++--- htdocs/css/openwebrx.css | 12 +++-- htdocs/index.html | 1 + htdocs/openwebrx.js | 16 +++--- owrx/feature.py | 25 ++++++--- owrx/wsjt.py | 111 ++++++++++++++++++++++++++++++--------- 6 files changed, 135 insertions(+), 53 deletions(-) diff --git a/csdr.py b/csdr.py index 43bd0ac..d9452c0 100755 --- a/csdr.py +++ b/csdr.py @@ -25,7 +25,7 @@ import os import signal import threading from functools import partial -from owrx.wsjt import Ft8Chopper +from owrx.wsjt import Ft8Chopper, WsprChopper import logging logger = logging.getLogger(__name__) @@ -174,7 +174,7 @@ class dsp(object): "csdr limit_ff" ] # fixed sample rate necessary for the wsjt-x tools. fix with sox... - if self.get_secondary_demodulator() == "ft8" and self.get_audio_rate() != self.get_output_rate(): + if self.isWsjtMode() and self.get_audio_rate() != self.get_output_rate(): chain += [ "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " ] @@ -196,8 +196,8 @@ class dsp(object): "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \ "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \ "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8" - elif which == "ft8": - chain = secondary_chain_base + "csdr realpart_cf | " + elif self.isWsjtMode(which): + chain = secondary_chain_base + "csdr realpart_cf | " if self.last_decimation != 1.0 : chain += "csdr fractional_decimator_ff {last_decimation} | " chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16" @@ -271,8 +271,12 @@ class dsp(object): self.secondary_processes_running = True self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read()))) - if self.get_secondary_demodulator() == "ft8": - chopper = Ft8Chopper(self.secondary_process_demod.stdout) + if self.isWsjtMode(): + smd = self.get_secondary_demodulator() + if smd == "ft8": + chopper = Ft8Chopper(self.secondary_process_demod.stdout) + elif smd == "wspr": + chopper = WsprChopper(self.secondary_process_demod.stdout) chopper.start() self.output.add_output("wsjt_demod", chopper.read) else: @@ -355,7 +359,7 @@ class dsp(object): def get_audio_rate(self): if self.isDigitalVoice(): return 48000 - elif self.secondary_demodulator == "ft8": + elif self.isWsjtMode(): return 12000 return self.get_output_rate() @@ -364,6 +368,11 @@ class dsp(object): demodulator = self.get_demodulator() return demodulator in ["dmr", "dstar", "nxdn", "ysf"] + def isWsjtMode(self, demodulator = None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator in ["ft8", "wspr"] + def set_output_rate(self,output_rate): self.output_rate=output_rate self.calculate_decimation() diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index b23c50b..87b0608 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -835,15 +835,21 @@ img.openwebrx-mirror-img width: 35px; } -#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container { +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container +{ display: none; } -#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container { +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container +{ height: 200px; margin: -10px; } -#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel { +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel +{ display: none; } \ No newline at end of file diff --git a/htdocs/index.html b/htdocs/index.html index ee688c5..45e94e4 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -81,6 +81,7 @@ +
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 6917541..c47786e 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -2693,6 +2693,7 @@ function demodulator_digital_replace(subtype) case "bpsk31": case "rtty": case "ft8": + case "wspr": secondary_demod_start(subtype); demodulator_analog_replace('usb', true); demodulator_buttons_update(); @@ -2700,7 +2701,7 @@ function demodulator_digital_replace(subtype) } $('#openwebrx-panel-digimodes').attr('data-mode', subtype); toggle_panel("openwebrx-panel-digimodes", true); - toggle_panel("openwebrx-panel-wsjt-message", subtype == 'ft8'); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr'].indexOf(subtype) >= 0); } function secondary_demod_create_canvas() @@ -2862,20 +2863,17 @@ function secondary_demod_waterfall_dequeue() secondary_demod_listbox_updating = false; function secondary_demod_listbox_changed() { - if(secondary_demod_listbox_updating) return; - switch ($("#openwebrx-secondary-demod-listbox")[0].value) - { + if (secondary_demod_listbox_updating) return; + var sdm = $("#openwebrx-secondary-demod-listbox")[0].value; + switch (sdm) { case "none": demodulator_analog_replace_last(); break; case "bpsk31": - demodulator_digital_replace('bpsk31'); - break; case "rtty": - demodulator_digital_replace('rtty'); - break; case "ft8": - demodulator_digital_replace('ft8'); + case "wspr": + demodulator_digital_replace(sdm); break; } } diff --git a/owrx/feature.py b/owrx/feature.py index 9009e13..cd67f62 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -165,13 +165,15 @@ class FeatureDetector(object): return version >= required_version except FileNotFoundError: return False - return reduce(and_, - map( - check_digiham_version, - ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator", - "digitalvoice_filter"] - ), - True) + return reduce( + and_, + map( + check_digiham_version, + ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator", + "digitalvoice_filter"] + ), + True + ) def has_dsd(self): """ @@ -201,4 +203,11 @@ class FeatureDetector(object): [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions on how to build from source. """ - return self.command_is_runnable("jt9") + return reduce( + and_, + map( + self.command_is_runnable, + ["jt9", "wsprd"] + ), + True + ) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index fc07b10..5d0c447 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -14,7 +14,7 @@ import logging logger = logging.getLogger(__name__) -class Ft8Chopper(threading.Thread): +class WsjtChopper(threading.Thread): def __init__(self, source): self.source = source self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"] @@ -27,7 +27,7 @@ class Ft8Chopper(threading.Thread): super().__init__() def getWaveFile(self): - filename = "{tmp_dir}/openwebrx-ft8chopper-{id}-{timestamp}.wav".format( + filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format( tmp_dir = self.tmp_dir, id = id(self), timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") @@ -40,11 +40,10 @@ class Ft8Chopper(threading.Thread): def getNextDecodingTime(self): t = datetime.now() - seconds = (int(t.second / 15) + 1) * 15 - if seconds >= 60: - t = t + timedelta(minutes = 1) - seconds = 0 - t = t.replace(second = seconds, microsecond = 0) + zeroed = t.replace(minute=0, second=0, microsecond=0) + delta = t - zeroed + seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval + t = zeroed + timedelta(seconds = seconds) logger.debug("scheduling: {0}".format(t)) return t.timestamp() @@ -70,10 +69,15 @@ class Ft8Chopper(threading.Thread): self.fileQueue.append(filename) self._scheduleNextSwitch() + def decoder_commandline(self, file): + ''' + must be overridden in child classes + ''' + return [] + def decode(self): def decode_and_unlink(file): - #TODO expose decoding quality parameters through config - decoder = subprocess.Popen(["jt9", "--ft8", "-d", "3", file], stdout=subprocess.PIPE, cwd=self.tmp_dir) + decoder = subprocess.Popen(self.decoder_commandline(file), stdout=subprocess.PIPE, cwd=self.tmp_dir) while True: line = decoder.stdout.readline() if line is None or (isinstance(line, bytes) and len(line) == 0): @@ -91,12 +95,12 @@ class Ft8Chopper(threading.Thread): threading.Thread(target=decode_and_unlink, args=[file]).start() def run(self) -> None: - logger.debug("FT8 chopper starting up") + logger.debug("WSJT chopper starting up") self.startScheduler() while self.doRun: data = self.source.read(256) if data is None or (isinstance(data, bytes) and len(data) == 0): - logger.warning("zero read on ft8 chopper") + logger.warning("zero read on WSJT chopper") self.doRun = False else: self.switchingLock.acquire() @@ -104,7 +108,7 @@ class Ft8Chopper(threading.Thread): self.switchingLock.release() self.decode() - logger.debug("FT8 chopper shutting down") + logger.debug("WSJT chopper shutting down") self.outputReader.close() self.outputWriter.close() self.emptyScheduler() @@ -120,10 +124,34 @@ class Ft8Chopper(threading.Thread): return None +class Ft8Chopper(WsjtChopper): + def __init__(self, source): + self.interval = 15 + super().__init__(source) + + def decoder_commandline(self, file): + #TODO expose decoding quality parameters through config + return ["jt9", "--ft8", "-d", "3", file] + + +class WsprChopper(WsjtChopper): + def __init__(self, source): + self.interval = 120 + super().__init__(source) + + def decoder_commandline(self, file): + #TODO expose decoding quality parameters through config + return ["wsprd", "-d", file] + + class WsjtParser(object): + locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") + jt9_pattern = re.compile("^[0-9]{6} .*") + wspr_pattern = re.compile("^[0-9]{4} .*") + wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-90]+)") + def __init__(self, handler): self.handler = handler - self.locator_pattern = re.compile(".*\s([A-Z0-9]+)\s([A-R]{2}[0-9]{2})$") modes = { "~": "FT8" @@ -132,8 +160,6 @@ class WsjtParser(object): def parse(self, data): try: msg = data.decode().rstrip() - # sample - # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' # known debug messages we know to skip if msg.startswith(""): return @@ -141,23 +167,33 @@ class WsjtParser(object): return out = {} - ts = datetime.strptime(msg[0:6], "%H%M%S") - out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) - out["db"] = float(msg[7:10]) - out["dt"] = float(msg[11:15]) - out["freq"] = int(msg[16:20]) - modeChar = msg[21:22] - out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" - wsjt_msg = msg[24:60].strip() - self.parseLocator(wsjt_msg, mode) - out["msg"] = wsjt_msg + if WsjtParser.jt9_pattern.match(msg): + out = self.parse_from_jt9(msg) + elif WsjtParser.wspr_pattern.match(msg): + out = self.parse_from_wsprd(msg) self.handler.write_wsjt_message(out) except ValueError: logger.exception("error while parsing wsjt message") + def parse_from_jt9(self, msg): + # ft8 sample + # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' + out = {} + ts = datetime.strptime(msg[0:6], "%H%M%S") + out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) + out["db"] = float(msg[7:10]) + out["dt"] = float(msg[11:15]) + out["freq"] = int(msg[16:20]) + modeChar = msg[21:22] + out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" + wsjt_msg = msg[24:60].strip() + self.parseLocator(wsjt_msg, mode) + out["msg"] = wsjt_msg + return out + def parseLocator(self, msg, mode): - m = self.locator_pattern.match(msg) + m = WsjtParser.locator_pattern.match(msg) if m is None: return # this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very @@ -165,3 +201,26 @@ class WsjtParser(object): if m.group(2) == "RR73": return Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode) + + def parse_from_wsprd(self, msg): + # wspr sample + # '2600 -24 0.4 0.001492 -1 G8AXA JO01 33' + out = {} + now = datetime.now() + ts = datetime.strptime(msg[0:4], "%M%S").replace(hour=now.hour) + out["timestamp"] = int(datetime.combine(date.today(), ts.time(), now.tzinfo).timestamp() * 1000) + out["db"] = float(msg[5:8]) + out["dt"] = float(msg[9:13]) + out["freq"] = float(msg[14:24]) + out["drift"] = int(msg[25:28]) + out["mode"] = "wspr" + wsjt_msg = msg[29:60].strip() + out["msg"] = wsjt_msg + self.parseWsprMessage(wsjt_msg) + return out + + def parseWsprMessage(self, msg): + m = WsjtParser.wspr_splitter_pattern.match(msg) + if m is None: + return + Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR") From a6f294f36187058c1993d9e8226a05603d88858e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 13 Jul 2019 21:51:30 +0000 Subject: [PATCH 070/118] lib64 hack only if lib64 exists --- docker/scripts/install-dependencies.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index 46d698c..2c7883e 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -68,8 +68,10 @@ rm -rf csdr git clone https://github.com/szechyjs/mbelib.git cmakebuild mbelib -# no idea why it's put into there now. alpine does not handle it correctly, so move it. -mv /usr/local/lib64/libmbe* /usr/local/lib +if [ -d "/usr/local/lib64" ]; then + # no idea why it's put into there now. alpine does not handle it correctly, so move it. + mv /usr/local/lib64/libmbe* /usr/local/lib +fi git clone https://github.com/jketterl/digiham.git cmakebuild digiham From 69c3a6379438b30894ba2170c75a852352f1157c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Jul 2019 14:33:30 +0200 Subject: [PATCH 071/118] link the map in wpsr messages, too --- htdocs/openwebrx.js | 21 ++++++++++++++++++--- owrx/wsjt.py | 6 +++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index c47786e..2952057 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1376,14 +1376,29 @@ function update_metadata(meta) { } +function html_escape(input) { + return $('
    ').text(input).html() +} + function update_wsjt_panel(msg) { var $b = $('#openwebrx-panel-wsjt-message tbody'); var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } var linkedmsg = msg['msg']; - var matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); - if (matches && matches[2] != 'RR73') { - linkedmsg = matches[1] + '' + matches[2] + ''; + if (msg['mode'] == 'FT8') { + var matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); + if (matches && matches[2] != 'RR73') { + linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; + } else { + linkedmsg = html_escape(linkedmsg); + } + } else if (msg['mode'] == 'WSPR') { + var matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/); + if (matches) { + linkedmsg = html_escape(matches[1]) + '' + matches[2] + '' + html_escape(matches[3]); + } else { + linkedmsg = html_escape(linkedmsg); + } } $b.append($( '
    ' + diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 5d0c447..925b3b0 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -148,7 +148,7 @@ class WsjtParser(object): locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") jt9_pattern = re.compile("^[0-9]{6} .*") wspr_pattern = re.compile("^[0-9]{4} .*") - wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-90]+)") + wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") def __init__(self, handler): self.handler = handler @@ -213,8 +213,8 @@ class WsjtParser(object): out["dt"] = float(msg[9:13]) out["freq"] = float(msg[14:24]) out["drift"] = int(msg[25:28]) - out["mode"] = "wspr" - wsjt_msg = msg[29:60].strip() + out["mode"] = "WSPR" + wsjt_msg = msg[29:].strip() out["msg"] = wsjt_msg self.parseWsprMessage(wsjt_msg) return out From 30b46c4cdd939586d0f9341158a813d8cd2f7880 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Jul 2019 14:43:44 +0200 Subject: [PATCH 072/118] allocate more space to the freq column --- htdocs/css/openwebrx.css | 6 +++++- htdocs/index.html | 2 +- htdocs/openwebrx.js | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 87b0608..a251a3a 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -827,7 +827,7 @@ img.openwebrx-mirror-img } #openwebrx-panel-wsjt-message .message { - width: 400px; + width: 380px; } #openwebrx-panel-wsjt-message .decimal { @@ -835,6 +835,10 @@ img.openwebrx-mirror-img width: 35px; } +#openwebrx-panel-wsjt-message .decimal.freq { + width: 70px; +} + #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container { diff --git a/htdocs/index.html b/htdocs/index.html index 45e94e4..8c9aa50 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -147,7 +147,7 @@ - + diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 2952057..92905cf 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1405,7 +1405,7 @@ function update_wsjt_panel(msg) { '' + '' + '' + - '' + + '' + '' + '' )); From 7dcfead84317e396f50cd456c3cdc7c410752722 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Jul 2019 17:09:34 +0200 Subject: [PATCH 073/118] let's try to implement jt65 and jt9 as well --- csdr.py | 8 +++++-- htdocs/css/openwebrx.css | 18 ++++++++------- htdocs/index.html | 2 ++ htdocs/openwebrx.js | 8 +++++-- owrx/wsjt.py | 47 ++++++++++++++++++++++++++++++++-------- 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/csdr.py b/csdr.py index d9452c0..05b8973 100755 --- a/csdr.py +++ b/csdr.py @@ -25,7 +25,7 @@ import os import signal import threading from functools import partial -from owrx.wsjt import Ft8Chopper, WsprChopper +from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper import logging logger = logging.getLogger(__name__) @@ -277,6 +277,10 @@ class dsp(object): chopper = Ft8Chopper(self.secondary_process_demod.stdout) elif smd == "wspr": chopper = WsprChopper(self.secondary_process_demod.stdout) + elif smd == "jt65": + chopper = Jt65Chopper(self.secondary_process_demod.stdout) + elif smd == "jt9": + chopper = Jt9Chopper(self.secondary_process_demod.stdout) chopper.start() self.output.add_output("wsjt_demod", chopper.read) else: @@ -371,7 +375,7 @@ class dsp(object): def isWsjtMode(self, demodulator = None): if demodulator is None: demodulator = self.get_secondary_demodulator() - return demodulator in ["ft8", "wspr"] + return demodulator in ["ft8", "wspr", "jt65", "jt9"] def set_output_rate(self,output_rate): self.output_rate=output_rate diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index a251a3a..47cf9cf 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -840,20 +840,22 @@ img.openwebrx-mirror-img } #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container, -#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel { display: none; } #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container, -#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container { height: 200px; margin: -10px; } - -#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, -#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel -{ - display: none; -} \ No newline at end of file diff --git a/htdocs/index.html b/htdocs/index.html index 8c9aa50..854236f 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -82,6 +82,8 @@ + +
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 92905cf..4d184bf 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1385,7 +1385,7 @@ function update_wsjt_panel(msg) { var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } var linkedmsg = msg['msg']; - if (msg['mode'] == 'FT8') { + if (['FT8', 'JT65', 'JT9'].indexOf(msg['mode']) >= 0) { var matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); if (matches && matches[2] != 'RR73') { linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; @@ -2709,6 +2709,8 @@ function demodulator_digital_replace(subtype) case "rtty": case "ft8": case "wspr": + case "jt65": + case "jt9": secondary_demod_start(subtype); demodulator_analog_replace('usb', true); demodulator_buttons_update(); @@ -2716,7 +2718,7 @@ function demodulator_digital_replace(subtype) } $('#openwebrx-panel-digimodes').attr('data-mode', subtype); toggle_panel("openwebrx-panel-digimodes", true); - toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr'].indexOf(subtype) >= 0); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9'].indexOf(subtype) >= 0); } function secondary_demod_create_canvas() @@ -2888,6 +2890,8 @@ function secondary_demod_listbox_changed() case "rtty": case "ft8": case "wspr": + case "jt65": + case "jt9": demodulator_digital_replace(sdm); break; } diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 925b3b0..75188ac 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -144,9 +144,29 @@ class WsprChopper(WsjtChopper): return ["wsprd", "-d", file] +class Jt65Chopper(WsjtChopper): + def __init__(self, source): + self.interval = 60 + super().__init__(source) + + def decoder_commandline(self, file): + #TODO expose decoding quality parameters through config + return ["jt9", "--jt65", "-d", "3", file] + + +class Jt9Chopper(WsjtChopper): + def __init__(self, source): + self.interval = 60 + super().__init__(source) + + def decoder_commandline(self, file): + #TODO expose decoding quality parameters through config + return ["jt9", "--jt9", "-d", "3", file] + + class WsjtParser(object): locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") - jt9_pattern = re.compile("^[0-9]{6} .*") + jt9_pattern = re.compile("^([0-9]{6}|\\*{4}) .*") wspr_pattern = re.compile("^[0-9]{4} .*") wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") @@ -154,7 +174,9 @@ class WsjtParser(object): self.handler = handler modes = { - "~": "FT8" + "~": "FT8", + "#": "JT65", + "@": "JT9" } def parse(self, data): @@ -179,15 +201,22 @@ class WsjtParser(object): def parse_from_jt9(self, msg): # ft8 sample # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' + # jt65 sample + # '**** -10 0.4 1556 # CQ RN6AM KN95' out = {} - ts = datetime.strptime(msg[0:6], "%H%M%S") - out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) - out["db"] = float(msg[7:10]) - out["dt"] = float(msg[11:15]) - out["freq"] = int(msg[16:20]) - modeChar = msg[21:22] + if msg.startswith("****"): + out["timestamp"] = int(datetime.now().timestamp() * 1000) + msg = msg[5:] + else: + ts = datetime.strptime(msg[0:6], "%H%M%S") + out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) + msg = msg[7:] + out["db"] = float(msg[0:3]) + out["dt"] = float(msg[4:8]) + out["freq"] = int(msg[9:13]) + modeChar = msg[14:15] out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" - wsjt_msg = msg[24:60].strip() + wsjt_msg = msg[17:53].strip() self.parseLocator(wsjt_msg, mode) out["msg"] = wsjt_msg return out From c94331bf24ac3ce50ae20f7ffeaab327d937c55e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Jul 2019 18:22:02 +0200 Subject: [PATCH 074/118] hide modes if not available --- htdocs/index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/htdocs/index.html b/htdocs/index.html index 854236f..7e9f380 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -80,10 +80,10 @@
    From a15341fdcf59af199241c1be36d991b05c00c235 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 14 Jul 2019 19:32:48 +0200 Subject: [PATCH 075/118] detect and pass band information to the map --- bands.json | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ htdocs/map.js | 9 +++-- owrx/bands.py | 32 +++++++++++++++++ owrx/map.py | 11 +++--- owrx/source.py | 8 +++-- owrx/wsjt.py | 11 ++++-- 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 bands.json create mode 100644 owrx/bands.py diff --git a/bands.json b/bands.json new file mode 100644 index 0000000..3a6939a --- /dev/null +++ b/bands.json @@ -0,0 +1,97 @@ +[ + { + "name": "160m", + "lower_bound": 1810000, + "upper_bound": 2000000 + }, + { + "name": "80m", + "lower_bound": 3500000, + "upper_bound": 3800000 + }, + { + "name": "60m", + "lower_bound": 5351500, + "upper_bound": 3566500 + }, + { + "name": "40m", + "lower_bound": 7000000, + "upper_bound": 7200000 + }, + { + "name": "30m", + "lower_bound": 10100000, + "upper_bound": 10150000 + }, + { + "name": "20m", + "lower_bound": 14000000, + "upper_bound": 14350000 + }, + { + "name": "17m", + "lower_bound": 18068000, + "upper_bound": 18168000 + }, + { + "name": "15m", + "lower_bound": 21000000, + "upper_bound": 21450000 + }, + { + "name": "12m", + "lower_bound": 24890000, + "upper_bound": 24990000 + }, + { + "name": "10m", + "lower_bound": 28000000, + "upper_bound": 29700000 + }, + { + "name": "6m", + "lower_bound": 50030000, + "upper_bound": 51000000 + }, + { + "name": "4m", + "lower_bound": 70150000, + "upper_bound": 70200000 + }, + { + "name": "2m", + "lower_bound": 144000000, + "upper_bound": 146000000 + }, + { + "name": "70cm", + "lower_bound": 430000000, + "upper_bound": 440000000 + }, + { + "name": "23cm", + "lower_bound": 1240000000, + "upper_bound": 1300000000 + }, + { + "name": "13cm", + "lower_bound": 2320000000, + "upper_bound": 2450000000 + }, + { + "name": "9cm", + "lower_bound": 3400000000, + "upper_bound": 3475000000 + }, + { + "name": "6cm", + "lower_bound": 5650000000, + "upper_bound": 5850000000 + }, + { + "name": "3cm", + "lower_bound": 10000000000, + "upper_bound": 10500000000 + } +] \ No newline at end of file diff --git a/htdocs/map.js b/htdocs/map.js index b5f905a..6af68ca 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -58,6 +58,7 @@ }, getMarkerOpacityOptions(update.lastseen) )); marker.lastseen = update.lastseen; marker.mode = update.mode; + marker.band = update.band; // TODO the trim should happen on the server side if (expectedCallsign && expectedCallsign == update.callsign.trim()) { @@ -96,6 +97,7 @@ rectangle.lastseen = update.lastseen; rectangle.locator = update.location.locator; rectangle.mode = update.mode; + rectangle.band = update.band; if (expectedLocator && expectedLocator == update.location.locator) { map.panTo(center); @@ -194,7 +196,7 @@ var showLocatorInfoWindow = function(locator, pos) { if (!infowindow) infowindow = new google.maps.InfoWindow(); var inLocator = $.map(rectangles, function(r, callsign) { - return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode} + return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band} }).filter(function(d) { return d.locator == locator; }).sort(function(a, b){ @@ -206,7 +208,10 @@ '
      ' + inLocator.map(function(i){ var timestring = moment(i.lastseen).fromNow(); - return '
    • ' + i.callsign + ' (' + timestring + ' using ' + i.mode + ')
    • ' + var message = i.callsign + ' (' + timestring + ' using ' + i.mode; + if (i.band) message += ' on ' + i.band; + message += ')'; + return '
    • ' + message + '
    • ' }).join("") + '
    ' ); diff --git a/owrx/bands.py b/owrx/bands.py new file mode 100644 index 0000000..c2ed79e --- /dev/null +++ b/owrx/bands.py @@ -0,0 +1,32 @@ +import json + + +class Band(object): + def __init__(self, dict): + self.name = dict["name"] + self.lower_bound = dict["lower_bound"] + self.upper_bound = dict["upper_bound"] + + def inBand(self, freq): + return self.lower_bound <= freq <= self.upper_bound + + def getName(self): + return self.name + + +class Bandplan(object): + sharedInstance = None + @staticmethod + def getSharedInstance(): + if Bandplan.sharedInstance is None: + Bandplan.sharedInstance = Bandplan() + return Bandplan.sharedInstance + + def __init__(self): + f = open("bands.json", "r") + bands_json = json.load(f) + f.close() + self.bands = [Band(d) for d in bands_json] + + def findBand(self, freq): + return next(band for band in self.bands if band.inBand(freq)) diff --git a/owrx/map.py b/owrx/map.py index 4d65a49..a6adbb6 100644 --- a/owrx/map.py +++ b/owrx/map.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import threading, time from owrx.config import PropertyManager +from owrx.bands import Band import logging logger = logging.getLogger(__name__) @@ -45,7 +46,8 @@ class Map(object): "callsign": callsign, "location": record["location"].__dict__(), "lastseen": record["updated"].timestamp() * 1000, - "mode" : record["mode"] + "mode" : record["mode"], + "band" : record["band"].getName() if record["band"] is not None else None } for (callsign, record) in self.positions.items() ]) @@ -56,15 +58,16 @@ class Map(object): except ValueError: pass - def updateLocation(self, callsign, loc: Location, mode: str): + def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None): ts = datetime.now() - self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode} + self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band} self.broadcast([ { "callsign": callsign, "location": loc.__dict__(), "lastseen": ts.timestamp() * 1000, - "mode" : mode + "mode" : mode, + "band" : band.getName() if band is not None else None } ]) diff --git a/owrx/source.py b/owrx/source.py index 7a37ef6..2e2bca7 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -353,7 +353,7 @@ class DspManager(csdr.output): self.localProps = self.sdrSource.getProps().collect( "audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", - "dmr_filter", "temporary_directory" + "dmr_filter", "temporary_directory", "center_freq" ).defaults(PropertyManager.getSharedInstance()) self.dsp = csdr.dsp(self) @@ -369,6 +369,9 @@ class DspManager(csdr.output): bpf[1] = cut self.dsp.set_bpf(*bpf) + def set_dial_freq(key, value): + self.wsjtParser.setDialFrequency(self.localProps["center_freq"] + self.localProps["offset_freq"]) + self.subscriptions = [ self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression), self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression), @@ -382,7 +385,8 @@ class DspManager(csdr.output): self.localProps.getProperty("mod").wire(self.dsp.set_demodulator), self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality), self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter), - self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory) + self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory), + self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq) ] self.dsp.set_offset_freq(0) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 75188ac..df3eda9 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -9,6 +9,7 @@ from multiprocessing.connection import Pipe from owrx.map import Map, LocatorLocation import re from owrx.config import PropertyManager +from owrx.bands import Bandplan import logging logger = logging.getLogger(__name__) @@ -172,6 +173,8 @@ class WsjtParser(object): def __init__(self, handler): self.handler = handler + self.dial_freq = None + self.band = None modes = { "~": "FT8", @@ -229,7 +232,7 @@ class WsjtParser(object): # likely this just means roger roger goodbye. if m.group(2) == "RR73": return - Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode) + Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), mode, self.band) def parse_from_wsprd(self, msg): # wspr sample @@ -252,4 +255,8 @@ class WsjtParser(object): m = WsjtParser.wspr_splitter_pattern.match(msg) if m is None: return - Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR") + Map.getSharedInstance().updateLocation(m.group(1), LocatorLocation(m.group(2)), "WSPR", self.band) + + def setDialFrequency(self, freq): + self.dial_freq = freq + self.band = Bandplan.getSharedInstance().findBand(freq) From f1098801e24feb4ad16ed0d22877c27a51c32c6c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 15 Jul 2019 21:35:39 +0200 Subject: [PATCH 076/118] let's try to avoid browser problems --- htdocs/openwebrx.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 4d184bf..b199eec 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1418,11 +1418,11 @@ var wsjt_removal_interval; function init_wsjt_removal_timer() { if (wsjt_removal_interval) clearInterval(wsjt_removal_interval); setInterval(function(){ - // let's keep 2 hours that should be plenty for most users - var cutoff = new Date().getTime()- 2 * 60 * 60 * 1000; - $('#openwebrx-panel-wsjt-message tbody tr').filter(function(_, e){ - return $(e).data('timestamp') < cutoff; - }).remove(); + var $elements = $('#openwebrx-panel-wsjt-message tbody tr'); + // limit to 1000 entries in the list since browsers get laggy at some point + var toRemove = $elements.length - 1000; + if (toRemove <= 0) return; + $elements.slice(0, toRemove).remove(); }, 15000); } From 4493f369ddb54c1f284a47483bf162d65115ad7a Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Jul 2019 17:01:50 +0200 Subject: [PATCH 077/118] enable 64-bit frames for large amounts of data --- owrx/websocket.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/owrx/websocket.py b/owrx/websocket.py index a247b2a..360f28b 100644 --- a/owrx/websocket.py +++ b/owrx/websocket.py @@ -23,10 +23,31 @@ class WebSocketConnection(object): def get_header(self, size, opcode): ws_first_byte = 0b10000000 | (opcode & 0x0F) - if (size > 125): - return bytes([ws_first_byte, 126, (size>>8) & 0xff, size & 0xff]) + if (size > 2**16 - 1): + # frame size can be increased up to 2^64 by setting the size to 127 + # anything beyond that would need to be segmented into frames. i don't really think we'll need more. + return bytes([ + ws_first_byte, + 127, + (size >> 56) & 0xff, + (size >> 48) & 0xff, + (size >> 40) & 0xff, + (size >> 32) & 0xff, + (size >> 24) & 0xff, + (size >> 16) & 0xff, + (size >> 8) & 0xff, + size & 0xff + ]) + elif (size > 125): + # up to 2^16 can be sent using the extended payload size field by putting the size to 126 + return bytes([ + ws_first_byte, + 126, + (size >> 8) & 0xff, + size & 0xff + ]) else: - # 256 bytes binary message in a single unmasked frame + # 125 bytes binary message in a single unmasked frame return bytes([ws_first_byte, size]) def send(self, data): From a7a032dc8ff4e61d0eba9bd98d842bebcd2087e4 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Jul 2019 21:16:16 +0200 Subject: [PATCH 078/118] this goes in there --- htdocs/openwebrx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index b199eec..eee6175 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1417,7 +1417,7 @@ var wsjt_removal_interval; // remove old wsjt messages in fixed intervals function init_wsjt_removal_timer() { if (wsjt_removal_interval) clearInterval(wsjt_removal_interval); - setInterval(function(){ + wsjt_removal_interval = setInterval(function(){ var $elements = $('#openwebrx-panel-wsjt-message tbody tr'); // limit to 1000 entries in the list since browsers get laggy at some point var toRemove = $elements.length - 1000; From a1856482ff579c00f626e072644eae162685b726 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Jul 2019 22:41:51 +0200 Subject: [PATCH 079/118] add dial frequencies --- bands.json | 106 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/bands.json b/bands.json index 3a6939a..971ab90 100644 --- a/bands.json +++ b/bands.json @@ -2,67 +2,147 @@ { "name": "160m", "lower_bound": 1810000, - "upper_bound": 2000000 + "upper_bound": 2000000, + "frequencies": { + "psk31": 1838000, + "ft8": 1840000, + "wspr": 1836600, + "jt65": 1838000, + "jt9": 1839000 + } }, { "name": "80m", "lower_bound": 3500000, - "upper_bound": 3800000 + "upper_bound": 3800000, + "frequencies": { + "psk31": 3580000, + "ft8": 3573000, + "wspr": 3592600, + "jt65": 3570000, + "jt9": 3572000 + } }, { "name": "60m", "lower_bound": 5351500, - "upper_bound": 3566500 + "upper_bound": 3566500, + "frequencies": { + "ft8": 5357000, + "wspr": 5287200 + } }, { "name": "40m", "lower_bound": 7000000, - "upper_bound": 7200000 + "upper_bound": 7200000, + "frequencies": { + "psk31": 7040000, + "ft8": 7074000, + "wspr": 7038600, + "jt65": 7076000, + "jt9": 7078000 + } }, { "name": "30m", "lower_bound": 10100000, - "upper_bound": 10150000 + "upper_bound": 10150000, + "frequencies": { + "psk31": 10141000, + "ft8": 10136000, + "wspr": 10138700, + "jt65": 10138000, + "jt9": 10140000 + } }, { "name": "20m", "lower_bound": 14000000, - "upper_bound": 14350000 + "upper_bound": 14350000, + "frequencies": { + "psk31": 14070000, + "ft8": 14074000, + "wspr": 14095600, + "jt65": 14076000, + "jt9": 14078000 + } }, { "name": "17m", "lower_bound": 18068000, - "upper_bound": 18168000 + "upper_bound": 18168000, + "frequencies": { + "psk31": 18098000, + "ft8": 18100000, + "wspr": 18104600, + "jt65": 18102000, + "jt9": 18104000 + } }, { "name": "15m", "lower_bound": 21000000, - "upper_bound": 21450000 + "upper_bound": 21450000, + "frequencies": { + "psk31": 21070000, + "ft8": 21074000, + "wspr": 21094600, + "jt65": 21076000, + "jt9": 21078000 + } }, { "name": "12m", "lower_bound": 24890000, - "upper_bound": 24990000 + "upper_bound": 24990000, + "frequencies": { + "psk31": 24920000, + "ft8": 24915000, + "wspr": 24924600, + "jt65": 24917000, + "jt9": 24919000 + } }, { "name": "10m", "lower_bound": 28000000, - "upper_bound": 29700000 + "upper_bound": 29700000, + "frequencies": { + "psk31": [28070000, 28120000], + "ft8": 28074000, + "wspr": 28124600, + "jt65": 28076000, + "jt9": 28078000 + } }, { "name": "6m", "lower_bound": 50030000, - "upper_bound": 51000000 + "upper_bound": 51000000, + "frequencies": { + "psk31": 50305000, + "ft8": 50313000, + "wspr": 50293000, + "jt65": 50310000, + "jt9": 50312000 + } }, { "name": "4m", "lower_bound": 70150000, - "upper_bound": 70200000 + "upper_bound": 70200000, + "frequencies": { + "wspr": 70091000 + } }, { "name": "2m", "lower_bound": 144000000, - "upper_bound": 146000000 + "upper_bound": 146000000, + "frequencies": { + "wspr": 144489000 + } }, { "name": "70cm", From 6e08a428d643342794f8ebfb1e71f6e443287738 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Jul 2019 23:15:10 +0200 Subject: [PATCH 080/118] import frequencies; fix band errors --- bands.json | 2 +- owrx/bands.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bands.json b/bands.json index 971ab90..f8b6bb6 100644 --- a/bands.json +++ b/bands.json @@ -26,7 +26,7 @@ { "name": "60m", "lower_bound": 5351500, - "upper_bound": 3566500, + "upper_bound": 5366500, "frequencies": { "ft8": 5357000, "wspr": 5287200 diff --git a/owrx/bands.py b/owrx/bands.py index c2ed79e..47bd857 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -1,11 +1,24 @@ import json +import logging +logger = logging.getLogger(__name__) + class Band(object): def __init__(self, dict): self.name = dict["name"] self.lower_bound = dict["lower_bound"] self.upper_bound = dict["upper_bound"] + self.frequencies = [] + if "frequencies" in dict: + for (mode, freqs) in dict["frequencies"].items(): + if not isinstance(freqs, list): + freqs = [freqs] + for f in freqs: + if not self.inBand(f): + logger.warning("Frequency for {mode} on {band} is not within band limits: {frequency}".format(mode = mode, frequency = f, band = self.name)) + else: + self.frequencies.append([{"mode": mode, "frequency": f}]) def inBand(self, freq): return self.lower_bound <= freq <= self.upper_bound From abd5cf07954d4e1b7e88a2bb44cc9b56d3c072bc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Fri, 19 Jul 2019 23:55:52 +0200 Subject: [PATCH 081/118] collect dial frequencies and send to client --- owrx/bands.py | 9 ++++++++- owrx/connection.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/owrx/bands.py b/owrx/bands.py index 47bd857..5971bf8 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -18,7 +18,7 @@ class Band(object): if not self.inBand(f): logger.warning("Frequency for {mode} on {band} is not within band limits: {frequency}".format(mode = mode, frequency = f, band = self.name)) else: - self.frequencies.append([{"mode": mode, "frequency": f}]) + self.frequencies.append({"mode": mode, "frequency": f}) def inBand(self, freq): return self.lower_bound <= freq <= self.upper_bound @@ -26,6 +26,10 @@ class Band(object): def getName(self): return self.name + def getDialFrequencies(self, range): + (low, hi) = range + return [e for e in self.frequencies if low <= e["frequency"] <= hi] + class Bandplan(object): sharedInstance = None @@ -43,3 +47,6 @@ class Bandplan(object): def findBand(self, freq): return next(band for band in self.bands if band.inBand(freq)) + + def collectDialFrequencis(self, range): + return [e for b in self.bands for e in b.getDialFrequencies(range)] diff --git a/owrx/connection.py b/owrx/connection.py index d83dacd..17551ff 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -2,6 +2,7 @@ from owrx.config import PropertyManager from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry from owrx.feature import FeatureDetector from owrx.version import openwebrx_version +from owrx.bands import Bandplan import json from owrx.map import Map @@ -83,6 +84,12 @@ class OpenWebRxReceiverClient(Client): config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] self.write_config(config) + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + frequencyRange = (cf - srh, cf + srh) + self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencis(frequencyRange)) + + self.configSub = configProps.wire(sendConfig) sendConfig(None, None) @@ -162,6 +169,9 @@ class OpenWebRxReceiverClient(Client): def write_wsjt_message(self, message): self.protected_send({"type": "wsjt_message", "value": message}) + def write_dial_frequendies(self, frequencies): + self.protected_send({"type": "dial_frequencies", "value": frequencies}) + class MapConnection(Client): def __init__(self, conn): From 18b65f769fc5f041e29ce41fa0deae9947d1e935 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Jul 2019 12:47:10 +0200 Subject: [PATCH 082/118] better timestamping and overhaul --- owrx/wsjt.py | 76 +++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index df3eda9..b0c8c4b 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -1,6 +1,6 @@ import threading import wave -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta, date, timezone import time import sched import subprocess @@ -31,7 +31,7 @@ class WsjtChopper(threading.Thread): filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format( tmp_dir = self.tmp_dir, id = id(self), - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + timestamp = datetime.utcnow().strftime(self.fileTimestampFormat) ) wavefile = wave.open(filename, "wb") wavefile.setnchannels(1) @@ -71,9 +71,9 @@ class WsjtChopper(threading.Thread): self._scheduleNextSwitch() def decoder_commandline(self, file): - ''' + """ must be overridden in child classes - ''' + """ return [] def decode(self): @@ -128,6 +128,7 @@ class WsjtChopper(threading.Thread): class Ft8Chopper(WsjtChopper): def __init__(self, source): self.interval = 15 + self.fileTimestampFormat = "%y%m%d_%H%M%S" super().__init__(source) def decoder_commandline(self, file): @@ -138,6 +139,7 @@ class Ft8Chopper(WsjtChopper): class WsprChopper(WsjtChopper): def __init__(self, source): self.interval = 120 + self.fileTimestampFormat = "%y%m%d_%H%M" super().__init__(source) def decoder_commandline(self, file): @@ -148,6 +150,7 @@ class WsprChopper(WsjtChopper): class Jt65Chopper(WsjtChopper): def __init__(self, source): self.interval = 60 + self.fileTimestampFormat = "%y%m%d_%H%M" super().__init__(source) def decoder_commandline(self, file): @@ -158,6 +161,7 @@ class Jt65Chopper(WsjtChopper): class Jt9Chopper(WsjtChopper): def __init__(self, source): self.interval = 60 + self.fileTimestampFormat = "%y%m%d_%H%M" super().__init__(source) def decoder_commandline(self, file): @@ -167,8 +171,6 @@ class Jt9Chopper(WsjtChopper): class WsjtParser(object): locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") - jt9_pattern = re.compile("^([0-9]{6}|\\*{4}) .*") - wspr_pattern = re.compile("^[0-9]{4} .*") wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") def __init__(self, handler): @@ -191,38 +193,45 @@ class WsjtParser(object): if msg.startswith(" EOF on input file"): return - out = {} - if WsjtParser.jt9_pattern.match(msg): + modes = list(WsjtParser.modes.keys()) + if msg[21] in modes or msg[19] in modes: out = self.parse_from_jt9(msg) - elif WsjtParser.wspr_pattern.match(msg): + else: out = self.parse_from_wsprd(msg) self.handler.write_wsjt_message(out) except ValueError: logger.exception("error while parsing wsjt message") + def parse_timestamp(self, instring, dateformat): + ts = datetime.strptime(instring, dateformat).replace(tzinfo=timezone.utc) + return int(datetime.combine(date.today(), ts.time(), timezone.utc).timestamp() * 1000) + def parse_from_jt9(self, msg): # ft8 sample # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' # jt65 sample - # '**** -10 0.4 1556 # CQ RN6AM KN95' - out = {} - if msg.startswith("****"): - out["timestamp"] = int(datetime.now().timestamp() * 1000) - msg = msg[5:] + # '2352 -7 0.4 1801 # R0WAS R2ABM KO85' + # '0003 -4 0.4 1762 # CQ R2ABM KO85' + modes = list(WsjtParser.modes.keys()) + if msg[19] in modes: + dateformat = "%H%M" else: - ts = datetime.strptime(msg[0:6], "%H%M%S") - out["timestamp"] = int(datetime.combine(date.today(), ts.time(), datetime.now().tzinfo).timestamp() * 1000) - msg = msg[7:] - out["db"] = float(msg[0:3]) - out["dt"] = float(msg[4:8]) - out["freq"] = int(msg[9:13]) + dateformat = "%H%M%S" + timestamp = self.parse_timestamp(msg[0:len(dateformat)], dateformat) + msg = msg[len(dateformat) + 1:] modeChar = msg[14:15] - out["mode"] = mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" + mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" wsjt_msg = msg[17:53].strip() self.parseLocator(wsjt_msg, mode) - out["msg"] = wsjt_msg - return out + return { + "timestamp": timestamp, + "db": float(msg[0:3]), + "dt": float(msg[4:8]), + "freq": int(msg[9:13]), + "mode": mode, + "msg": wsjt_msg + } def parseLocator(self, msg, mode): m = WsjtParser.locator_pattern.match(msg) @@ -237,19 +246,18 @@ class WsjtParser(object): def parse_from_wsprd(self, msg): # wspr sample # '2600 -24 0.4 0.001492 -1 G8AXA JO01 33' - out = {} - now = datetime.now() - ts = datetime.strptime(msg[0:4], "%M%S").replace(hour=now.hour) - out["timestamp"] = int(datetime.combine(date.today(), ts.time(), now.tzinfo).timestamp() * 1000) - out["db"] = float(msg[5:8]) - out["dt"] = float(msg[9:13]) - out["freq"] = float(msg[14:24]) - out["drift"] = int(msg[25:28]) - out["mode"] = "WSPR" + # '0052 -29 2.6 0.001486 0 G02CWT IO92 23' wsjt_msg = msg[29:].strip() - out["msg"] = wsjt_msg self.parseWsprMessage(wsjt_msg) - return out + return { + "timestamp": self.parse_timestamp(msg[0:4], "%H%M"), + "db": float(msg[5:8]), + "dt": float(msg[9:13]), + "freq": float(msg[14:24]), + "drift": int(msg[25:28]), + "mode": "WSPR", + "msg": wsjt_msg + } def parseWsprMessage(self, msg): m = WsjtParser.wspr_splitter_pattern.match(msg) From 25b0e86f09114db8e02a1db4e22c2bbd412a443b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Jul 2019 13:38:25 +0200 Subject: [PATCH 083/118] add FT4 because why not --- csdr.py | 6 ++++-- htdocs/css/openwebrx.css | 7 +++++-- htdocs/index.html | 1 + htdocs/openwebrx.js | 6 ++++-- owrx/wsjt.py | 14 +++++++++++++- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/csdr.py b/csdr.py index 05b8973..8b8edef 100755 --- a/csdr.py +++ b/csdr.py @@ -25,7 +25,7 @@ import os import signal import threading from functools import partial -from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper +from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper import logging logger = logging.getLogger(__name__) @@ -281,6 +281,8 @@ class dsp(object): chopper = Jt65Chopper(self.secondary_process_demod.stdout) elif smd == "jt9": chopper = Jt9Chopper(self.secondary_process_demod.stdout) + elif smd == "ft4": + chopper = Ft4Chopper(self.secondary_process_demod.stdout) chopper.start() self.output.add_output("wsjt_demod", chopper.read) else: @@ -375,7 +377,7 @@ class dsp(object): def isWsjtMode(self, demodulator = None): if demodulator is None: demodulator = self.get_secondary_demodulator() - return demodulator in ["ft8", "wspr", "jt65", "jt9"] + return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"] def set_output_rate(self,output_rate): self.output_rate=output_rate diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 47cf9cf..91e03e8 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -843,10 +843,12 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-content-container, #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, #openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, -#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-select-channel { display: none; } @@ -854,7 +856,8 @@ img.openwebrx-mirror-img #openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container, #openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container, -#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-canvas-container { height: 200px; margin: -10px; diff --git a/htdocs/index.html b/htdocs/index.html index 7e9f380..e2b8010 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -84,6 +84,7 @@ +
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index eee6175..be79ee5 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1385,7 +1385,7 @@ function update_wsjt_panel(msg) { var t = new Date(msg['timestamp']); var pad = function(i) { return ('' + i).padStart(2, "0"); } var linkedmsg = msg['msg']; - if (['FT8', 'JT65', 'JT9'].indexOf(msg['mode']) >= 0) { + if (['FT8', 'JT65', 'JT9', 'FT4'].indexOf(msg['mode']) >= 0) { var matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); if (matches && matches[2] != 'RR73') { linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; @@ -2711,6 +2711,7 @@ function demodulator_digital_replace(subtype) case "wspr": case "jt65": case "jt9": + case "ft4": secondary_demod_start(subtype); demodulator_analog_replace('usb', true); demodulator_buttons_update(); @@ -2718,7 +2719,7 @@ function demodulator_digital_replace(subtype) } $('#openwebrx-panel-digimodes').attr('data-mode', subtype); toggle_panel("openwebrx-panel-digimodes", true); - toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9'].indexOf(subtype) >= 0); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4'].indexOf(subtype) >= 0); } function secondary_demod_create_canvas() @@ -2892,6 +2893,7 @@ function secondary_demod_listbox_changed() case "wspr": case "jt65": case "jt9": + case "ft4": demodulator_digital_replace(sdm); break; } diff --git a/owrx/wsjt.py b/owrx/wsjt.py index b0c8c4b..0e89e11 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -169,6 +169,17 @@ class Jt9Chopper(WsjtChopper): return ["jt9", "--jt9", "-d", "3", file] +class Ft4Chopper(WsjtChopper): + def __init__(self, source): + self.interval = 7.5 + self.fileTimestampFormat = "%y%m%d_%H%M%S" + super().__init__(source) + + def decoder_commandline(self, file): + #TODO expose decoding quality parameters through config + return ["jt9", "--ft4", "-d", "3", file] + + class WsjtParser(object): locator_pattern = re.compile(".*\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") wspr_splitter_pattern = re.compile("([A-Z0-9]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") @@ -181,7 +192,8 @@ class WsjtParser(object): modes = { "~": "FT8", "#": "JT65", - "@": "JT9" + "@": "JT9", + "+": "FT4" } def parse(self, data): From f09f730bff88f3a0be0cc7999bb0660bf097190c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Jul 2019 19:52:46 +0200 Subject: [PATCH 084/118] ft4 frequency for 20m (at least to my knowledge) --- bands.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bands.json b/bands.json index f8b6bb6..487ef29 100644 --- a/bands.json +++ b/bands.json @@ -65,7 +65,8 @@ "ft8": 14074000, "wspr": 14095600, "jt65": 14076000, - "jt9": 14078000 + "jt9": 14078000, + "ft4": 14080000 } }, { From ea9feeefd2239c14ee15f1b3afa44af03ce62af0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Jul 2019 19:53:42 +0200 Subject: [PATCH 085/118] complete dial frequency feature frontend --- htdocs/css/openwebrx.css | 16 +++++++++++++++- htdocs/index.html | 10 ++++++++++ htdocs/openwebrx.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 91e03e8..cd1498b 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -364,6 +364,20 @@ input[type=range]:focus::-ms-fill-upper text-align: center; } +.openwebrx-dial-button svg { + width: 19px; + height: 19px; + vertical-align: bottom; +} + +.openwebrx-dial-button #ph_dial { + fill: #888; +} + +.openwebrx-dial-button.available #ph_dial { + fill: #FFF; +} + .openwebrx-square-button img { height: 27px; @@ -602,7 +616,7 @@ img.openwebrx-mirror-img #openwebrx-secondary-demod-listbox { - width: 201px; + width: 174px; height: 27px; padding-left:3px; } diff --git a/htdocs/index.html b/htdocs/index.html index e2b8010..bcd410e 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -86,6 +86,16 @@ +
    + + + + + + + + +
    diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index be79ee5..631b322 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -1249,6 +1249,10 @@ function on_ws_recv(evt) case "wsjt_message": update_wsjt_panel(json.value); break; + case "dial_frequencies": + dial_frequencies = json.value; + update_dial_button(); + break; default: console.warn('received message of unknown type: ' + json.type); } @@ -1314,6 +1318,29 @@ function on_ws_recv(evt) } } +var dial_frequencies = []; + +function find_dial_frequencies() { + var sdm = $("#openwebrx-secondary-demod-listbox")[0].value; + return dial_frequencies.filter(function(d){ + return d.mode == sdm; + }); +} + +function update_dial_button() { + var available = find_dial_frequencies(); + $("#openwebrx-secondary-demod-dial-button")[available.length ? "addClass" : "removeClass"]("available"); +} + +function dial_button_click() { + var available = find_dial_frequencies(); + if (!available.length) return; + var frequency = available[0].frequency; + console.info(frequency); + demodulator_set_offset_frequency(0, frequency - center_freq); + $("#webrx-actual-freq").html(format_frequency("{x} MHz", frequency, 1e6, 4)); +} + function update_metadata(meta) { if (meta.protocol) switch (meta.protocol) { case 'DMR': @@ -2897,6 +2924,7 @@ function secondary_demod_listbox_changed() demodulator_digital_replace(sdm); break; } + update_dial_button(); } function secondary_demod_listbox_update() From 2fae8ffa705837a02cd563dbf4c8165722ca9e99 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 20 Jul 2019 20:45:13 +0200 Subject: [PATCH 086/118] remove some pointless stuff --- htdocs/index.html | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/htdocs/index.html b/htdocs/index.html index bcd410e..2db20ea 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -87,10 +87,7 @@
    - - - - + From 6900810f5d29e92919ce31a73ac1864417b7e1a9 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 13:07:38 +0100 Subject: [PATCH 087/118] modify so that it runs with python 3.5, too --- owrx/wsjt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 0e89e11..fb97ac8 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -216,8 +216,8 @@ class WsjtParser(object): logger.exception("error while parsing wsjt message") def parse_timestamp(self, instring, dateformat): - ts = datetime.strptime(instring, dateformat).replace(tzinfo=timezone.utc) - return int(datetime.combine(date.today(), ts.time(), timezone.utc).timestamp() * 1000) + ts = datetime.strptime(instring, dateformat) + return int(datetime.combine(date.today(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000) def parse_from_jt9(self, msg): # ft8 sample From fc5abd38cc9d37e3214caa4b4ed44ad39e3415bb Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 18:38:54 +0200 Subject: [PATCH 088/118] add information about wsjt-x --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 493f71c..1b4833c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ It has the following features: - Multiple SDR devices can be used simultaneously - [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF) - [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN) +- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9) + +**News (2019-07-21 by DD5JFK)** +- Latest Features: + - More WSJT-X have been added, including the new FT4 mode + - I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the dial frequency for digital modes + - fixed some bugs in the websocket communication which broke the map **News (2019-07-13 by DD5JFK)** - Latest Features: @@ -77,9 +84,17 @@ Optional Dependencies if you want to be able to listen do digital voice: - [digiham](https://github.com/jketterl/digiham) - [dsd](https://github.com/f4exb/dsdcc) +Optional Dependency if you want to decode WSJT-X modes: + +- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) + After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server: python openwebrx.py + +You may need to specify the Python version explicitly if your distribution still defaults to Python 2: + + python3 openwebrx.py You can now open the GUI at http://localhost:8073. From 79062ff3d663c9953a4d0929995158e90cc70c5d Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 18:40:00 +0200 Subject: [PATCH 089/118] fix wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b4833c..1df5b19 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It has the following features: **News (2019-07-21 by DD5JFK)** - Latest Features: - - More WSJT-X have been added, including the new FT4 mode + - More WSJT-X modes have been added, including the new FT4 mode - I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the dial frequency for digital modes - fixed some bugs in the websocket communication which broke the map From e15dc1ce11c92aa45592e17096566867f33bc982 Mon Sep 17 00:00:00 2001 From: D0han Date: Sun, 21 Jul 2019 19:40:28 +0200 Subject: [PATCH 090/118] Reformatted with black -l 120 -t py35 . --- config_webrx.py | 124 ++++++++------- csdr.py | 366 ++++++++++++++++++++++++-------------------- openwebrx.py | 19 ++- owrx/bands.py | 8 +- owrx/config.py | 20 ++- owrx/connection.py | 86 +++++++---- owrx/controllers.py | 45 ++++-- owrx/feature.py | 50 +++--- owrx/http.py | 25 ++- owrx/map.py | 56 +++---- owrx/meta.py | 30 ++-- owrx/sdrhu.py | 15 +- owrx/source.py | 175 +++++++++++++++------ owrx/version.py | 2 +- owrx/websocket.py | 85 +++++----- owrx/wsjt.py | 34 ++-- sdrhu.py | 3 +- 17 files changed, 681 insertions(+), 462 deletions(-) diff --git a/config_webrx.py b/config_webrx.py index eaba803..15569de 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -35,21 +35,21 @@ config_webrx: configuration options for OpenWebRX # https://github.com/simonyiszk/openwebrx/wiki # ==== Server settings ==== -web_port=8073 -max_clients=20 +web_port = 8073 +max_clients = 20 # ==== Web GUI configuration ==== -receiver_name="[Callsign]" -receiver_location="Budapest, Hungary" -receiver_qra="JN97ML" -receiver_asl=200 -receiver_ant="Longwire" -receiver_device="RTL-SDR" -receiver_admin="example@example.com" -receiver_gps=(47.000000,19.000000) -photo_height=350 -photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory" -photo_desc=""" +receiver_name = "[Callsign]" +receiver_location = "Budapest, Hungary" +receiver_qra = "JN97ML" +receiver_asl = 200 +receiver_ant = "Longwire" +receiver_device = "RTL-SDR" +receiver_admin = "example@example.com" +receiver_gps = (47.000000, 19.000000) +photo_height = 350 +photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory" +photo_desc = """ You can add your own background photo and receiver information.
    Receiver is operated by: %[RX_ADMIN]
    Device: %[RX_DEVICE]
    @@ -64,18 +64,20 @@ Website: http://localhost sdrhu_key = "" # 3. Set this setting to True to enable listing: sdrhu_public_listing = False -server_hostname="localhost" +server_hostname = "localhost" # ==== DSP/RX settings ==== -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. +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. -audio_compression="adpcm" #valid values: "adpcm", "none" -fft_compression="adpcm" #valid values: "adpcm", "none" +audio_compression = "adpcm" # valid values: "adpcm", "none" +fft_compression = "adpcm" # valid values: "adpcm", "none" -digimodes_enable=True #Decoding digimodes come with higher CPU usage. -digimodes_fft_size=1024 +digimodes_enable = True # Decoding digimodes come with higher CPU usage. +digimodes_fft_size = 1024 # determines the quality, and thus the cpu usage, for the ambe codec used by digital voice modes # if you're running on a Raspi (up to 3B+) you'll want to leave this on 1 @@ -116,7 +118,7 @@ sdrs = { "rf_gain": 30, "samp_rate": 2400000, "start_freq": 439275000, - "start_mod": "nfm" + "start_mod": "nfm", }, "2m": { "name": "2m komplett", @@ -124,9 +126,9 @@ sdrs = { "rf_gain": 30, "samp_rate": 2400000, "start_freq": 145725000, - "start_mod": "nfm" - } - } + "start_mod": "nfm", + }, + }, }, "sdrplay": { "name": "SDRPlay RSP2", @@ -134,39 +136,39 @@ sdrs = { "ppm": 0, "profiles": { "20m": { - "name":"20m", + "name": "20m", "center_freq": 14150000, "rf_gain": 4, "samp_rate": 500000, "start_freq": 14070000, "start_mod": "usb", - "antenna": "Antenna A" + "antenna": "Antenna A", }, "30m": { - "name":"30m", + "name": "30m", "center_freq": 10125000, "rf_gain": 4, "samp_rate": 250000, "start_freq": 10142000, - "start_mod": "usb" + "start_mod": "usb", }, "40m": { - "name":"40m", + "name": "40m", "center_freq": 7100000, "rf_gain": 4, "samp_rate": 500000, "start_freq": 7070000, "start_mod": "usb", - "antenna": "Antenna A" + "antenna": "Antenna A", }, "80m": { - "name":"80m", + "name": "80m", "center_freq": 3650000, "rf_gain": 4, "samp_rate": 500000, "start_freq": 3570000, "start_mod": "usb", - "antenna": "Antenna A" + "antenna": "Antenna A", }, "49m": { "name": "49m Broadcast", @@ -175,42 +177,43 @@ sdrs = { "samp_rate": 500000, "start_freq": 6070000, "start_mod": "am", - "antenna": "Antenna A" - } - } + "antenna": "Antenna A", + }, + }, }, # this one is just here to test feature detection - "test": { - "type": "test" - } + "test": {"type": "test"}, } # ==== Misc settings ==== client_audio_buffer_size = 5 -#increasing client_audio_buffer_size will: +# increasing client_audio_buffer_size will: # - also increase the latency # - decrease the chance of audio underruns -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. +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. # ==== Color themes ==== -#A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels +# A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels ### default theme by teejez: -waterfall_colors = [0x000000ff,0x0000ffff,0x00ffffff,0x00ff00ff,0xffff00ff,0xff0000ff,0xff00ffff,0xffffffff] -waterfall_min_level = -88 #in dB +waterfall_colors = [0x000000FF, 0x0000FFFF, 0x00FFFFFF, 0x00FF00FF, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFFFF] +waterfall_min_level = -88 # in dB waterfall_max_level = -20 waterfall_auto_level_margin = (5, 40) ### old theme by HA7ILM: -#waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]" -#waterfall_min_level = -115 #in dB -#waterfall_max_level = 0 -#waterfall_auto_level_margin = (20, 30) +# waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]" +# waterfall_min_level = -115 #in dB +# waterfall_max_level = 0 +# waterfall_auto_level_margin = (20, 30) ##For the old colors, you might also want to set [fft_voverlap_factor] to 0. -#Note: When the auto waterfall level button is clicked, the following happens: +# Note: When the auto waterfall level button is clicked, the following happens: # [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin[0]] # [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin[1]] # @@ -219,17 +222,26 @@ waterfall_auto_level_margin = (5, 40) # current_max_power_level __| # 3D view settings -mathbox_waterfall_frequency_resolution = 128 #bins -mathbox_waterfall_history_length = 10 #seconds -mathbox_waterfall_colors = [0x000000ff,0x2e6893ff,0x69a5d0ff,0x214b69ff,0x9dc4e0ff,0xfff775ff,0xff8a8aff,0xb20000ff] +mathbox_waterfall_frequency_resolution = 128 # bins +mathbox_waterfall_history_length = 10 # seconds +mathbox_waterfall_colors = [ + 0x000000FF, + 0x2E6893FF, + 0x69A5D0FF, + 0x214B69FF, + 0x9DC4E0FF, + 0xFFF775FF, + 0xFF8A8AFF, + 0xB20000FF, +] # === Experimental settings === -#Warning! The settings below are very experimental. -csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr. +# Warning! The settings below are very experimental. +csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr. csdr_print_bufsizes = False # This prints the buffer sizes used for csdr processes. -csdr_through = False # Setting this True will print out how much data is going into the DSP chains. +csdr_through = False # Setting this True will print out how much data is going into the DSP chains. -nmux_memory = 50 #in megabytes. This sets the approximate size of the circular buffer used by nmux. +nmux_memory = 50 # in megabytes. This sets the approximate size of the circular buffer used by nmux. google_maps_api_key = "" diff --git a/csdr.py b/csdr.py index 8b8edef..54fd13c 100755 --- a/csdr.py +++ b/csdr.py @@ -28,26 +28,29 @@ from functools import partial from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper import logging + logger = logging.getLogger(__name__) + class output(object): def add_output(self, type, read_fn): pass + def reset(self): pass -class dsp(object): +class dsp(object): def __init__(self, output): self.samp_rate = 250000 - self.output_rate = 11025 #this is default, and cannot be set at the moment + self.output_rate = 11025 # this is default, and cannot be set at the moment self.fft_size = 1024 self.fft_fps = 5 self.offset_freq = 0 self.low_cut = -4000 self.high_cut = 4000 - self.bpf_transition_bw = 320 #Hz, and this is a constant - self.ddc_transition_bw_rate = 0.15 # of the IF sample rate + self.bpf_transition_bw = 320 # Hz, and this is a constant + self.ddc_transition_bw_rate = 0.15 # of the IF sample rate self.running = False self.secondary_processes_running = False self.audio_compression = "none" @@ -67,9 +70,17 @@ class dsp(object): self.secondary_fft_size = 1024 self.secondary_process_fft = None self.secondary_process_demod = None - self.pipe_names=["bpf_pipe", "shift_pipe", "squelch_pipe", "smeter_pipe", "meta_pipe", "iqtee_pipe", - "iqtee2_pipe", "dmr_control_pipe"] - self.secondary_pipe_names=["secondary_shift_pipe"] + self.pipe_names = [ + "bpf_pipe", + "shift_pipe", + "squelch_pipe", + "smeter_pipe", + "meta_pipe", + "iqtee_pipe", + "iqtee2_pipe", + "dmr_control_pipe", + ] + self.secondary_pipe_names = ["secondary_shift_pipe"] self.secondary_offset_freq = 1000 self.unvoiced_quality = 1 self.modification_lock = threading.Lock() @@ -79,15 +90,19 @@ class dsp(object): def set_temporary_directory(self, what): self.temporary_directory = what - def chain(self,which): + def chain(self, which): chain = ["nc -v 127.0.0.1 {nc_port}"] - if self.csdr_dynamic_bufsize: chain += ["csdr setbuf {start_bufsize}"] - if self.csdr_through: chain += ["csdr through"] + if self.csdr_dynamic_bufsize: + chain += ["csdr setbuf {start_bufsize}"] + if self.csdr_through: + chain += ["csdr through"] if which == "fft": chain += [ "csdr fft_cc {fft_size} {fft_block_size}", - "csdr logpower_cf -70" if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}", - "csdr fft_exchange_sides_ff {fft_size}" + "csdr logpower_cf -70" + if self.fft_averages == 0 + else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}", + "csdr fft_exchange_sides_ff {fft_size}", ] if self.fft_compression == "adpcm": chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"] @@ -96,37 +111,24 @@ class dsp(object): "csdr shift_addition_cc --fifo {shift_pipe}", "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING", "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING", - "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}" + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}", ] if self.secondary_demodulator: - chain += [ - "csdr tee {iqtee_pipe}", - "csdr tee {iqtee2_pipe}" - ] + chain += ["csdr tee {iqtee_pipe}", "csdr tee {iqtee2_pipe}"] # safe some cpu cycles... no need to decimate if decimation factor is 1 - last_decimation_block = ["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else [] + last_decimation_block = ( + ["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else [] + ) if which == "nfm": - chain += [ - "csdr fmdemod_quadri_cf", - "csdr limit_ff" - ] + chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"] chain += last_decimation_block - chain += [ - "csdr deemphasis_nfm_ff {output_rate}", - "csdr convert_f_s16" - ] + chain += ["csdr deemphasis_nfm_ff {output_rate}", "csdr convert_f_s16"] elif self.isDigitalVoice(which): - chain += [ - "csdr fmdemod_quadri_cf", - "dc_block " - ] + chain += ["csdr fmdemod_quadri_cf", "dc_block "] chain += last_decimation_block # dsd modes - if which in [ "dstar", "nxdn" ]: - chain += [ - "csdr limit_ff", - "csdr convert_f_s16" - ] + if which in ["dstar", "nxdn"]: + chain += ["csdr limit_ff", "csdr convert_f_s16"] if which == "dstar": chain += ["dsd -fd -i - -o - -u {unvoiced_quality} -g -1 "] elif which == "nxdn": @@ -135,44 +137,28 @@ class dsp(object): max_gain = 5 # digiham modes else: - chain += [ - "rrc_filter", - "gfsk_demodulator" - ] + chain += ["rrc_filter", "gfsk_demodulator"] if which == "dmr": chain += [ "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}", - "mbe_synthesizer -f -u {unvoiced_quality}" + "mbe_synthesizer -f -u {unvoiced_quality}", ] elif which == "ysf": - chain += [ - "ysf_decoder --fifo {meta_pipe}", - "mbe_synthesizer -y -f -u {unvoiced_quality}" - ] + chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y -f -u {unvoiced_quality}"] max_gain = 0.0005 chain += [ "digitalvoice_filter -f", "CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain}".format(max_gain=max_gain), - "sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + "sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", ] elif which == "am": - chain += [ - "csdr amdemod_cf", - "csdr fastdcblock_ff" - ] + chain += ["csdr amdemod_cf", "csdr fastdcblock_ff"] chain += last_decimation_block - chain += [ - "csdr agc_ff", - "csdr limit_ff", - "csdr convert_f_s16" - ] + chain += ["csdr agc_ff", "csdr limit_ff", "csdr convert_f_s16"] elif which == "ssb": chain += ["csdr realpart_cf"] chain += last_decimation_block - chain += [ - "csdr agc_ff", - "csdr limit_ff" - ] + chain += ["csdr agc_ff", "csdr limit_ff"] # fixed sample rate necessary for the wsjt-x tools. fix with sox... if self.isWsjtMode() and self.get_audio_rate() != self.get_output_rate(): chain += [ @@ -181,24 +167,31 @@ class dsp(object): else: chain += ["csdr convert_f_s16"] - if self.audio_compression=="adpcm": + if self.audio_compression == "adpcm": chain += ["csdr encode_ima_adpcm_i16_u8"] return chain def secondary_chain(self, which): - secondary_chain_base="cat {input_pipe} | " + secondary_chain_base = "cat {input_pipe} | " if which == "fft": - return secondary_chain_base+"csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 " + (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression=="adpcm" else "") + return ( + secondary_chain_base + + "csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 " + + (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression == "adpcm" else "") + ) elif which == "bpsk31": - return secondary_chain_base + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | " + \ - "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | " + \ - "csdr simple_agc_cc 0.001 0.5 | " + \ - "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \ - "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \ - "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8" + return ( + secondary_chain_base + + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | " + + "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | " + + "csdr simple_agc_cc 0.001 0.5 | " + + "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + + "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + + "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8" + ) elif self.isWsjtMode(which): chain = secondary_chain_base + "csdr realpart_cf | " - if self.last_decimation != 1.0 : + if self.last_decimation != 1.0: chain += "csdr fractional_decimator_ff {last_decimation} | " chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16" return chain @@ -211,14 +204,16 @@ class dsp(object): self.restart() def secondary_fft_block_size(self): - return (self.samp_rate/self.decimation)/(self.fft_fps*2) #*2 is there because we do FFT on real signal here + return (self.samp_rate / self.decimation) / ( + self.fft_fps * 2 + ) # *2 is there because we do FFT on real signal here def secondary_decimation(self): - return 1 #currently unused + return 1 # currently unused def secondary_bpf_cutoff(self): if self.secondary_demodulator == "bpsk31": - return 31.25 / self.if_samp_rate() + return 31.25 / self.if_samp_rate() return 0 def secondary_bpf_transition_bw(self): @@ -228,7 +223,7 @@ class dsp(object): def secondary_samples_per_bits(self): if self.secondary_demodulator == "bpsk31": - return int(round(self.if_samp_rate()/31.25))&~3 + return int(round(self.if_samp_rate() / 31.25)) & ~3 return 0 def secondary_bw(self): @@ -236,19 +231,20 @@ class dsp(object): return 31.25 def start_secondary_demodulator(self): - if not self.secondary_demodulator: return - logger.debug("[openwebrx] starting secondary demodulator from IF input sampled at %d"%self.if_samp_rate()) - secondary_command_fft=self.secondary_chain("fft") - secondary_command_demod=self.secondary_chain(self.secondary_demodulator) + if not self.secondary_demodulator: + return + logger.debug("[openwebrx] starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) + secondary_command_fft = self.secondary_chain("fft") + secondary_command_demod = self.secondary_chain(self.secondary_demodulator) self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft) - secondary_command_fft=secondary_command_fft.format( + secondary_command_fft = secondary_command_fft.format( input_pipe=self.iqtee_pipe, secondary_fft_input_size=self.secondary_fft_size, secondary_fft_size=self.secondary_fft_size, secondary_fft_block_size=self.secondary_fft_block_size(), - ) - secondary_command_demod=secondary_command_demod.format( + ) + secondary_command_demod = secondary_command_demod.format( input_pipe=self.iqtee2_pipe, secondary_shift_pipe=self.secondary_shift_pipe, secondary_decimation=self.secondary_decimation(), @@ -256,21 +252,29 @@ class dsp(object): secondary_bpf_cutoff=self.secondary_bpf_cutoff(), secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), if_samp_rate=self.if_samp_rate(), - last_decimation=self.last_decimation - ) + last_decimation=self.last_decimation, + ) logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (fft) = %s", secondary_command_fft) logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (demod) = %s", secondary_command_demod) - my_env=os.environ.copy() - #if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; - if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1"; - self.secondary_process_fft = subprocess.Popen(secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) + my_env = os.environ.copy() + # if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; + if self.csdr_print_bufsizes: + my_env["CSDR_PRINT_BUFSIZES"] = "1" + self.secondary_process_fft = subprocess.Popen( + secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env + ) logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (fft)") - self.secondary_process_demod = subprocess.Popen(secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) #TODO digimodes - logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)") #TODO digimodes + self.secondary_process_demod = subprocess.Popen( + secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env + ) # TODO digimodes + logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)") # TODO digimodes self.secondary_processes_running = True - self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read()))) + self.output.add_output( + "secondary_fft", + partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())), + ) if self.isWsjtMode(): smd = self.get_secondary_demodulator() if smd == "ft8": @@ -288,19 +292,20 @@ class dsp(object): else: self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) - #open control pipes for csdr and send initialization data - if self.secondary_shift_pipe != None: #TODO digimodes - self.secondary_shift_pipe_file=open(self.secondary_shift_pipe,"w") #TODO digimodes - self.set_secondary_offset_freq(self.secondary_offset_freq) #TODO digimodes + # open control pipes for csdr and send initialization data + if self.secondary_shift_pipe != None: # TODO digimodes + self.secondary_shift_pipe_file = open(self.secondary_shift_pipe, "w") # TODO digimodes + self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes def set_secondary_offset_freq(self, value): - self.secondary_offset_freq=value + self.secondary_offset_freq = value if self.secondary_processes_running and hasattr(self, "secondary_shift_pipe_file"): - self.secondary_shift_pipe_file.write("%g\n"%(-float(self.secondary_offset_freq)/self.if_samp_rate())) + self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate())) self.secondary_shift_pipe_file.flush() def stop_secondary_demodulator(self): - if self.secondary_processes_running == False: return + if self.secondary_processes_running == False: + return self.try_delete_pipes(self.secondary_pipe_names) if self.secondary_process_fft: try: @@ -319,42 +324,47 @@ class dsp(object): def get_secondary_demodulator(self): return self.secondary_demodulator - def set_secondary_fft_size(self,secondary_fft_size): - #to change this, restart is required - self.secondary_fft_size=secondary_fft_size + def set_secondary_fft_size(self, secondary_fft_size): + # to change this, restart is required + self.secondary_fft_size = secondary_fft_size - def set_audio_compression(self,what): + def set_audio_compression(self, what): self.audio_compression = what - def set_fft_compression(self,what): + def set_fft_compression(self, what): self.fft_compression = what def get_fft_bytes_to_read(self): - if self.fft_compression=="none": return self.fft_size*4 - if self.fft_compression=="adpcm": return (self.fft_size/2)+(10/2) + if self.fft_compression == "none": + return self.fft_size * 4 + if self.fft_compression == "adpcm": + return (self.fft_size / 2) + (10 / 2) def get_secondary_fft_bytes_to_read(self): - if self.fft_compression=="none": return self.secondary_fft_size*4 - if self.fft_compression=="adpcm": return (self.secondary_fft_size/2)+(10/2) + if self.fft_compression == "none": + return self.secondary_fft_size * 4 + if self.fft_compression == "adpcm": + return (self.secondary_fft_size / 2) + (10 / 2) - def set_samp_rate(self,samp_rate): - self.samp_rate=samp_rate + def set_samp_rate(self, samp_rate): + self.samp_rate = samp_rate self.calculate_decimation() - if self.running: self.restart() + if self.running: + self.restart() def calculate_decimation(self): (self.decimation, self.last_decimation, _) = self.get_decimation(self.samp_rate, self.get_audio_rate()) def get_decimation(self, input_rate, output_rate): - decimation=1 - while input_rate / (decimation+1) >= output_rate: + decimation = 1 + while input_rate / (decimation + 1) >= output_rate: decimation += 1 fraction = float(input_rate / decimation) / output_rate intermediate_rate = input_rate / decimation return (decimation, fraction, intermediate_rate) def if_samp_rate(self): - return self.samp_rate/self.decimation + return self.samp_rate / self.decimation def get_name(self): return self.name @@ -369,59 +379,64 @@ class dsp(object): return 12000 return self.get_output_rate() - def isDigitalVoice(self, demodulator = None): + def isDigitalVoice(self, demodulator=None): if demodulator is None: demodulator = self.get_demodulator() return demodulator in ["dmr", "dstar", "nxdn", "ysf"] - def isWsjtMode(self, demodulator = None): + def isWsjtMode(self, demodulator=None): if demodulator is None: demodulator = self.get_secondary_demodulator() return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"] - def set_output_rate(self,output_rate): - self.output_rate=output_rate + def set_output_rate(self, output_rate): + self.output_rate = output_rate self.calculate_decimation() - def set_demodulator(self,demodulator): - if (self.demodulator == demodulator): return - self.demodulator=demodulator + def set_demodulator(self, demodulator): + if self.demodulator == demodulator: + return + self.demodulator = demodulator self.calculate_decimation() self.restart() def get_demodulator(self): return self.demodulator - def set_fft_size(self,fft_size): - self.fft_size=fft_size + def set_fft_size(self, fft_size): + self.fft_size = fft_size self.restart() - def set_fft_fps(self,fft_fps): - self.fft_fps=fft_fps + def set_fft_fps(self, fft_fps): + self.fft_fps = fft_fps self.restart() - def set_fft_averages(self,fft_averages): - self.fft_averages=fft_averages + def set_fft_averages(self, fft_averages): + self.fft_averages = fft_averages self.restart() def fft_block_size(self): - if self.fft_averages == 0: return self.samp_rate/self.fft_fps - else: return self.samp_rate/self.fft_fps/self.fft_averages + if self.fft_averages == 0: + return self.samp_rate / self.fft_fps + else: + return self.samp_rate / self.fft_fps / self.fft_averages - def set_offset_freq(self,offset_freq): - self.offset_freq=offset_freq + def set_offset_freq(self, offset_freq): + self.offset_freq = offset_freq if self.running: self.modification_lock.acquire() - self.shift_pipe_file.write("%g\n"%(-float(self.offset_freq)/self.samp_rate)) + self.shift_pipe_file.write("%g\n" % (-float(self.offset_freq) / self.samp_rate)) self.shift_pipe_file.flush() self.modification_lock.release() - def set_bpf(self,low_cut,high_cut): - self.low_cut=low_cut - self.high_cut=high_cut + def set_bpf(self, low_cut, high_cut): + self.low_cut = low_cut + self.high_cut = high_cut if self.running: self.modification_lock.acquire() - self.bpf_pipe_file.write( "%g %g\n"%(float(self.low_cut)/self.if_samp_rate(), float(self.high_cut)/self.if_samp_rate()) ) + self.bpf_pipe_file.write( + "%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate()) + ) self.bpf_pipe_file.flush() self.modification_lock.release() @@ -429,12 +444,12 @@ class dsp(object): return [self.low_cut, self.high_cut] def set_squelch_level(self, squelch_level): - self.squelch_level=squelch_level - #no squelch required on digital voice modes + self.squelch_level = squelch_level + # no squelch required on digital voice modes actual_squelch = 0 if self.isDigitalVoice() else self.squelch_level if self.running: self.modification_lock.acquire() - self.squelch_pipe_file.write("%g\n"%(float(actual_squelch))) + self.squelch_pipe_file.write("%g\n" % (float(actual_squelch))) self.squelch_pipe_file.flush() self.modification_lock.release() @@ -450,7 +465,7 @@ class dsp(object): self.dmr_control_pipe_file.write("{0}\n".format(filter)) self.dmr_control_pipe_file.flush() - def mkfifo(self,path): + def mkfifo(self, path): try: os.unlink(path) except: @@ -458,27 +473,28 @@ class dsp(object): os.mkfifo(path) def ddc_transition_bw(self): - return self.ddc_transition_bw_rate*(self.if_samp_rate()/float(self.samp_rate)) + return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate)) def try_create_pipes(self, pipe_names, command_base): for pipe_name in pipe_names: - if "{"+pipe_name+"}" in command_base: - setattr(self, pipe_name, self.pipe_base_path+pipe_name) + if "{" + pipe_name + "}" in command_base: + setattr(self, pipe_name, self.pipe_base_path + pipe_name) self.mkfifo(getattr(self, pipe_name)) else: setattr(self, pipe_name, None) def try_delete_pipes(self, pipe_names): for pipe_name in pipe_names: - pipe_path = getattr(self,pipe_name,None) + pipe_path = getattr(self, pipe_name, None) if pipe_path: - try: os.unlink(pipe_path) + try: + os.unlink(pipe_path) except Exception: logger.exception("try_delete_pipes()") def start(self): self.modification_lock.acquire() - if (self.running): + if self.running: self.modification_lock.release() return self.running = True @@ -486,37 +502,58 @@ class dsp(object): command_base = " | ".join(self.chain(self.demodulator)) logger.debug(command_base) - #create control pipes for csdr + # create control pipes for csdr self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self)) self.try_create_pipes(self.pipe_names, command_base) - #run the command - command=command_base.format( bpf_pipe=self.bpf_pipe, shift_pipe=self.shift_pipe, decimation=self.decimation, - last_decimation=self.last_decimation, fft_size=self.fft_size, fft_block_size=self.fft_block_size(), fft_averages=self.fft_averages, - bpf_transition_bw=float(self.bpf_transition_bw)/self.if_samp_rate(), ddc_transition_bw=self.ddc_transition_bw(), - flowcontrol=int(self.samp_rate*2), start_bufsize=self.base_bufsize*self.decimation, nc_port=self.nc_port, - squelch_pipe=self.squelch_pipe, smeter_pipe=self.smeter_pipe, meta_pipe=self.meta_pipe, iqtee_pipe=self.iqtee_pipe, iqtee2_pipe=self.iqtee2_pipe, - output_rate = self.get_output_rate(), smeter_report_every = int(self.if_samp_rate()/6000), - unvoiced_quality = self.get_unvoiced_quality(), dmr_control_pipe = self.dmr_control_pipe, - audio_rate = self.get_audio_rate()) + # run the command + command = command_base.format( + bpf_pipe=self.bpf_pipe, + shift_pipe=self.shift_pipe, + decimation=self.decimation, + last_decimation=self.last_decimation, + fft_size=self.fft_size, + fft_block_size=self.fft_block_size(), + fft_averages=self.fft_averages, + bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(), + ddc_transition_bw=self.ddc_transition_bw(), + flowcontrol=int(self.samp_rate * 2), + start_bufsize=self.base_bufsize * self.decimation, + nc_port=self.nc_port, + squelch_pipe=self.squelch_pipe, + smeter_pipe=self.smeter_pipe, + meta_pipe=self.meta_pipe, + iqtee_pipe=self.iqtee_pipe, + iqtee2_pipe=self.iqtee2_pipe, + output_rate=self.get_output_rate(), + smeter_report_every=int(self.if_samp_rate() / 6000), + unvoiced_quality=self.get_unvoiced_quality(), + dmr_control_pipe=self.dmr_control_pipe, + audio_rate=self.get_audio_rate(), + ) logger.debug("[openwebrx-dsp-plugin:csdr] Command = %s", command) - my_env=os.environ.copy() - if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; - if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1"; + my_env = os.environ.copy() + if self.csdr_dynamic_bufsize: + my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" + if self.csdr_print_bufsizes: + my_env["CSDR_PRINT_BUFSIZES"] = "1" self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) def watch_thread(): rc = self.process.wait() logger.debug("dsp thread ended with rc=%d", rc) - if (rc == 0 and self.running and not self.modification_lock.locked()): + if rc == 0 and self.running and not self.modification_lock.locked(): logger.debug("restarting since rc = 0, self.running = true, and no modification") self.restart() - threading.Thread(target = watch_thread).start() + threading.Thread(target=watch_thread).start() - self.output.add_output("audio", partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256)) + self.output.add_output( + "audio", + partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256), + ) # open control pipes for csdr if self.bpf_pipe: @@ -537,23 +574,27 @@ class dsp(object): if self.bpf_pipe: self.set_bpf(self.low_cut, self.high_cut) if self.smeter_pipe: - self.smeter_pipe_file=open(self.smeter_pipe,"r") + self.smeter_pipe_file = open(self.smeter_pipe, "r") + def read_smeter(): raw = self.smeter_pipe_file.readline() if len(raw) == 0: return None else: return float(raw.rstrip("\n")) + self.output.add_output("smeter", read_smeter) if self.meta_pipe != None: # TODO make digiham output unicode and then change this here - self.meta_pipe_file=open(self.meta_pipe, "r", encoding="cp437") + self.meta_pipe_file = open(self.meta_pipe, "r", encoding="cp437") + def read_meta(): raw = self.meta_pipe_file.readline() if len(raw) == 0: return None else: return raw.rstrip("\n") + self.output.add_output("meta", read_meta) if self.dmr_control_pipe: @@ -575,10 +616,11 @@ class dsp(object): self.modification_lock.release() def restart(self): - if not self.running: return + if not self.running: + return self.stop() self.start() def __del__(self): self.stop() - del(self.process) + del self.process diff --git a/openwebrx.py b/openwebrx.py index 99b1419..07b48de 100644 --- a/openwebrx.py +++ b/openwebrx.py @@ -1,13 +1,14 @@ from http.server import HTTPServer from owrx.http import RequestHandler from owrx.config import PropertyManager -from owrx.feature import FeatureDetector +from owrx.feature import FeatureDetector from owrx.source import SdrService, ClientRegistry from socketserver import ThreadingMixIn from owrx.sdrhu import SdrHuUpdater import logging -logging.basicConfig(level = logging.DEBUG, format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s") + +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") class ThreadedHttpServer(ThreadingMixIn, HTTPServer): @@ -15,21 +16,25 @@ class ThreadedHttpServer(ThreadingMixIn, HTTPServer): def main(): - print(""" + print( + """ OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package _________________________________________________________________________________________________ Author contact info: Andras Retzler, HA7ILM - """) + """ + ) pm = PropertyManager.getSharedInstance().loadConfig("config_webrx") featureDetector = FeatureDetector() if not featureDetector.is_available("core"): - print("you are missing required dependencies to run openwebrx. " - "please check that the following core requirements are installed:") + print( + "you are missing required dependencies to run openwebrx. " + "please check that the following core requirements are installed:" + ) print(", ".join(featureDetector.get_requirements("core"))) return @@ -40,7 +45,7 @@ Author contact info: Andras Retzler, HA7ILM updater = SdrHuUpdater() updater.start() - server = ThreadedHttpServer(('0.0.0.0', pm.getPropertyValue("web_port")), RequestHandler) + server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler) server.serve_forever() diff --git a/owrx/bands.py b/owrx/bands.py index 5971bf8..bc76b2a 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -1,6 +1,7 @@ import json import logging + logger = logging.getLogger(__name__) @@ -16,7 +17,11 @@ class Band(object): freqs = [freqs] for f in freqs: if not self.inBand(f): - logger.warning("Frequency for {mode} on {band} is not within band limits: {frequency}".format(mode = mode, frequency = f, band = self.name)) + logger.warning( + "Frequency for {mode} on {band} is not within band limits: {frequency}".format( + mode=mode, frequency=f, band=self.name + ) + ) else: self.frequencies.append({"mode": mode, "frequency": f}) @@ -33,6 +38,7 @@ class Band(object): class Bandplan(object): sharedInstance = None + @staticmethod def getSharedInstance(): if Bandplan.sharedInstance is None: diff --git a/owrx/config.py b/owrx/config.py index 6db151e..d8b6ad2 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -1,4 +1,5 @@ import logging + logger = logging.getLogger(__name__) @@ -15,7 +16,7 @@ class Subscription(object): class Property(object): - def __init__(self, value = None): + def __init__(self, value=None): self.value = value self.subscribers = [] @@ -23,7 +24,7 @@ class Property(object): return self.value def setValue(self, value): - if (self.value == value): + if self.value == value: return self self.value = value for c in self.subscribers: @@ -36,7 +37,8 @@ class Property(object): def wire(self, callback): sub = Subscription(self, callback) self.subscribers.append(sub) - if not self.value is None: sub.call(self.value) + if not self.value is None: + sub.call(self.value) return sub def unwire(self, sub): @@ -47,8 +49,10 @@ class Property(object): pass return self + class PropertyManager(object): sharedInstance = None + @staticmethod def getSharedInstance(): if PropertyManager.sharedInstance is None: @@ -56,9 +60,11 @@ class PropertyManager(object): return PropertyManager.sharedInstance def collect(self, *props): - return PropertyManager({name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props}) + return PropertyManager( + {name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props} + ) - def __init__(self, properties = None): + def __init__(self, properties=None): self.properties = {} self.subscribers = [] if properties is not None: @@ -67,12 +73,14 @@ class PropertyManager(object): def add(self, name, prop): self.properties[name] = prop + def fireCallbacks(value): for c in self.subscribers: try: c.call(name, value) except Exception as e: logger.exception(e) + prop.wire(fireCallbacks) return self @@ -88,7 +96,7 @@ class PropertyManager(object): self.getProperty(name).setValue(value) def __dict__(self): - return {k:v.getValue() for k, v in self.properties.items()} + return {k: v.getValue() for k, v in self.properties.items()} def hasProperty(self, name): return name in self.properties diff --git a/owrx/connection.py b/owrx/connection.py index 17551ff..b68cbaf 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -7,6 +7,7 @@ import json from owrx.map import Map import logging + logger = logging.getLogger(__name__) @@ -29,11 +30,26 @@ class Client(object): class OpenWebRxReceiverClient(Client): - 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", "mathbox_waterfall_colors", - "mathbox_waterfall_history_length", "mathbox_waterfall_frequency_resolution"] + 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", + "mathbox_waterfall_colors", + "mathbox_waterfall_history_length", + "mathbox_waterfall_frequency_resolution", + ] def __init__(self, conn): super().__init__(conn) @@ -49,12 +65,23 @@ class OpenWebRxReceiverClient(Client): self.setSdr() # send receiver info - receiver_keys = ["receiver_name", "receiver_location", "receiver_qra", "receiver_asl", "receiver_gps", - "photo_title", "photo_desc"] + 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) - profiles = [{"name": s.getName() + " " + p["name"], "id":sid + "|" + pid} for (sid, s) in SdrService.getSources().items() for (pid, p) in s.getProfiles().items()] + profiles = [ + {"name": s.getName() + " " + p["name"], "id": sid + "|" + pid} + for (sid, s) in SdrService.getSources().items() + for (pid, p) in s.getProfiles().items() + ] self.write_profiles(profiles) features = FeatureDetector().feature_availability() @@ -62,9 +89,9 @@ class OpenWebRxReceiverClient(Client): CpuUsageThread.getSharedInstance().add_client(self) - def setSdr(self, id = None): + def setSdr(self, id=None): next = SdrService.getSource(id) - if (next == self.sdr): + if next == self.sdr: return self.stopDsp() @@ -76,7 +103,11 @@ class OpenWebRxReceiverClient(Client): self.sdr = next # send initial config - configProps = self.sdr.getProps().collect(*OpenWebRxReceiverClient.config_keys).defaults(PropertyManager.getSharedInstance()) + configProps = ( + self.sdr.getProps() + .collect(*OpenWebRxReceiverClient.config_keys) + .defaults(PropertyManager.getSharedInstance()) + ) def sendConfig(key, value): config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys) @@ -89,7 +120,6 @@ class OpenWebRxReceiverClient(Client): frequencyRange = (cf - srh, cf + srh) self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencis(frequencyRange)) - self.configSub = configProps.wire(sendConfig) sendConfig(None, None) @@ -118,8 +148,11 @@ class OpenWebRxReceiverClient(Client): 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", "if_gain") \ + protected = ( + self.sdr.getProps() + .collect("samp_rate", "center_freq", "rf_gain", "type", "if_gain") .defaults(PropertyManager.getSharedInstance()) + ) for key, value in params.items(): protected[key] = value @@ -134,13 +167,13 @@ class OpenWebRxReceiverClient(Client): self.protected_send(bytes([0x02]) + data) def write_s_meter_level(self, level): - self.protected_send({"type":"smeter","value":level}) + self.protected_send({"type": "smeter", "value": level}) def write_cpu_usage(self, usage): - self.protected_send({"type":"cpuusage","value":usage}) + self.protected_send({"type": "cpuusage", "value": usage}) def write_clients(self, clients): - self.protected_send({"type":"clients","value":clients}) + self.protected_send({"type": "clients", "value": clients}) def write_secondary_fft(self, data): self.protected_send(bytes([0x03]) + data) @@ -149,22 +182,22 @@ class OpenWebRxReceiverClient(Client): self.protected_send(bytes([0x04]) + data) def write_secondary_dsp_config(self, cfg): - self.protected_send({"type":"secondary_config", "value":cfg}) + self.protected_send({"type": "secondary_config", "value": cfg}) def write_config(self, cfg): - self.protected_send({"type":"config","value":cfg}) + self.protected_send({"type": "config", "value": cfg}) def write_receiver_details(self, details): - self.protected_send({"type":"receiver_details","value":details}) + self.protected_send({"type": "receiver_details", "value": details}) def write_profiles(self, profiles): - self.protected_send({"type":"profiles","value":profiles}) + self.protected_send({"type": "profiles", "value": profiles}) def write_features(self, features): - self.protected_send({"type":"features","value":features}) + self.protected_send({"type": "features", "value": features}) def write_metadata(self, metadata): - self.protected_send({"type":"metadata","value":metadata}) + self.protected_send({"type": "metadata", "value": metadata}) def write_wsjt_message(self, message): self.protected_send({"type": "wsjt_message", "value": message}) @@ -187,10 +220,11 @@ class MapConnection(Client): super().close() def write_config(self, cfg): - self.protected_send({"type":"config","value":cfg}) + self.protected_send({"type": "config", "value": cfg}) def write_update(self, update): - self.protected_send({"type":"update","value":update}) + self.protected_send({"type": "update", "value": update}) + class WebSocketMessageHandler(object): def __init__(self): @@ -199,11 +233,11 @@ class WebSocketMessageHandler(object): self.dsp = None def handleTextMessage(self, conn, message): - if (message[:16] == "SERVER DE CLIENT"): + if message[:16] == "SERVER DE CLIENT": meta = message[17:].split(" ") self.handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)} - conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version = openwebrx_version)) + conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version)) logger.debug("client connection intitialized") if "type" in self.handshake: diff --git a/owrx/controllers.py b/owrx/controllers.py index c011677..c6c0da5 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -11,13 +11,16 @@ from owrx.version import openwebrx_version from owrx.feature import FeatureDetector import logging + logger = logging.getLogger(__name__) + class Controller(object): def __init__(self, handler, request): self.handler = handler self.request = request - def send_response(self, content, code = 200, content_type = "text/html", last_modified: datetime = None, max_age = None): + + def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None): self.handler.send_response(code) if content_type is not None: self.handler.send_header("Content-Type", content_type) @@ -26,7 +29,7 @@ class Controller(object): if max_age is not None: self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age)) self.handler.end_headers() - if (type(content) == str): + if type(content) == str: content = content.encode() self.handler.wfile.write(content) @@ -45,44 +48,49 @@ class StatusController(Controller): "asl": pm["receiver_asl"], "loc": pm["receiver_location"], "sw_version": openwebrx_version, - "avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png") + "avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png"), } - self.send_response("\n".join(["{key}={value}".format(key = key, value = value) for key, value in vars.items()])) + self.send_response("\n".join(["{key}={value}".format(key=key, value=value) for key, value in vars.items()])) + class AssetsController(Controller): - def serve_file(self, file, content_type = None): + def serve_file(self, file, content_type=None): try: - modified = datetime.fromtimestamp(os.path.getmtime('htdocs/' + file)) + modified = datetime.fromtimestamp(os.path.getmtime("htdocs/" + file)) if "If-Modified-Since" in self.handler.headers: - client_modified = datetime.strptime(self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z") + client_modified = datetime.strptime( + self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z" + ) if modified <= client_modified: - self.send_response("", code = 304) + self.send_response("", code=304) return - f = open('htdocs/' + file, 'rb') + f = open("htdocs/" + file, "rb") data = f.read() f.close() if content_type is None: (content_type, encoding) = mimetypes.MimeTypes().guess_type(file) - self.send_response(data, content_type = content_type, last_modified = modified, max_age = 3600) + self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600) except FileNotFoundError: - self.send_response("file not found", code = 404) + self.send_response("file not found", code=404) + def handle_request(self): filename = self.request.matches.group(1) self.serve_file(filename) + class TemplateController(Controller): def render_template(self, file, **vars): - f = open('htdocs/' + file, 'r') + f = open("htdocs/" + file, "r") template = Template(f.read()) f.close() return template.safe_substitute(**vars) def serve_template(self, file, **vars): - self.send_response(self.render_template(file, **vars), content_type = 'text/html') + self.send_response(self.render_template(file, **vars), content_type="text/html") def default_variables(self): return {} @@ -90,8 +98,8 @@ class TemplateController(Controller): class WebpageController(TemplateController): def template_variables(self): - header = self.render_template('include/header.include.html') - return { "header": header } + header = self.render_template("include/header.include.html") + return {"header": header} class IndexController(WebpageController): @@ -101,17 +109,20 @@ class IndexController(WebpageController): class MapController(WebpageController): def handle_request(self): - #TODO check if we have a google maps api key first? + # TODO check if we have a google maps api key first? self.serve_template("map.html", **self.template_variables()) + class FeatureController(WebpageController): def handle_request(self): self.serve_template("features.html", **self.template_variables()) + class ApiController(Controller): def handle_request(self): data = json.dumps(FeatureDetector().feature_report()) - self.send_response(data, content_type = "application/json") + self.send_response(data, content_type="application/json") + class WebSocketController(Controller): def handle_request(self): diff --git a/owrx/feature.py b/owrx/feature.py index cd67f62..66fe1f9 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -7,6 +7,7 @@ from distutils.version import LooseVersion import inspect import logging + logger = logging.getLogger(__name__) @@ -16,14 +17,14 @@ class UnknownFeatureException(Exception): class FeatureDetector(object): features = { - "core": [ "csdr", "nmux", "nc" ], - "rtl_sdr": [ "rtl_sdr" ], - "sdrplay": [ "rx_tools" ], - "hackrf": [ "hackrf_transfer" ], - "airspy": [ "airspy_rx" ], - "digital_voice_digiham": [ "digiham", "sox" ], - "digital_voice_dsd": [ "dsd", "sox", "digiham" ], - "wsjt-x": [ "wsjtx", "sox" ] + "core": ["csdr", "nmux", "nc"], + "rtl_sdr": ["rtl_sdr"], + "sdrplay": ["rx_tools"], + "hackrf": ["hackrf_transfer"], + "airspy": ["airspy_rx"], + "digital_voice_digiham": ["digiham", "sox"], + "digital_voice_dsd": ["dsd", "sox", "digiham"], + "wsjt-x": ["wsjtx", "sox"], } def feature_availability(self): @@ -36,14 +37,14 @@ class FeatureDetector(object): "available": available, # as of now, features are always enabled as soon as they are available. this may change in the future. "enabled": available, - "description": self.get_requirement_description(name) + "description": self.get_requirement_description(name), } def feature_details(name): return { "description": "", "available": self.is_available(name), - "requirements": {name: requirement_details(name) for name in self.get_requirements(name)} + "requirements": {name: requirement_details(name) for name in self.get_requirements(name)}, } return {name: feature_details(name) for name in FeatureDetector.features} @@ -55,7 +56,7 @@ class FeatureDetector(object): try: return FeatureDetector.features[feature] except KeyError: - raise UnknownFeatureException("Feature \"{0}\" is not known.".format(feature)) + raise UnknownFeatureException('Feature "{0}" is not known.'.format(feature)) def has_requirements(self, requirements): passed = True @@ -102,7 +103,7 @@ class FeatureDetector(object): Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended for better performance) or GNU netcat packages. Please check your distribution package manager for options. """ - return self.command_is_runnable('nc --help') + return self.command_is_runnable("nc --help") def has_rtl_sdr(self): """ @@ -156,7 +157,8 @@ class FeatureDetector(object): """ required_version = LooseVersion("0.2") - digiham_version_regex = re.compile('^digiham version (.*)$') + digiham_version_regex = re.compile("^digiham version (.*)$") + def check_digiham_version(command): try: process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) @@ -165,14 +167,21 @@ class FeatureDetector(object): return version >= required_version except FileNotFoundError: return False + return reduce( and_, map( check_digiham_version, - ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator", - "digitalvoice_filter"] + [ + "rrc_filter", + "ysf_decoder", + "dmr_decoder", + "mbe_synthesizer", + "gfsk_demodulator", + "digitalvoice_filter", + ], ), - True + True, ) def has_dsd(self): @@ -203,11 +212,4 @@ class FeatureDetector(object): [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions on how to build from source. """ - return reduce( - and_, - map( - self.command_is_runnable, - ["jt9", "wsprd"] - ), - True - ) + return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) diff --git a/owrx/http.py b/owrx/http.py index ce821b9..99c1003 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,23 +1,36 @@ -from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController, MapController, FeatureController, ApiController +from owrx.controllers import ( + StatusController, + IndexController, + AssetsController, + WebSocketController, + MapController, + FeatureController, + ApiController, +) from http.server import BaseHTTPRequestHandler import re from urllib.parse import urlparse, parse_qs import logging + logger = logging.getLogger(__name__) + class RequestHandler(BaseHTTPRequestHandler): def __init__(self, request, client_address, server): self.router = Router() super().__init__(request, client_address, server) + def do_GET(self): self.router.route(self) + class Request(object): - def __init__(self, query = None, matches = None): + def __init__(self, query=None, matches=None): self.query = query self.matches = matches + class Router(object): mappings = [ {"route": "/", "controller": IndexController}, @@ -29,8 +42,9 @@ class Router(object): {"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController}, {"route": "/map", "controller": MapController}, {"route": "/features", "controller": FeatureController}, - {"route": "/api/features", "controller": ApiController} + {"route": "/api/features", "controller": ApiController}, ] + def find_controller(self, path): for m in Router.mappings: if "route" in m: @@ -41,13 +55,16 @@ class Router(object): matches = regex.match(path) if matches: return (m["controller"], matches) + def route(self, handler): url = urlparse(handler.path) res = self.find_controller(url.path) if res is not None: (controller, matches) = res query = parse_qs(url.query) - logger.debug("path: {0}, controller: {1}, query: {2}, matches: {3}".format(handler.path, controller, query, matches)) + logger.debug( + "path: {0}, controller: {1}, query: {2}, matches: {3}".format(handler.path, controller, query, matches) + ) request = Request(query, matches) controller(handler, request).handle_request() else: diff --git a/owrx/map.py b/owrx/map.py index a6adbb6..9908d7a 100644 --- a/owrx/map.py +++ b/owrx/map.py @@ -4,6 +4,7 @@ from owrx.config import PropertyManager from owrx.bands import Band import logging + logger = logging.getLogger(__name__) @@ -14,6 +15,7 @@ class Location(object): class Map(object): sharedInstance = None + @staticmethod def getSharedInstance(): if Map.sharedInstance is None: @@ -41,16 +43,18 @@ class Map(object): def addClient(self, client): self.clients.append(client) - client.write_update([ - { - "callsign": callsign, - "location": record["location"].__dict__(), - "lastseen": record["updated"].timestamp() * 1000, - "mode" : record["mode"], - "band" : record["band"].getName() if record["band"] is not None else None - } - for (callsign, record) in self.positions.items() - ]) + client.write_update( + [ + { + "callsign": callsign, + "location": record["location"].__dict__(), + "lastseen": record["updated"].timestamp() * 1000, + "mode": record["mode"], + "band": record["band"].getName() if record["band"] is not None else None, + } + for (callsign, record) in self.positions.items() + ] + ) def removeClient(self, client): try: @@ -61,15 +65,17 @@ class Map(object): def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None): ts = datetime.now() self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band} - self.broadcast([ - { - "callsign": callsign, - "location": loc.__dict__(), - "lastseen": ts.timestamp() * 1000, - "mode" : mode, - "band" : band.getName() if band is not None else None - } - ]) + self.broadcast( + [ + { + "callsign": callsign, + "location": loc.__dict__(), + "lastseen": ts.timestamp() * 1000, + "mode": mode, + "band": band.getName() if band is not None else None, + } + ] + ) def removeLocation(self, callsign): self.positions.pop(callsign, None) @@ -84,17 +90,14 @@ class Map(object): for callsign in to_be_removed: self.removeLocation(callsign) + class LatLngLocation(Location): def __init__(self, lat: float, lon: float): self.lat = lat self.lon = lon def __dict__(self): - return { - "type":"latlon", - "lat":self.lat, - "lon":self.lon - } + return {"type": "latlon", "lat": self.lat, "lon": self.lon} class LocatorLocation(Location): @@ -102,7 +105,4 @@ class LocatorLocation(Location): self.locator = locator def __dict__(self): - return { - "type":"locator", - "locator":self.locator - } + return {"type": "locator", "locator": self.locator} diff --git a/owrx/meta.py b/owrx/meta.py index 8a85bad..1d7a63f 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -8,8 +8,10 @@ from owrx.map import Map, LatLngLocation logger = logging.getLogger(__name__) + class DmrCache(object): sharedInstance = None + @staticmethod def getSharedInstance(): if DmrCache.sharedInstance is None: @@ -18,21 +20,20 @@ class DmrCache(object): def __init__(self): self.cache = {} - self.cacheTimeout = timedelta(seconds = 86400) + self.cacheTimeout = timedelta(seconds=86400) def isValid(self, key): - if not key in self.cache: return False + if not key in self.cache: + return False entry = self.cache[key] return entry["timestamp"] + self.cacheTimeout > datetime.now() def put(self, key, value): - self.cache[key] = { - "timestamp": datetime.now(), - "data": value - } + self.cache[key] = {"timestamp": datetime.now(), "data": value} def get(self, key): - if not self.isValid(key): return None + if not self.isValid(key): + return None return self.cache[key]["data"] @@ -52,8 +53,10 @@ class DmrMetaEnricher(object): del self.threads[id] def enrich(self, meta): - if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: return None - if not "source" in meta: return None + if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: + return None + if not "source" in meta: + return None id = meta["source"] cache = DmrCache.getSharedInstance() if not cache.isValid(id): @@ -77,10 +80,7 @@ class YsfMetaEnricher(object): class MetaParser(object): - enrichers = { - "DMR": DmrMetaEnricher(), - "YSF": YsfMetaEnricher() - } + enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher()} def __init__(self, handler): self.handler = handler @@ -93,6 +93,6 @@ class MetaParser(object): protocol = meta["protocol"] if protocol in MetaParser.enrichers: additional_data = MetaParser.enrichers[protocol].enrich(meta) - if additional_data is not None: meta["additional"] = additional_data + if additional_data is not None: + meta["additional"] = additional_data self.handler.write_metadata(meta) - diff --git a/owrx/sdrhu.py b/owrx/sdrhu.py index 5f0d7fb..c84a2f5 100644 --- a/owrx/sdrhu.py +++ b/owrx/sdrhu.py @@ -4,23 +4,26 @@ import time from owrx.config import PropertyManager import logging + logger = logging.getLogger(__name__) class SdrHuUpdater(threading.Thread): def __init__(self): self.doRun = True - super().__init__(daemon = True) + super().__init__(daemon=True) def update(self): pm = PropertyManager.getSharedInstance() - cmd = "wget --timeout=15 -4qO- https://sdr.hu/update --post-data \"url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}\" 2>&1".format(**pm.__dict__()) + cmd = 'wget --timeout=15 -4qO- https://sdr.hu/update --post-data "url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}" 2>&1'.format( + **pm.__dict__() + ) logger.debug(cmd) - returned=subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate() - returned=returned[0].decode('utf-8') + returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate() + returned = returned[0].decode("utf-8") if "UPDATE:" in returned: retrytime_mins = 20 - value=returned.split("UPDATE:")[1].split("\n",1)[0] + value = returned.split("UPDATE:")[1].split("\n", 1)[0] if value.startswith("SUCCESS"): logger.info("Update succeeded!") else: @@ -33,4 +36,4 @@ class SdrHuUpdater(threading.Thread): def run(self): while self.doRun: retrytime_mins = self.update() - time.sleep(60*retrytime_mins) + time.sleep(60 * retrytime_mins) diff --git a/owrx/source.py b/owrx/source.py index 2e2bca7..da2e928 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -14,10 +14,12 @@ import logging logger = logging.getLogger(__name__) + class SdrService(object): sdrProps = None sources = {} lastPort = None + @staticmethod def getNextPort(): pm = PropertyManager.getSharedInstance() @@ -29,45 +31,61 @@ class SdrService(object): if SdrService.lastPort > end: raise IndexError("no more available ports to start more sdrs") return SdrService.lastPort + @staticmethod def loadProps(): if SdrService.sdrProps is None: pm = PropertyManager.getSharedInstance() featureDetector = FeatureDetector() + def loadIntoPropertyManager(dict: dict): propertyManager = PropertyManager() for (name, value) in dict.items(): propertyManager[name] = value return propertyManager + def sdrTypeAvailable(value): try: if not featureDetector.is_available(value["type"]): - logger.error("The RTL source type \"{0}\" is not available. please check requirements.".format(value["type"])) + logger.error( + 'The RTL source type "{0}" is not available. please check requirements.'.format( + value["type"] + ) + ) return False return True except UnknownFeatureException: - logger.error("The RTL source type \"{0}\" is invalid. Please check your configuration".format(value["type"])) + logger.error( + 'The RTL source type "{0}" is invalid. Please check your configuration'.format(value["type"]) + ) return False + # transform all dictionary items into PropertyManager object, filtering out unavailable ones SdrService.sdrProps = { name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value) } - logger.info("SDR sources loaded. Availables SDRs: {0}".format(", ".join(map(lambda x: x["name"], SdrService.sdrProps.values())))) + logger.info( + "SDR sources loaded. Availables SDRs: {0}".format( + ", ".join(map(lambda x: x["name"], SdrService.sdrProps.values())) + ) + ) + @staticmethod - def getSource(id = None): + def getSource(id=None): SdrService.loadProps() 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] sources = SdrService.getSources() return sources[id] + @staticmethod def getSources(): SdrService.loadProps() for id in SdrService.sdrProps.keys(): if not id in SdrService.sources: props = SdrService.sdrProps[id] - className = ''.join(x for x in props["type"].title() if x.isalnum()) + "Source" + className = "".join(x for x in props["type"].title() if x.isalnum()) + "Source" cls = getattr(sys.modules[__name__], className) SdrService.sources[id] = cls(props, SdrService.getNextPort()) return SdrService.sources @@ -85,6 +103,7 @@ class SdrSource(object): logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value)) self.stop() self.start() + self.rtlProps.wire(restart) self.port = port self.monitor = None @@ -102,7 +121,7 @@ class SdrSource(object): def getFormatConversion(self): return None - def activateProfile(self, id = None): + def activateProfile(self, id=None): profiles = self.props["profiles"] if id is None: id = list(profiles.keys())[0] @@ -110,7 +129,8 @@ class SdrSource(object): profile = profiles[id] for (key, value) in profile.items(): # skip the name, that would overwrite the source name. - if key == "name": continue + if key == "name": + continue self.props[key] = value def getProfiles(self): @@ -134,7 +154,9 @@ class SdrSource(object): props = self.rtlProps start_sdr_command = self.getCommand().format( - **props.collect("samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain").__dict__() + **props.collect( + "samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain" + ).__dict__() ) format_conversion = self.getFormatConversion() @@ -142,14 +164,22 @@ class SdrSource(object): start_sdr_command += " | " + format_conversion nmux_bufcnt = nmux_bufsize = 0 - while nmux_bufsize < props["samp_rate"]/4: nmux_bufsize += 4096 - while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6: nmux_bufcnt += 1 + while nmux_bufsize < props["samp_rate"] / 4: + nmux_bufsize += 4096 + while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6: + nmux_bufcnt += 1 if nmux_bufcnt == 0 or nmux_bufsize == 0: - logger.error("Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py") + logger.error( + "Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py" + ) self.modificationLock.release() return logger.debug("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, self.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) logger.info("Started rtl source: " + cmd) @@ -158,7 +188,7 @@ class SdrSource(object): logger.debug("shut down with RC={0}".format(rc)) self.monitor = None - self.monitor = threading.Thread(target = wait_for_process_to_end) + self.monitor = threading.Thread(target=wait_for_process_to_end) self.monitor.start() while True: @@ -201,6 +231,7 @@ class SdrSource(object): def addClient(self, c): self.clients.append(c) self.start() + def removeClient(self, c): try: self.clients.remove(c) @@ -236,6 +267,7 @@ class RtlSdrSource(SdrSource): def getFormatConversion(self): return "csdr convert_u8_f" + class HackrfSource(SdrSource): def getCommand(self): return "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-" @@ -243,39 +275,54 @@ class HackrfSource(SdrSource): def getFormatConversion(self): return "csdr convert_s8_f" + class SdrplaySource(SdrSource): def getCommand(self): command = "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm}" - gainMap = { "rf_gain" : "RFGR", "if_gain" : "IFGR"} - gains = [ "{0}={{{1}}}".format(gainMap[name], name) for (name, value) in self.rtlProps.collect("rf_gain", "if_gain").__dict__().items() if value is not None ] + gainMap = {"rf_gain": "RFGR", "if_gain": "IFGR"} + gains = [ + "{0}={{{1}}}".format(gainMap[name], name) + for (name, value) in self.rtlProps.collect("rf_gain", "if_gain").__dict__().items() + if value is not None + ] if gains: - command += " -g {gains}".format(gains = ",".join(gains)) + command += " -g {gains}".format(gains=",".join(gains)) if self.rtlProps["antenna"] is not None: - command += " -a \"{antenna}\"" + command += ' -a "{antenna}"' command += " -" return command def sleepOnRestart(self): time.sleep(1) + class AirspySource(SdrSource): def getCommand(self): - frequency = self.props['center_freq'] / 1e6 + frequency = self.props["center_freq"] / 1e6 command = "airspy_rx" command += " -f{0}".format(frequency) command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}" return command + def getFormatConversion(self): return "csdr convert_s16_f" + class SpectrumThread(csdr.output): def __init__(self, sdrSource): self.sdrSource = sdrSource super().__init__() self.props = props = self.sdrSource.props.collect( - "samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor", "fft_compression", - "csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through", "temporary_directory" + "samp_rate", + "fft_size", + "fft_fps", + "fft_voverlap_factor", + "fft_compression", + "csdr_dynamic_bufsize", + "csdr_print_bufsizes", + "csdr_through", + "temporary_directory", ).defaults(PropertyManager.getSharedInstance()) self.dsp = dsp = csdr.dsp(self) @@ -288,7 +335,11 @@ class SpectrumThread(csdr.output): fft_fps = props["fft_fps"] fft_voverlap_factor = props["fft_voverlap_factor"] - dsp.set_fft_averages(int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor))) if fft_voverlap_factor>0 else 0) + dsp.set_fft_averages( + int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor))) + if fft_voverlap_factor > 0 + else 0 + ) self.subscriptions = [ props.getProperty("samp_rate").wire(dsp.set_samp_rate), @@ -296,7 +347,7 @@ class SpectrumThread(csdr.output): props.getProperty("fft_fps").wire(dsp.set_fft_fps), props.getProperty("fft_compression").wire(dsp.set_fft_compression), props.getProperty("temporary_directory").wire(dsp.set_temporary_directory), - props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages) + props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), ] set_fft_averages(None, None) @@ -317,7 +368,7 @@ class SpectrumThread(csdr.output): return if self.props["csdr_dynamic_bufsize"]: - read_fn(8) #dummy read to skip bufsize & preamble + read_fn(8) # dummy read to skip bufsize & preamble logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1") def pipe(): @@ -329,7 +380,7 @@ class SpectrumThread(csdr.output): else: self.sdrSource.writeSpectrumData(data) - threading.Thread(target = pipe).start() + threading.Thread(target=pipe).start() def stop(self): self.dsp.stop() @@ -340,9 +391,11 @@ class SpectrumThread(csdr.output): def onSdrAvailable(self): self.dsp.start() + def onSdrUnavailable(self): self.dsp.stop() + class DspManager(csdr.output): def __init__(self, handler, sdrSource): self.handler = handler @@ -350,11 +403,24 @@ class DspManager(csdr.output): self.metaParser = MetaParser(self.handler) self.wsjtParser = WsjtParser(self.handler) - self.localProps = self.sdrSource.getProps().collect( - "audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", - "csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", - "dmr_filter", "temporary_directory", "center_freq" - ).defaults(PropertyManager.getSharedInstance()) + self.localProps = ( + self.sdrSource.getProps() + .collect( + "audio_compression", + "fft_compression", + "digimodes_fft_size", + "csdr_dynamic_bufsize", + "csdr_print_bufsizes", + "csdr_through", + "digimodes_enable", + "samp_rate", + "digital_voice_unvoiced_quality", + "dmr_filter", + "temporary_directory", + "center_freq", + ) + .defaults(PropertyManager.getSharedInstance()) + ) self.dsp = csdr.dsp(self) self.dsp.nc_port = self.sdrSource.getPort() @@ -386,28 +452,33 @@ class DspManager(csdr.output): self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality), self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter), self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory), - self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq) + self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq), ] self.dsp.set_offset_freq(0) - self.dsp.set_bpf(-4000,4000) + self.dsp.set_bpf(-4000, 4000) 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"] - if (self.localProps["digimodes_enable"]): + if self.localProps["digimodes_enable"]: + def set_secondary_mod(mod): - if mod == False: mod = None + if mod == False: + mod = None self.dsp.set_secondary_demodulator(mod) if mod is not None: - self.handler.write_secondary_dsp_config({ - "secondary_fft_size":self.localProps["digimodes_fft_size"], - "if_samp_rate":self.dsp.if_samp_rate(), - "secondary_bw":self.dsp.secondary_bw() - }) + self.handler.write_secondary_dsp_config( + { + "secondary_fft_size": self.localProps["digimodes_fft_size"], + "if_samp_rate": self.dsp.if_samp_rate(), + "secondary_bw": self.dsp.secondary_bw(), + } + ) + self.subscriptions += [ self.localProps.getProperty("secondary_mod").wire(set_secondary_mod), - self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq) + self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq), ] self.sdrSource.addClient(self) @@ -426,7 +497,7 @@ class DspManager(csdr.output): "secondary_fft": self.handler.write_secondary_fft, "secondary_demod": self.handler.write_secondary_demod, "meta": self.metaParser.parse, - "wsjt_demod": self.wsjtParser.parse + "wsjt_demod": self.wsjtParser.parse, } write = writers[t] @@ -440,6 +511,7 @@ class DspManager(csdr.output): run = False else: write(data) + return copy threading.Thread(target=pump(read_fn, write)).start() @@ -462,8 +534,10 @@ class DspManager(csdr.output): logger.debug("received onSdrUnavailable, shutting down DspSource") self.dsp.stop() + class CpuUsageThread(threading.Thread): sharedInstance = None + @staticmethod def getSharedInstance(): if CpuUsageThread.sharedInstance is None: @@ -491,21 +565,23 @@ class CpuUsageThread(threading.Thread): def get_cpu_usage(self): try: - f = open("/proc/stat","r") + f = open("/proc/stat", "r") except: - return 0 #Workaround, possibly we're on a Mac + return 0 # Workaround, possibly we're on a Mac line = "" - while not "cpu " in line: line=f.readline() + while not "cpu " in line: + line = f.readline() f.close() spl = line.split(" ") worktime = int(spl[2]) + int(spl[3]) + int(spl[4]) idletime = int(spl[5]) - dworktime = (worktime - self.last_worktime) - didletime = (idletime - self.last_idletime) - rate = float(dworktime) / (didletime+dworktime) + dworktime = worktime - self.last_worktime + didletime = idletime - self.last_idletime + rate = float(dworktime) / (didletime + dworktime) self.last_worktime = worktime self.last_idletime = idletime - if (self.last_worktime==0): return 0 + if self.last_worktime == 0: + return 0 return rate def add_client(self, c): @@ -523,11 +599,14 @@ class CpuUsageThread(threading.Thread): CpuUsageThread.sharedInstance = None self.doRun = False + class TooManyClientsException(Exception): pass + class ClientRegistry(object): sharedInstance = None + @staticmethod def getSharedInstance(): if ClientRegistry.sharedInstance is None: @@ -558,4 +637,4 @@ class ClientRegistry(object): self.clients.remove(client) except ValueError: pass - self.broadcast() \ No newline at end of file + self.broadcast() diff --git a/owrx/version.py b/owrx/version.py index 7437eda..73f2d99 100644 --- a/owrx/version.py +++ b/owrx/version.py @@ -1 +1 @@ -openwebrx_version = "v0.18" \ No newline at end of file +openwebrx_version = "v0.18" diff --git a/owrx/websocket.py b/owrx/websocket.py index 360f28b..c773cf9 100644 --- a/owrx/websocket.py +++ b/owrx/websocket.py @@ -3,69 +3,76 @@ import hashlib import json import logging + logger = logging.getLogger(__name__) + class WebSocketConnection(object): def __init__(self, handler, messageHandler): self.handler = handler self.messageHandler = messageHandler my_headers = self.handler.headers.items() - my_header_keys = list(map(lambda x:x[0],my_headers)) - h_key_exists = lambda x:my_header_keys.count(x) - h_value = lambda x:my_headers[my_header_keys.index(x)][1] - if (not h_key_exists("Upgrade")) or not (h_value("Upgrade")=="websocket") or (not h_key_exists("Sec-WebSocket-Key")): + my_header_keys = list(map(lambda x: x[0], my_headers)) + h_key_exists = lambda x: my_header_keys.count(x) + h_value = lambda x: my_headers[my_header_keys.index(x)][1] + if ( + (not h_key_exists("Upgrade")) + or not (h_value("Upgrade") == "websocket") + or (not h_key_exists("Sec-WebSocket-Key")) + ): raise WebSocketException ws_key = h_value("Sec-WebSocket-Key") shakey = hashlib.sha1() - shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key = ws_key).encode()) + shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key=ws_key).encode()) ws_key_toreturn = base64.b64encode(shakey.digest()) - self.handler.wfile.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format(ws_key_toreturn.decode()).encode()) + self.handler.wfile.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format( + ws_key_toreturn.decode() + ).encode() + ) def get_header(self, size, opcode): ws_first_byte = 0b10000000 | (opcode & 0x0F) - if (size > 2**16 - 1): + if size > 2 ** 16 - 1: # frame size can be increased up to 2^64 by setting the size to 127 # anything beyond that would need to be segmented into frames. i don't really think we'll need more. - return bytes([ - ws_first_byte, - 127, - (size >> 56) & 0xff, - (size >> 48) & 0xff, - (size >> 40) & 0xff, - (size >> 32) & 0xff, - (size >> 24) & 0xff, - (size >> 16) & 0xff, - (size >> 8) & 0xff, - size & 0xff - ]) - elif (size > 125): + return bytes( + [ + ws_first_byte, + 127, + (size >> 56) & 0xFF, + (size >> 48) & 0xFF, + (size >> 40) & 0xFF, + (size >> 32) & 0xFF, + (size >> 24) & 0xFF, + (size >> 16) & 0xFF, + (size >> 8) & 0xFF, + size & 0xFF, + ] + ) + elif size > 125: # up to 2^16 can be sent using the extended payload size field by putting the size to 126 - return bytes([ - ws_first_byte, - 126, - (size >> 8) & 0xff, - size & 0xff - ]) + return bytes([ws_first_byte, 126, (size >> 8) & 0xFF, size & 0xFF]) else: # 125 bytes binary message in a single unmasked frame return bytes([ws_first_byte, size]) def send(self, data): # convenience - if (type(data) == dict): + if type(data) == dict: # allow_nan = False disallows NaN and Infinty to be encoded. Browser JSON will not parse them anyway. - data = json.dumps(data, allow_nan = False) + data = json.dumps(data, allow_nan=False) # string-type messages are sent as text frames - if (type(data) == str): + if type(data) == str: header = self.get_header(len(data), 1) - data_to_send = header + data.encode('utf-8') + data_to_send = header + data.encode("utf-8") # anything else as binary else: header = self.get_header(len(data), 2) data_to_send = header + data written = self.handler.wfile.write(data_to_send) - if (written != len(data_to_send)): + if written != len(data_to_send): logger.error("incomplete write! closing socket!") self.close() else: @@ -73,25 +80,25 @@ class WebSocketConnection(object): def read_loop(self): open = True - while (open): + while open: header = self.handler.rfile.read(2) opcode = header[0] & 0x0F length = header[1] & 0x7F mask = (header[1] & 0x80) >> 7 - if (length == 126): + if length == 126: header = self.handler.rfile.read(2) length = (header[0] << 8) + header[1] - if (mask): + if mask: masking_key = self.handler.rfile.read(4) data = self.handler.rfile.read(length) - if (mask): + if mask: data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)]) - if (opcode == 1): - message = data.decode('utf-8') + if opcode == 1: + message = data.decode("utf-8") self.messageHandler.handleTextMessage(self, message) - elif (opcode == 2): + elif opcode == 2: self.messageHandler.handleBinaryMessage(self, data) - elif (opcode == 8): + elif opcode == 8: open = False self.messageHandler.handleClose(self) else: diff --git a/owrx/wsjt.py b/owrx/wsjt.py index fb97ac8..0a401a4 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -12,6 +12,7 @@ from owrx.config import PropertyManager from owrx.bands import Bandplan import logging + logger = logging.getLogger(__name__) @@ -29,9 +30,7 @@ class WsjtChopper(threading.Thread): def getWaveFile(self): filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format( - tmp_dir = self.tmp_dir, - id = id(self), - timestamp = datetime.utcnow().strftime(self.fileTimestampFormat) + tmp_dir=self.tmp_dir, id=id(self), timestamp=datetime.utcnow().strftime(self.fileTimestampFormat) ) wavefile = wave.open(filename, "wb") wavefile.setnchannels(1) @@ -44,13 +43,13 @@ class WsjtChopper(threading.Thread): zeroed = t.replace(minute=0, second=0, microsecond=0) delta = t - zeroed seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval - t = zeroed + timedelta(seconds = seconds) + t = zeroed + timedelta(seconds=seconds) logger.debug("scheduling: {0}".format(t)) return t.timestamp() def startScheduler(self): self._scheduleNextSwitch() - threading.Thread(target = self.scheduler.run).start() + threading.Thread(target=self.scheduler.run).start() def emptyScheduler(self): for event in self.scheduler.queue: @@ -132,7 +131,7 @@ class Ft8Chopper(WsjtChopper): super().__init__(source) def decoder_commandline(self, file): - #TODO expose decoding quality parameters through config + # TODO expose decoding quality parameters through config return ["jt9", "--ft8", "-d", "3", file] @@ -143,7 +142,7 @@ class WsprChopper(WsjtChopper): super().__init__(source) def decoder_commandline(self, file): - #TODO expose decoding quality parameters through config + # TODO expose decoding quality parameters through config return ["wsprd", "-d", file] @@ -154,7 +153,7 @@ class Jt65Chopper(WsjtChopper): super().__init__(source) def decoder_commandline(self, file): - #TODO expose decoding quality parameters through config + # TODO expose decoding quality parameters through config return ["jt9", "--jt65", "-d", "3", file] @@ -165,7 +164,7 @@ class Jt9Chopper(WsjtChopper): super().__init__(source) def decoder_commandline(self, file): - #TODO expose decoding quality parameters through config + # TODO expose decoding quality parameters through config return ["jt9", "--jt9", "-d", "3", file] @@ -176,7 +175,7 @@ class Ft4Chopper(WsjtChopper): super().__init__(source) def decoder_commandline(self, file): - #TODO expose decoding quality parameters through config + # TODO expose decoding quality parameters through config return ["jt9", "--ft4", "-d", "3", file] @@ -189,12 +188,7 @@ class WsjtParser(object): self.dial_freq = None self.band = None - modes = { - "~": "FT8", - "#": "JT65", - "@": "JT9", - "+": "FT4" - } + modes = {"~": "FT8", "#": "JT65", "@": "JT9", "+": "FT4"} def parse(self, data): try: @@ -230,8 +224,8 @@ class WsjtParser(object): dateformat = "%H%M" else: dateformat = "%H%M%S" - timestamp = self.parse_timestamp(msg[0:len(dateformat)], dateformat) - msg = msg[len(dateformat) + 1:] + timestamp = self.parse_timestamp(msg[0 : len(dateformat)], dateformat) + msg = msg[len(dateformat) + 1 :] modeChar = msg[14:15] mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" wsjt_msg = msg[17:53].strip() @@ -242,7 +236,7 @@ class WsjtParser(object): "dt": float(msg[4:8]), "freq": int(msg[9:13]), "mode": mode, - "msg": wsjt_msg + "msg": wsjt_msg, } def parseLocator(self, msg, mode): @@ -268,7 +262,7 @@ class WsjtParser(object): "freq": float(msg[14:24]), "drift": int(msg[25:28]), "mode": "WSPR", - "msg": wsjt_msg + "msg": wsjt_msg, } def parseWsprMessage(self, msg): diff --git a/sdrhu.py b/sdrhu.py index 3060789..87459b3 100755 --- a/sdrhu.py +++ b/sdrhu.py @@ -23,10 +23,9 @@ from owrx.sdrhu import SdrHuUpdater from owrx.config import PropertyManager -if __name__=="__main__": +if __name__ == "__main__": pm = PropertyManager.getSharedInstance().loadConfig("config_webrx") if not "sdrhu_key" in pm: exit(1) SdrHuUpdater().update() - From 35f8daee292defebd1212992b41ca546a3091139 Mon Sep 17 00:00:00 2001 From: D0han Date: Sun, 21 Jul 2019 20:19:33 +0200 Subject: [PATCH 091/118] Allow openwebrx.py to be run as normal executable --- README.md | 6 +----- csdr.py | 0 openwebrx.py | 2 ++ 3 files changed, 3 insertions(+), 5 deletions(-) mode change 100755 => 100644 csdr.py mode change 100644 => 100755 openwebrx.py diff --git a/README.md b/README.md index 1df5b19..68178af 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,8 @@ Optional Dependency if you want to decode WSJT-X modes: After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server: - python openwebrx.py + ./openwebrx.py -You may need to specify the Python version explicitly if your distribution still defaults to Python 2: - - python3 openwebrx.py - You can now open the GUI at http://localhost:8073. Please note that the server is also listening on the following ports (on localhost only): diff --git a/csdr.py b/csdr.py old mode 100755 new mode 100644 diff --git a/openwebrx.py b/openwebrx.py old mode 100644 new mode 100755 index 99b1419..42ea3ba --- a/openwebrx.py +++ b/openwebrx.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + from http.server import HTTPServer from owrx.http import RequestHandler from owrx.config import PropertyManager From 6c2488f05225f1ab102c5aa44d1a6be50eb4fb0c Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 22:12:41 +0200 Subject: [PATCH 092/118] fix shadowing warning --- owrx/source.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/owrx/source.py b/owrx/source.py index da2e928..9bf5647 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -121,12 +121,12 @@ class SdrSource(object): def getFormatConversion(self): return None - def activateProfile(self, id=None): + def activateProfile(self, profile_id=None): profiles = self.props["profiles"] - if id is None: - id = list(profiles.keys())[0] - logger.debug("activating profile {0}".format(id)) - profile = profiles[id] + if profile_id is None: + profile_id = list(profiles.keys())[0] + logger.debug("activating profile {0}".format(profile_id)) + profile = profiles[profile_id] for (key, value) in profile.items(): # skip the name, that would overwrite the source name. if key == "name": From 2d6b0f187764555213e89e4bbd59b4d4d89aff06 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 22:13:20 +0200 Subject: [PATCH 093/118] try to catch a failing sdr device --- owrx/source.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/owrx/source.py b/owrx/source.py index 9bf5647..86348ea 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -91,6 +91,10 @@ class SdrService(object): return SdrService.sources +class SdrSourceException(Exception): + pass + + class SdrSource(object): def __init__(self, props, port): self.props = props @@ -183,6 +187,8 @@ class SdrSource(object): self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp) logger.info("Started rtl source: " + cmd) + available = False + def wait_for_process_to_end(): rc = self.process.wait() logger.debug("shut down with RC={0}".format(rc)) @@ -191,17 +197,25 @@ class SdrSource(object): self.monitor = threading.Thread(target=wait_for_process_to_end) self.monitor.start() - while True: + retries = 100 + while retries > 0: + retries -= 1 + if self.monitor is None: + break testsock = socket.socket() try: testsock.connect(("127.0.0.1", self.getPort())) testsock.close() + available = True break except: time.sleep(0.1) self.modificationLock.release() + if not available: + raise SdrSourceException("rtl source failed to start up") + for c in self.clients: c.onSdrAvailable() From 9c927d90018ec45f6906fe3feda9fe8c45b15b0e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 21 Jul 2019 23:39:11 +0200 Subject: [PATCH 094/118] first iteration of background services --- openwebrx.py | 3 ++ owrx/bands.py | 2 +- owrx/connection.py | 2 +- owrx/service.py | 117 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 owrx/service.py diff --git a/openwebrx.py b/openwebrx.py index de51f66..9fa685b 100755 --- a/openwebrx.py +++ b/openwebrx.py @@ -7,6 +7,7 @@ from owrx.feature import FeatureDetector from owrx.source import SdrService, ClientRegistry from socketserver import ThreadingMixIn from owrx.sdrhu import SdrHuUpdater +from owrx.service import ServiceManager import logging @@ -47,6 +48,8 @@ Author contact info: Andras Retzler, HA7ILM updater = SdrHuUpdater() updater.start() + ServiceManager.getSharedInstance().start() + server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler) server.serve_forever() diff --git a/owrx/bands.py b/owrx/bands.py index bc76b2a..c32caec 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -54,5 +54,5 @@ class Bandplan(object): def findBand(self, freq): return next(band for band in self.bands if band.inBand(freq)) - def collectDialFrequencis(self, range): + def collectDialFrequencies(self, range): return [e for b in self.bands for e in b.getDialFrequencies(range)] diff --git a/owrx/connection.py b/owrx/connection.py index b68cbaf..5008e36 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -118,7 +118,7 @@ class OpenWebRxReceiverClient(Client): cf = configProps["center_freq"] srh = configProps["samp_rate"] / 2 frequencyRange = (cf - srh, cf + srh) - self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencis(frequencyRange)) + self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange)) self.configSub = configProps.wire(sendConfig) sendConfig(None, None) diff --git a/owrx/service.py b/owrx/service.py new file mode 100644 index 0000000..84a436e --- /dev/null +++ b/owrx/service.py @@ -0,0 +1,117 @@ +import threading +from owrx.source import SdrService +from owrx.bands import Bandplan +from csdr import dsp, output +from owrx.wsjt import WsjtParser + +import logging + +logger = logging.getLogger(__name__) + + +class ServiceOutput(output): + def __init__(self, frequency): + self.frequency = frequency + + def add_output(self, t, read_fn): + logger.debug("got output of type {0}".format(t)) + + def pump(read, write): + def copy(): + run = True + while run: + data = read() + if data is None or (isinstance(data, bytes) and len(data) == 0): + logger.warning("zero read on {0}".format(t)) + run = False + else: + write(data) + + return copy + + if t == "wsjt_demod": + parser = WsjtParser(WsjtHandler()) + parser.setDialFrequency(self.frequency) + target = pump(read_fn, parser.parse) + else: + # dump everything else + # TODO rewrite the output mechanism in a way that avoids producing unnecessary data + target = pump(read_fn, lambda x: None) + threading.Thread(target=target).start() + + +class ServiceHandler(object): + def __init__(self, source): + self.services = [] + self.source = source + self.source.addClient(self) + self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange) + self.onFrequencyChange("", "") + + def onSdrAvailable(self): + logger.debug("sdr {0} is available".format(self.source.getName())) + self.onFrequencyChange("", "") + + def onSdrUnavailable(self): + logger.debug("sdr {0} is unavailable".format(self.source.getName())) + self.stopServices() + + def isSupported(self, mode): + return mode in ["ft8", "ft4", "wspr"] + + def stopServices(self): + for service in self.services: + service.stop() + self.services = [] + + def startServices(self): + for service in self.services: + service.start() + + def onFrequencyChange(self, key, value): + if not self.source.isAvailable(): + return + logger.debug("sdr {0} is changing frequency".format(self.source.getName())) + self.stopServices() + cf = self.source.getProps()["center_freq"] + srh = self.source.getProps()["samp_rate"] / 2 + frequency_range = (cf - srh, cf + srh) + self.services = [self.setupService(dial["mode"], dial["frequency"]) for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range) if self.isSupported(dial["mode"])] + + def setupService(self, mode, frequency): + logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) + d = dsp(ServiceOutput(frequency)) + d.set_offset_freq(frequency - self.source.getProps()["center_freq"]) + d.set_demodulator("usb") + d.set_bpf(0, 3000) + d.set_secondary_demodulator(mode) + d.set_audio_compression("none") + d.set_samp_rate(self.source.getProps()["samp_rate"]) + d.start() + return d + + +class WsjtHandler(object): + def write_wsjt_message(self, msg): + pass + + +class ServiceManager(object): + sharedInstance = None + @staticmethod + def getSharedInstance(): + if ServiceManager.sharedInstance is None: + ServiceManager.sharedInstance = ServiceManager() + return ServiceManager.sharedInstance + + def start(self): + for source in SdrService.getSources().values(): + ServiceHandler(source) + + +class Service(object): + pass + + +class WsjtService(Service): + pass From eb9bc5f8dcd9a96108ad76c2dc3dc4e27ff943cc Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Mon, 22 Jul 2019 23:24:46 +0200 Subject: [PATCH 095/118] add ft4 frequencies, if available --- bands.json | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/bands.json b/bands.json index 487ef29..df7bce7 100644 --- a/bands.json +++ b/bands.json @@ -20,7 +20,8 @@ "ft8": 3573000, "wspr": 3592600, "jt65": 3570000, - "jt9": 3572000 + "jt9": 3572000, + "ft4": 3568000 } }, { @@ -41,7 +42,8 @@ "ft8": 7074000, "wspr": 7038600, "jt65": 7076000, - "jt9": 7078000 + "jt9": 7078000, + "ft4": 7047500 } }, { @@ -53,7 +55,8 @@ "ft8": 10136000, "wspr": 10138700, "jt65": 10138000, - "jt9": 10140000 + "jt9": 10140000, + "ft4": 10140000 } }, { @@ -78,7 +81,8 @@ "ft8": 18100000, "wspr": 18104600, "jt65": 18102000, - "jt9": 18104000 + "jt9": 18104000, + "ft4": 18104000 } }, { @@ -90,7 +94,8 @@ "ft8": 21074000, "wspr": 21094600, "jt65": 21076000, - "jt9": 21078000 + "jt9": 21078000, + "ft4": 21140000 } }, { @@ -102,7 +107,8 @@ "ft8": 24915000, "wspr": 24924600, "jt65": 24917000, - "jt9": 24919000 + "jt9": 24919000, + "ft4": 24919000 } }, { @@ -114,7 +120,8 @@ "ft8": 28074000, "wspr": 28124600, "jt65": 28076000, - "jt9": 28078000 + "jt9": 28078000, + "ft4": 28180000 } }, { @@ -126,7 +133,8 @@ "ft8": 50313000, "wspr": 50293000, "jt65": 50310000, - "jt9": 50312000 + "jt9": 50312000, + "ft4": 50318000 } }, { From 8c2cefe304889ed0f5f48f133838f7686df14391 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 23 Jul 2019 16:43:46 +0100 Subject: [PATCH 096/118] pass the nmux port on (defaults are bad...) --- owrx/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/owrx/service.py b/owrx/service.py index 84a436e..641ddb4 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -81,6 +81,7 @@ class ServiceHandler(object): def setupService(self, mode, frequency): logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) d = dsp(ServiceOutput(frequency)) + d.nc_port = self.source.getPort() d.set_offset_freq(frequency - self.source.getProps()["center_freq"]) d.set_demodulator("usb") d.set_bpf(0, 3000) From 7689e31640b8fcc21f461086b65be7fd443d8923 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Tue, 23 Jul 2019 20:28:51 +0100 Subject: [PATCH 097/118] increase timeout --- owrx/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/owrx/source.py b/owrx/source.py index 86348ea..99f29ec 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -197,7 +197,7 @@ class SdrSource(object): self.monitor = threading.Thread(target=wait_for_process_to_end) self.monitor.start() - retries = 100 + retries = 1000 while retries > 0: retries -= 1 if self.monitor is None: From a15e6256924a2695c12944223d52cdf0a5df4786 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 11:40:58 +0200 Subject: [PATCH 098/118] de-duplicate; better logging --- csdr.py | 25 +++++++++++++++++-------- owrx/service.py | 17 ++--------------- owrx/source.py | 15 +-------------- 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/csdr.py b/csdr.py index 54fd13c..197f62c 100644 --- a/csdr.py +++ b/csdr.py @@ -39,6 +39,18 @@ class output(object): def reset(self): pass + def pump(self, read, write): + def copy(): + run = True + while run: + data = read() + if data is None or (isinstance(data, bytes) and len(data) == 0): + run = False + else: + write(data) + + return copy + class dsp(object): def __init__(self, output): @@ -233,7 +245,7 @@ class dsp(object): def start_secondary_demodulator(self): if not self.secondary_demodulator: return - logger.debug("[openwebrx] starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) + logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) secondary_command_fft = self.secondary_chain("fft") secondary_command_demod = self.secondary_chain(self.secondary_demodulator) self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft) @@ -255,8 +267,8 @@ class dsp(object): last_decimation=self.last_decimation, ) - logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (fft) = %s", secondary_command_fft) - logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (demod) = %s", secondary_command_demod) + logger.debug("secondary command (fft) = %s", secondary_command_fft) + logger.debug("secondary command (demod) = %s", secondary_command_demod) my_env = os.environ.copy() # if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; if self.csdr_print_bufsizes: @@ -264,11 +276,9 @@ class dsp(object): self.secondary_process_fft = subprocess.Popen( secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env ) - logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (fft)") self.secondary_process_demod = subprocess.Popen( secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env - ) # TODO digimodes - logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)") # TODO digimodes + ) self.secondary_processes_running = True self.output.add_output( @@ -500,7 +510,6 @@ class dsp(object): self.running = True command_base = " | ".join(self.chain(self.demodulator)) - logger.debug(command_base) # create control pipes for csdr self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self)) @@ -533,7 +542,7 @@ class dsp(object): audio_rate=self.get_audio_rate(), ) - logger.debug("[openwebrx-dsp-plugin:csdr] Command = %s", command) + logger.debug("Command = %s", command) my_env = os.environ.copy() if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" diff --git a/owrx/service.py b/owrx/service.py index 641ddb4..8c997e3 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -16,27 +16,14 @@ class ServiceOutput(output): def add_output(self, t, read_fn): logger.debug("got output of type {0}".format(t)) - def pump(read, write): - def copy(): - run = True - while run: - data = read() - if data is None or (isinstance(data, bytes) and len(data) == 0): - logger.warning("zero read on {0}".format(t)) - run = False - else: - write(data) - - return copy - if t == "wsjt_demod": parser = WsjtParser(WsjtHandler()) parser.setDialFrequency(self.frequency) - target = pump(read_fn, parser.parse) + target = self.pump(read_fn, parser.parse) else: # dump everything else # TODO rewrite the output mechanism in a way that avoids producing unnecessary data - target = pump(read_fn, lambda x: None) + target = self.pump(read_fn, lambda x: None) threading.Thread(target=target).start() diff --git a/owrx/source.py b/owrx/source.py index 99f29ec..3191cc2 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -515,20 +515,7 @@ class DspManager(csdr.output): } write = writers[t] - def pump(read, write): - def copy(): - run = True - while run: - data = read() - if data is None or (isinstance(data, bytes) and len(data) == 0): - logger.warning("zero read on {0}".format(t)) - run = False - else: - write(data) - - return copy - - threading.Thread(target=pump(read_fn, write)).start() + threading.Thread(target=self.pump(read_fn, write)).start() def stop(self): self.dsp.stop() From accf2a34ff16e27d8dc409e46b4606def335614b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 11:45:28 +0200 Subject: [PATCH 099/118] fix exception when outside of band --- owrx/bands.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/owrx/bands.py b/owrx/bands.py index bc76b2a..e4cb5ef 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -51,8 +51,15 @@ class Bandplan(object): f.close() self.bands = [Band(d) for d in bands_json] + def findBands(self, freq): + return [band for band in self.bands if band.inBand(freq)] + def findBand(self, freq): - return next(band for band in self.bands if band.inBand(freq)) + bands = self.findBands(freq) + if bands: + return bands[0] + else: + return None def collectDialFrequencis(self, range): return [e for b in self.bands for e in b.getDialFrequencies(range)] From fa08009c501a5a2b6b1af5480a3f0bf6f01dfb19 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 12:11:22 +0200 Subject: [PATCH 100/118] more logging improvements --- csdr.py | 3 +++ owrx/service.py | 14 +++++++------- owrx/wsjt.py | 6 ++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/csdr.py b/csdr.py index 197f62c..a67631d 100644 --- a/csdr.py +++ b/csdr.py @@ -499,6 +499,9 @@ class dsp(object): if pipe_path: try: os.unlink(pipe_path) + except FileNotFoundError: + # it seems like we keep calling this twice. no idea why, but we don't need the resulting error. + pass except Exception: logger.exception("try_delete_pipes()") diff --git a/owrx/service.py b/owrx/service.py index 8c997e3..577d29a 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -14,8 +14,6 @@ class ServiceOutput(output): self.frequency = frequency def add_output(self, t, read_fn): - logger.debug("got output of type {0}".format(t)) - if t == "wsjt_demod": parser = WsjtParser(WsjtHandler()) parser.setDialFrequency(self.frequency) @@ -33,17 +31,16 @@ class ServiceHandler(object): self.source = source self.source.addClient(self) self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange) - self.onFrequencyChange("", "") + self.updateServices() def onSdrAvailable(self): - logger.debug("sdr {0} is available".format(self.source.getName())) - self.onFrequencyChange("", "") + self.updateServices() def onSdrUnavailable(self): - logger.debug("sdr {0} is unavailable".format(self.source.getName())) self.stopServices() def isSupported(self, mode): + # TODO make configurable return mode in ["ft8", "ft4", "wspr"] def stopServices(self): @@ -58,7 +55,10 @@ class ServiceHandler(object): def onFrequencyChange(self, key, value): if not self.source.isAvailable(): return - logger.debug("sdr {0} is changing frequency".format(self.source.getName())) + self.updateServices() + + def updateServices(self): + logger.debug("re-scheduling services due to sdr changes") self.stopServices() cf = self.source.getProps()["center_freq"] srh = self.source.getProps()["samp_rate"] / 2 diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 0a401a4..2c26b2f 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -84,11 +84,10 @@ class WsjtChopper(threading.Thread): break self.outputWriter.send(line) rc = decoder.wait() - logger.debug("decoder return code: %i", rc) + if rc != 0: + logger.warning("decoder return code: %i", rc) os.unlink(file) - self.decoder = decoder - if self.fileQueue: file = self.fileQueue.pop() logger.debug("processing file {0}".format(file)) @@ -100,7 +99,6 @@ class WsjtChopper(threading.Thread): while self.doRun: data = self.source.read(256) if data is None or (isinstance(data, bytes) and len(data) == 0): - logger.warning("zero read on WSJT chopper") self.doRun = False else: self.switchingLock.acquire() From 98c5e9e15baaa7e5fbb93ebb377cafc2a5ebeed2 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 13:29:45 +0200 Subject: [PATCH 101/118] allow service configuration --- config_webrx.py | 3 +++ owrx/service.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/config_webrx.py b/config_webrx.py index 15569de..044dc85 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -251,3 +251,6 @@ google_maps_api_key = "" map_position_retention_time = 2 * 60 * 60 temporary_directory = "/tmp" + +services_enabled = True +services_decoders = ["ft8", "ft4", "wspr"] diff --git a/owrx/service.py b/owrx/service.py index 577d29a..b6c2731 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -3,6 +3,7 @@ from owrx.source import SdrService from owrx.bands import Bandplan from csdr import dsp, output from owrx.wsjt import WsjtParser +from owrx.config import PropertyManager import logging @@ -41,7 +42,7 @@ class ServiceHandler(object): def isSupported(self, mode): # TODO make configurable - return mode in ["ft8", "ft4", "wspr"] + return mode in PropertyManager.getSharedInstance()["services_decoders"] def stopServices(self): for service in self.services: @@ -93,6 +94,8 @@ class ServiceManager(object): return ServiceManager.sharedInstance def start(self): + if not PropertyManager.getSharedInstance()["services_enabled"]: + return for source in SdrService.getSources().values(): ServiceHandler(source) From 6e7d99376d139f61c9e8fcef404bdb90c67f3c6e Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 15:28:39 +0200 Subject: [PATCH 102/118] color by band --- htdocs/lib/chroma.min.js | 58 ++++++++++++++++++++++++++++++++++++++++ htdocs/map.html | 1 + htdocs/map.js | 33 +++++++++++++++++++++-- 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 htdocs/lib/chroma.min.js diff --git a/htdocs/lib/chroma.min.js b/htdocs/lib/chroma.min.js new file mode 100644 index 0000000..76dd1f8 --- /dev/null +++ b/htdocs/lib/chroma.min.js @@ -0,0 +1,58 @@ +/** + * chroma.js - JavaScript library for color conversions + * + * Copyright (c) 2011-2019, Gregor Aisch + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. The name Gregor Aisch may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL GREGOR AISCH OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * ------------------------------------------------------- + * + * chroma.js includes colors from colorbrewer2.org, which are released under + * the following license: + * + * Copyright (c) 2002 Cynthia Brewer, Mark Harrower, + * and The Pennsylvania State University. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + * + * ------------------------------------------------------ + * + * Named colors are taken from X11 Color Names. + * http://www.w3.org/TR/css3-color/#svg-color + * + * @preserve + */ + +!function(r,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):r.chroma=e()}(this,function(){"use strict";for(var t=function(r,e,t){return void 0===e&&(e=0),void 0===t&&(t=1),r>16,e>>8&255,255&e,1]}if(r.match(fr)){9===r.length&&(r=r.substr(1));var t=parseInt(r,16);return[t>>24&255,t>>16&255,t>>8&255,Math.round((255&t)/255*100)/100]}throw new Error("unknown hex color: "+r)},ur=o.type;A.prototype.hex=function(r){return nr(this._rgb,r)},N.hex=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];return new(Function.prototype.bind.apply(A,[null].concat(r,["hex"])))},b.format.hex=or,b.autodetect.push({p:4,test:function(r){for(var e=[],t=arguments.length-1;0>16,r>>8&255,255&r,1];throw new Error("unknown num color: "+r)},xe=o.type;A.prototype.num=function(){return Me(this._rgb)},N.num=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];return new(Function.prototype.bind.apply(A,[null].concat(r,["num"])))},b.format.num=_e,b.autodetect.push({p:5,test:function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];if(1===r.length&&"number"===xe(r[0])&&0<=r[0]&&r[0]<=16777215)return"num"}});var Ae=o.unpack,Ee=o.type,Pe=Math.round;A.prototype.rgb=function(r){return void 0===r&&(r=!0),!1===r?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Pe)},A.prototype.rgba=function(t){return void 0===t&&(t=!0),this._rgb.slice(0,4).map(function(r,e){return e<3?!1===t?r:Pe(r):r})},N.rgb=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];return new(Function.prototype.bind.apply(A,[null].concat(r,["rgb"])))},b.format.rgb=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];var t=Ae(r,"rgba");return void 0===t[3]&&(t[3]=1),t},b.autodetect.push({p:3,test:function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];if(r=Ae(r,"rgba"),"array"===Ee(r)&&(3===r.length||4===r.length&&"number"==Ee(r[3])&&0<=r[3]&&r[3]<=1))return"rgb"}});var Fe=Math.log,Oe=function(r){var e,t,n,a=r/100;return n=a<66?(e=255,t=-155.25485562709179-.44596950469579133*(t=a-2)+104.49216199393888*Fe(t),a<20?0:.8274096064007395*(n=a-10)-254.76935184120902+115.67994401066147*Fe(n)):(e=351.97690566805693+.114206453784165*(e=a-55)-40.25366309332127*Fe(e),t=325.4494125711974+.07943456536662342*(t=a-50)-28.0852963507957*Fe(t),255),[e,t,n,1]},je=o.unpack,Ge=Math.round,qe=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];for(var t,n=je(r,"rgb"),a=n[0],f=n[2],o=1e3,u=4e4;.4=f/a?u=t:o=t}return Ge(t)};A.prototype.temp=A.prototype.kelvin=A.prototype.temperature=function(){return qe(this._rgb)},N.temp=N.kelvin=N.temperature=function(){for(var r=[],e=arguments.length;e--;)r[e]=arguments[e];return new(Function.prototype.bind.apply(A,[null].concat(r,["temp"])))},b.format.temp=b.format.kelvin=b.format.temperature=Oe;var Le=o.type;A.prototype.alpha=function(r,e){return void 0===e&&(e=!1),void 0!==r&&"number"===Le(r)?e?(this._rgb[3]=r,this):new A([this._rgb[0],this._rgb[1],this._rgb[2],r],"rgb"):this._rgb[3]},A.prototype.clipped=function(){return this._rgb._clipped||!1},A.prototype.darken=function(r){void 0===r&&(r=1);var e=this.lab();return e[0]-=qr*r,new A(e,"lab").alpha(this.alpha(),!0)},A.prototype.brighten=function(r){return void 0===r&&(r=1),this.darken(-r)},A.prototype.darker=A.prototype.darken,A.prototype.brighter=A.prototype.brighten,A.prototype.get=function(r){var e=r.split("."),t=e[0],n=e[1],a=this[t]();if(n){var f=t.indexOf(n);if(-1=s[t];)t++;return t-1}(r)/(s.length-2):g!==p?(r-p)/(g-p):1;e||(n=w(n)),1!==y&&(n=tt(n,y)),n=d[0]+n*(1-d[0]-d[1]),n=Math.min(1,Math.max(0,n));var a=Math.floor(1e4*n);if(m&&v[a])t=v[a];else{if("array"===et(b))for(var f=0;ft.max&&(t.max=r),t.count+=1)}),t.domain=[t.min,t.max],t.limits=function(r,e){return Mt(t,r,e)},t},Mt=function(r,e,t){void 0===e&&(e="equal"),void 0===t&&(t=7),"array"==Y(r)&&(r=kt(r));var n=r.min,a=r.max,f=r.values.sort(function(r,e){return r-e});if(1===t)return[n,a];var o=[];if("c"===e.substr(0,1)&&(o.push(n),o.push(a)),"e"===e.substr(0,1)){o.push(n);for(var u=1;u 0");var c=Math.LOG10E*vt(n),i=Math.LOG10E*vt(a);o.push(n);for(var l=1;l OpenWebRX Map + diff --git a/htdocs/map.js b/htdocs/map.js index 6af68ca..ba7e151 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -31,6 +31,34 @@ var strokeOpacity = 0.8; var fillOpacity = 0.35; + var colorKeys = {}; + var getColor = function(id){ + if (!id) return "#000000"; + if (!colorKeys[id]) { + var keys = Object.keys(colorKeys); + keys.push(id); + keys.sort(); + var colors = chroma.scale(['#FF0000', '#0000FF']).colors(keys.length); + colorKeys = {}; + keys.forEach(function(key, index) { + colorKeys[key] = colors[index]; + }); + reColor(); + } + return colorKeys[id]; + } + + // when the color palette changes, update all grid squares with new color + var reColor = function() { + $.each(rectangles, function(_, r) { + var color = getColor(r.band); + r.setOptions({ + strokeColor: color, + fillColor: color + }); + }); + } + var processUpdates = function(updates) { if (!map) { updateQueue = updateQueue.concat(updates); @@ -73,6 +101,7 @@ var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2; var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); var rectangle; + var color = getColor(update.band); if (rectangles[update.callsign]) { rectangle = rectangles[update.callsign]; } else { @@ -83,9 +112,9 @@ rectangles[update.callsign] = rectangle; } rectangle.setOptions($.extend({ - strokeColor: '#FF0000', + strokeColor: color, strokeWeight: 2, - fillColor: '#FF0000', + fillColor: color, map: map, bounds:{ north: lat, From 74dddcb8ad66bee5260bfba5014d7bd53ffe1276 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 15:57:33 +0200 Subject: [PATCH 103/118] add simple legend with colors --- htdocs/css/map.css | 19 ++++++++++++++++++- htdocs/map.html | 1 + htdocs/map.js | 9 +++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 4b27afe..6170218 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -28,4 +28,21 @@ ul { margin-block-start: 5px; margin-block-end: 5px; padding-inline-start: 25px; -} \ No newline at end of file +} + +.openwebrx-map-legend { + background-color: #fff; + padding: 10px; +} + +.openwebrx-map-legend ul { + list-style-type: none; + padding: 0; +} + +.openwebrx-map-legend li.square .illustration { + display: inline-block; + width: 30px; + height: 20px; + margin-right: 10px; +} diff --git a/htdocs/map.html b/htdocs/map.html index bd8b60a..9e03c40 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -12,5 +12,6 @@ ${header}
    +

    Colors

    diff --git a/htdocs/map.js b/htdocs/map.js index ba7e151..c958f94 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -44,6 +44,7 @@ colorKeys[key] = colors[index]; }); reColor(); + updateLegend(); } return colorKeys[id]; } @@ -59,6 +60,13 @@ }); } + var updateLegend = function() { + var lis = $.map(colorKeys, function(value, key) { + return '
  • ' + key + '
  • '; + }); + $(".openwebrx-map-legend .content").html('
      ' + lis.join('') + '
    '); + } + var processUpdates = function(updates) { if (!map) { updateQueue = updateQueue.concat(updates); @@ -183,6 +191,7 @@ nite.init(map); setInterval(function() { nite.refresh() }, 10000); // every 10s }); + map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push($(".openwebrx-map-legend")[0]); }); retention_time = config.map_position_retention_time * 1000; break; From 30d8b1327b0ded8067a2fa772eb12d6abb3f0cbf Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 15:59:54 +0200 Subject: [PATCH 104/118] give it some space --- htdocs/css/map.css | 1 + 1 file changed, 1 insertion(+) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 6170218..73d556d 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -33,6 +33,7 @@ ul { .openwebrx-map-legend { background-color: #fff; padding: 10px; + margin: 10px; } .openwebrx-map-legend ul { From ff98b172c43f9d3ebda970cd41523f9c4f6decf0 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 16:17:23 +0200 Subject: [PATCH 105/118] add option to select coloring by mode, too --- htdocs/css/map.css | 6 ++++++ htdocs/map.html | 9 ++++++++- htdocs/map.js | 24 ++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 73d556d..6d09b70 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -47,3 +47,9 @@ ul { height: 20px; margin-right: 10px; } + +.openwebrx-map-legend select { + background-color: #FFF; + border-color: #DDD; + padding: 5px; +} diff --git a/htdocs/map.html b/htdocs/map.html index 9e03c40..2aed010 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -12,6 +12,13 @@ ${header}
    -

    Colors

    +
    +

    Colors

    + +
    +
    diff --git a/htdocs/map.js b/htdocs/map.js index c958f94..4d068f5 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -52,7 +52,7 @@ // when the color palette changes, update all grid squares with new color var reColor = function() { $.each(rectangles, function(_, r) { - var color = getColor(r.band); + var color = getColor(colorAccessor(r)); r.setOptions({ strokeColor: color, fillColor: color @@ -60,6 +60,25 @@ }); } + var colorMode = 'byband'; + var colorAccessor = function(r) { + switch (colorMode) { + case 'byband': + return r.band; + case 'bymode': + return r.mode; + } + }; + + $(function(){ + $('#openwebrx-map-colormode').on('change', function(){ + colorMode = $(this).val(); + colorKeys = {}; + reColor(); + updateLegend(); + }); + }); + var updateLegend = function() { var lis = $.map(colorKeys, function(value, key) { return '
  • ' + key + '
  • '; @@ -109,7 +128,8 @@ var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2; var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); var rectangle; - var color = getColor(update.band); + // the accessor is designed to work on the rectangle... but it should work on the update object, too + var color = getColor(colorAccessor(update)); if (rectangles[update.callsign]) { rectangle = rectangles[update.callsign]; } else { From 785d43960596b5214b116da566aa54320edfe166 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 16:26:03 +0200 Subject: [PATCH 106/118] play with the colors --- htdocs/map.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/map.js b/htdocs/map.js index 4d068f5..01672ef 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -32,13 +32,14 @@ var fillOpacity = 0.35; var colorKeys = {}; + var colorScale = chroma.bezier(['red', 'blue', 'green']).scale(); var getColor = function(id){ if (!id) return "#000000"; if (!colorKeys[id]) { var keys = Object.keys(colorKeys); keys.push(id); keys.sort(); - var colors = chroma.scale(['#FF0000', '#0000FF']).colors(keys.length); + var colors = colorScale.colors(keys.length); colorKeys = {}; keys.forEach(function(key, index) { colorKeys[key] = colors[index]; From 3b5883dd55eb9caf089606436aa01eb0a0f9d97b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 16:33:19 +0200 Subject: [PATCH 107/118] improved legend with opacity --- htdocs/css/map.css | 2 ++ htdocs/map.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 6d09b70..5d478cd 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -46,6 +46,8 @@ ul { width: 30px; height: 20px; margin-right: 10px; + border-width: 2px; + border-style: solid; } .openwebrx-map-legend select { diff --git a/htdocs/map.js b/htdocs/map.js index 01672ef..83801f0 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -82,7 +82,7 @@ var updateLegend = function() { var lis = $.map(colorKeys, function(value, key) { - return '
  • ' + key + '
  • '; + return '
  • ' + key + '
  • '; }); $(".openwebrx-map-legend .content").html('
      ' + lis.join('') + '
    '); } From e40b400f6f2e70febe44fd322083b44a980d63ac Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 16:36:12 +0200 Subject: [PATCH 108/118] try to improve "moving" callsigns --- htdocs/map.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/map.js b/htdocs/map.js index 83801f0..098b909 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -136,7 +136,7 @@ } else { rectangle = new google.maps.Rectangle(); rectangle.addListener('click', function(){ - showLocatorInfoWindow(update.location.locator, center); + showLocatorInfoWindow(this.locator, this.center); }); rectangles[update.callsign] = rectangle; } @@ -156,6 +156,7 @@ rectangle.locator = update.location.locator; rectangle.mode = update.mode; rectangle.band = update.band; + rectangle.center = center; if (expectedLocator && expectedLocator == update.location.locator) { map.panTo(center); From 8f7f34c190853e9479efa7cd7655c85daab1163f Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 28 Jul 2019 22:13:55 +0200 Subject: [PATCH 109/118] better colors (?) --- htdocs/map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/htdocs/map.js b/htdocs/map.js index 098b909..7d98f49 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -32,7 +32,7 @@ var fillOpacity = 0.35; var colorKeys = {}; - var colorScale = chroma.bezier(['red', 'blue', 'green']).scale(); + var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl'); var getColor = function(id){ if (!id) return "#000000"; if (!colorKeys[id]) { From d1eaab771110ae2ce2ebe9cf1689e6c6d06ea1e3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 3 Aug 2019 23:44:56 +0200 Subject: [PATCH 110/118] delay startup of background services to increase user interface response --- owrx/service.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/owrx/service.py b/owrx/service.py index b6c2731..d04ee0b 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -30,12 +30,13 @@ class ServiceHandler(object): def __init__(self, source): self.services = [] self.source = source + self.startupTimer = None self.source.addClient(self) self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange) - self.updateServices() + self.scheduleServiceStartup() def onSdrAvailable(self): - self.updateServices() + self.scheduleServiceStartup() def onSdrUnavailable(self): self.stopServices() @@ -54,9 +55,16 @@ class ServiceHandler(object): service.start() def onFrequencyChange(self, key, value): + self.stopServices() if not self.source.isAvailable(): return - self.updateServices() + self.scheduleServiceStartup() + + def scheduleServiceStartup(self): + if self.startupTimer: + self.startupTimer.cancel() + self.startupTimer = threading.Timer(10, self.updateServices) + self.startupTimer.start() def updateServices(self): logger.debug("re-scheduling services due to sdr changes") From 5337ddba8d52dba77e4ed696beec9bc16d06c206 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sat, 3 Aug 2019 23:58:08 +0200 Subject: [PATCH 111/118] add 2m frequencies from wsjt-x --- bands.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bands.json b/bands.json index df7bce7..a122916 100644 --- a/bands.json +++ b/bands.json @@ -150,7 +150,10 @@ "lower_bound": 144000000, "upper_bound": 146000000, "frequencies": { - "wspr": 144489000 + "wspr": 144489000, + "ft8": 144174000, + "ft4": 144170000, + "jt65": 144120000 } }, { From 441738e56948e81b34b63cca9d2fb00f79557570 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 4 Aug 2019 00:21:53 +0200 Subject: [PATCH 112/118] additional ft4 frequency on 80m --- bands.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bands.json b/bands.json index a122916..30a26bb 100644 --- a/bands.json +++ b/bands.json @@ -21,7 +21,7 @@ "wspr": 3592600, "jt65": 3570000, "jt9": 3572000, - "ft4": 3568000 + "ft4": [3568000, 3568000] } }, { From 42aae4c03a042b37d6a61d172ac03fac32c004ee Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 4 Aug 2019 14:55:56 +0200 Subject: [PATCH 113/118] save some cpu cycles by only running necessary stuff for services --- csdr.py | 83 +++++++++++++++++++++++++++++++------------------ owrx/service.py | 16 +++++----- owrx/source.py | 4 +-- 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/csdr.py b/csdr.py index a67631d..08a0c45 100644 --- a/csdr.py +++ b/csdr.py @@ -33,10 +33,15 @@ logger = logging.getLogger(__name__) class output(object): - def add_output(self, type, read_fn): - pass + def send_output(self, t, read_fn): + if not self.supports_type(t): + # TODO rewrite the output mechanism in a way that avoids producing unnecessary data + logger.warning('dumping output of type %s since it is not supported.', t) + threading.Thread(target=self.pump(read_fn, lambda x: None)).start() + return + self.receive_output(t, read_fn) - def reset(self): + def receive_output(self, t, read_fn): pass def pump(self, read, write): @@ -51,6 +56,9 @@ class output(object): return copy + def supports_type(self, t): + return True + class dsp(object): def __init__(self, output): @@ -123,10 +131,19 @@ class dsp(object): "csdr shift_addition_cc --fifo {shift_pipe}", "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING", "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING", - "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}", + ] + if self.output.supports_type('smeter'): + chain += [ + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}", + ] if self.secondary_demodulator: - chain += ["csdr tee {iqtee_pipe}", "csdr tee {iqtee2_pipe}"] + if self.output.supports_type('secondary_fft'): + chain += ["csdr tee {iqtee_pipe}"] + chain += ["csdr tee {iqtee2_pipe}"] + # early exit if we don't want audio + if not self.output.supports_type('audio'): + return chain # safe some cpu cycles... no need to decimate if decimation factor is 1 last_decimation_block = ( ["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else [] @@ -246,16 +263,9 @@ class dsp(object): if not self.secondary_demodulator: return logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) - secondary_command_fft = self.secondary_chain("fft") secondary_command_demod = self.secondary_chain(self.secondary_demodulator) - self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft) + self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod) - secondary_command_fft = secondary_command_fft.format( - input_pipe=self.iqtee_pipe, - secondary_fft_input_size=self.secondary_fft_size, - secondary_fft_size=self.secondary_fft_size, - secondary_fft_block_size=self.secondary_fft_block_size(), - ) secondary_command_demod = secondary_command_demod.format( input_pipe=self.iqtee2_pipe, secondary_shift_pipe=self.secondary_shift_pipe, @@ -267,24 +277,34 @@ class dsp(object): last_decimation=self.last_decimation, ) - logger.debug("secondary command (fft) = %s", secondary_command_fft) logger.debug("secondary command (demod) = %s", secondary_command_demod) my_env = os.environ.copy() # if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"] = "1" - self.secondary_process_fft = subprocess.Popen( - secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env - ) + if self.output.supports_type('secondary_fft'): + secondary_command_fft = self.secondary_chain("fft") + secondary_command_fft = secondary_command_fft.format( + input_pipe=self.iqtee_pipe, + secondary_fft_input_size=self.secondary_fft_size, + secondary_fft_size=self.secondary_fft_size, + secondary_fft_block_size=self.secondary_fft_block_size(), + ) + logger.debug("secondary command (fft) = %s", secondary_command_fft) + + self.secondary_process_fft = subprocess.Popen( + secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env + ) + self.output.send_output( + "secondary_fft", + partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())), + ) + self.secondary_process_demod = subprocess.Popen( secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env ) self.secondary_processes_running = True - self.output.add_output( - "secondary_fft", - partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())), - ) if self.isWsjtMode(): smd = self.get_secondary_demodulator() if smd == "ft8": @@ -298,9 +318,9 @@ class dsp(object): elif smd == "ft4": chopper = Ft4Chopper(self.secondary_process_demod.stdout) chopper.start() - self.output.add_output("wsjt_demod", chopper.read) + self.output.send_output("wsjt_demod", chopper.read) else: - self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) + self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) # open control pipes for csdr and send initialization data if self.secondary_shift_pipe != None: # TODO digimodes @@ -551,7 +571,9 @@ class dsp(object): my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"] = "1" - self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) + + out = subprocess.PIPE if self.output.supports_type('audio') else subprocess.DEVNULL + self.process = subprocess.Popen(command, stdout=out, shell=True, preexec_fn=os.setpgrp, env=my_env) def watch_thread(): rc = self.process.wait() @@ -562,10 +584,11 @@ class dsp(object): threading.Thread(target=watch_thread).start() - self.output.add_output( - "audio", - partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256), - ) + if self.output.supports_type('audio'): + self.output.send_output( + "audio", + partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256), + ) # open control pipes for csdr if self.bpf_pipe: @@ -595,7 +618,7 @@ class dsp(object): else: return float(raw.rstrip("\n")) - self.output.add_output("smeter", read_smeter) + self.output.send_output("smeter", read_smeter) if self.meta_pipe != None: # TODO make digiham output unicode and then change this here self.meta_pipe_file = open(self.meta_pipe, "r", encoding="cp437") @@ -607,7 +630,7 @@ class dsp(object): else: return raw.rstrip("\n") - self.output.add_output("meta", read_meta) + self.output.send_output("meta", read_meta) if self.dmr_control_pipe: self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w") diff --git a/owrx/service.py b/owrx/service.py index d04ee0b..f90b2bb 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -14,17 +14,15 @@ class ServiceOutput(output): def __init__(self, frequency): self.frequency = frequency - def add_output(self, t, read_fn): - if t == "wsjt_demod": - parser = WsjtParser(WsjtHandler()) - parser.setDialFrequency(self.frequency) - target = self.pump(read_fn, parser.parse) - else: - # dump everything else - # TODO rewrite the output mechanism in a way that avoids producing unnecessary data - target = self.pump(read_fn, lambda x: None) + def receive_output(self, t, read_fn): + parser = WsjtParser(WsjtHandler()) + parser.setDialFrequency(self.frequency) + target = self.pump(read_fn, parser.parse) threading.Thread(target=target).start() + def supports_type(self, t): + return t == 'wsjt_demod' + class ServiceHandler(object): def __init__(self, source): diff --git a/owrx/source.py b/owrx/source.py index 3191cc2..ca82270 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -376,7 +376,7 @@ class SpectrumThread(csdr.output): if self.sdrSource.isAvailable(): self.dsp.start() - def add_output(self, type, read_fn): + def receive_output(self, type, read_fn): if type != "audio": logger.error("unsupported output type received by FFT: %s", type) return @@ -503,7 +503,7 @@ class DspManager(csdr.output): if self.sdrSource.isAvailable(): self.dsp.start() - def add_output(self, t, read_fn): + def receive_output(self, t, read_fn): logger.debug("adding new output of type %s", t) writers = { "audio": self.handler.write_dsp_data, From 8214fdb24dff843c2937678f2a1cc357eaa44125 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 4 Aug 2019 15:17:03 +0200 Subject: [PATCH 114/118] looks configurable to me, at least for now --- owrx/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/owrx/service.py b/owrx/service.py index f90b2bb..fe6a4eb 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -40,7 +40,6 @@ class ServiceHandler(object): self.stopServices() def isSupported(self, mode): - # TODO make configurable return mode in PropertyManager.getSharedInstance()["services_decoders"] def stopServices(self): From 766300bdfffa0d473222bd3335731e544ab6f470 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 4 Aug 2019 17:31:50 +0200 Subject: [PATCH 115/118] use latest improvementes for fft, too --- owrx/source.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/owrx/source.py b/owrx/source.py index ca82270..488ff47 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -376,25 +376,15 @@ class SpectrumThread(csdr.output): if self.sdrSource.isAvailable(): self.dsp.start() - def receive_output(self, type, read_fn): - if type != "audio": - logger.error("unsupported output type received by FFT: %s", type) - return + def supports_type(self, t): + return t == 'audio' + def receive_output(self, type, read_fn): if self.props["csdr_dynamic_bufsize"]: read_fn(8) # dummy read to skip bufsize & preamble logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1") - def pipe(): - run = True - while run: - data = read_fn() - if len(data) == 0: - run = False - else: - self.sdrSource.writeSpectrumData(data) - - threading.Thread(target=pipe).start() + threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start() def stop(self): self.dsp.stop() From 92321a3b4e537593ac62509ae68385e5156890d3 Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 4 Aug 2019 18:36:03 +0200 Subject: [PATCH 116/118] simple metrics api to interface with collectd and grafana --- owrx/controllers.py | 7 +++++++ owrx/http.py | 2 ++ owrx/metrics.py | 32 ++++++++++++++++++++++++++++++++ owrx/wsjt.py | 3 +++ 4 files changed, 44 insertions(+) create mode 100644 owrx/metrics.py diff --git a/owrx/controllers.py b/owrx/controllers.py index c6c0da5..f7ce7e0 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -9,6 +9,7 @@ from owrx.source import ClientRegistry from owrx.connection import WebSocketMessageHandler from owrx.version import openwebrx_version from owrx.feature import FeatureDetector +from owrx.metrics import Metrics import logging @@ -124,6 +125,12 @@ class ApiController(Controller): self.send_response(data, content_type="application/json") +class MetricsController(Controller): + def handle_request(self): + data = json.dumps(Metrics.getSharedInstance().getMetrics()) + self.send_response(data, content_type="application/json") + + class WebSocketController(Controller): def handle_request(self): conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) diff --git a/owrx/http.py b/owrx/http.py index 99c1003..ce96acc 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -6,6 +6,7 @@ from owrx.controllers import ( MapController, FeatureController, ApiController, + MetricsController, ) from http.server import BaseHTTPRequestHandler import re @@ -43,6 +44,7 @@ class Router(object): {"route": "/map", "controller": MapController}, {"route": "/features", "controller": FeatureController}, {"route": "/api/features", "controller": ApiController}, + {"route": "/metrics", "controller": MetricsController}, ] def find_controller(self, path): diff --git a/owrx/metrics.py b/owrx/metrics.py new file mode 100644 index 0000000..a8923d0 --- /dev/null +++ b/owrx/metrics.py @@ -0,0 +1,32 @@ +class Metrics(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if Metrics.sharedInstance is None: + Metrics.sharedInstance = Metrics() + return Metrics.sharedInstance + + def __init__(self): + self.metrics = {} + + def pushDecodes(self, band, mode, count = 1): + if band is None: + band = 'unknown' + else: + band = band.getName() + + if mode is None: + mode = 'unknown' + + if not band in self.metrics: + self.metrics[band] = {} + if not mode in self.metrics[band]: + self.metrics[band][mode] = { + "count": 0 + } + + self.metrics[band][mode]["count"] += count + + def getMetrics(self): + return self.metrics diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 2c26b2f..082e59f 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -10,6 +10,7 @@ from owrx.map import Map, LocatorLocation import re from owrx.config import PropertyManager from owrx.bands import Bandplan +from owrx.metrics import Metrics import logging @@ -228,6 +229,7 @@ class WsjtParser(object): mode = WsjtParser.modes[modeChar] if modeChar in WsjtParser.modes else "unknown" wsjt_msg = msg[17:53].strip() self.parseLocator(wsjt_msg, mode) + Metrics.getSharedInstance().pushDecodes(self.band, mode) return { "timestamp": timestamp, "db": float(msg[0:3]), @@ -253,6 +255,7 @@ class WsjtParser(object): # '0052 -29 2.6 0.001486 0 G02CWT IO92 23' wsjt_msg = msg[29:].strip() self.parseWsprMessage(wsjt_msg) + Metrics.getSharedInstance().pushDecodes(self.band, 'WSPR') return { "timestamp": self.parse_timestamp(msg[0:4], "%H%M"), "db": float(msg[5:8]), From d467d79bdf5a175cccee5dc621dd6127a823425b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 11 Aug 2019 11:37:45 +0200 Subject: [PATCH 117/118] code format with black --- csdr.py | 21 +++++++++++---------- owrx/metrics.py | 10 ++++------ owrx/service.py | 9 +++++++-- owrx/source.py | 2 +- owrx/wsjt.py | 2 +- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/csdr.py b/csdr.py index 08a0c45..832d069 100644 --- a/csdr.py +++ b/csdr.py @@ -36,7 +36,7 @@ class output(object): def send_output(self, t, read_fn): if not self.supports_type(t): # TODO rewrite the output mechanism in a way that avoids producing unnecessary data - logger.warning('dumping output of type %s since it is not supported.', t) + logger.warning("dumping output of type %s since it is not supported.", t) threading.Thread(target=self.pump(read_fn, lambda x: None)).start() return self.receive_output(t, read_fn) @@ -131,18 +131,17 @@ class dsp(object): "csdr shift_addition_cc --fifo {shift_pipe}", "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING", "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING", - ] - if self.output.supports_type('smeter'): + if self.output.supports_type("smeter"): chain += [ - "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}", + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}" ] if self.secondary_demodulator: - if self.output.supports_type('secondary_fft'): + if self.output.supports_type("secondary_fft"): chain += ["csdr tee {iqtee_pipe}"] chain += ["csdr tee {iqtee2_pipe}"] # early exit if we don't want audio - if not self.output.supports_type('audio'): + if not self.output.supports_type("audio"): return chain # safe some cpu cycles... no need to decimate if decimation factor is 1 last_decimation_block = ( @@ -282,7 +281,7 @@ class dsp(object): # if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"] = "1" - if self.output.supports_type('secondary_fft'): + if self.output.supports_type("secondary_fft"): secondary_command_fft = self.secondary_chain("fft") secondary_command_fft = secondary_command_fft.format( input_pipe=self.iqtee_pipe, @@ -572,7 +571,7 @@ class dsp(object): if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"] = "1" - out = subprocess.PIPE if self.output.supports_type('audio') else subprocess.DEVNULL + out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL self.process = subprocess.Popen(command, stdout=out, shell=True, preexec_fn=os.setpgrp, env=my_env) def watch_thread(): @@ -584,10 +583,12 @@ class dsp(object): threading.Thread(target=watch_thread).start() - if self.output.supports_type('audio'): + if self.output.supports_type("audio"): self.output.send_output( "audio", - partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256), + partial( + self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256 + ), ) # open control pipes for csdr diff --git a/owrx/metrics.py b/owrx/metrics.py index a8923d0..11f503f 100644 --- a/owrx/metrics.py +++ b/owrx/metrics.py @@ -10,21 +10,19 @@ class Metrics(object): def __init__(self): self.metrics = {} - def pushDecodes(self, band, mode, count = 1): + def pushDecodes(self, band, mode, count=1): if band is None: - band = 'unknown' + band = "unknown" else: band = band.getName() if mode is None: - mode = 'unknown' + mode = "unknown" if not band in self.metrics: self.metrics[band] = {} if not mode in self.metrics[band]: - self.metrics[band][mode] = { - "count": 0 - } + self.metrics[band][mode] = {"count": 0} self.metrics[band][mode]["count"] += count diff --git a/owrx/service.py b/owrx/service.py index fe6a4eb..b64e504 100644 --- a/owrx/service.py +++ b/owrx/service.py @@ -21,7 +21,7 @@ class ServiceOutput(output): threading.Thread(target=target).start() def supports_type(self, t): - return t == 'wsjt_demod' + return t == "wsjt_demod" class ServiceHandler(object): @@ -69,7 +69,11 @@ class ServiceHandler(object): cf = self.source.getProps()["center_freq"] srh = self.source.getProps()["samp_rate"] / 2 frequency_range = (cf - srh, cf + srh) - self.services = [self.setupService(dial["mode"], dial["frequency"]) for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range) if self.isSupported(dial["mode"])] + self.services = [ + self.setupService(dial["mode"], dial["frequency"]) + for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range) + if self.isSupported(dial["mode"]) + ] def setupService(self, mode, frequency): logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) @@ -92,6 +96,7 @@ class WsjtHandler(object): class ServiceManager(object): sharedInstance = None + @staticmethod def getSharedInstance(): if ServiceManager.sharedInstance is None: diff --git a/owrx/source.py b/owrx/source.py index 488ff47..8f5dc65 100644 --- a/owrx/source.py +++ b/owrx/source.py @@ -377,7 +377,7 @@ class SpectrumThread(csdr.output): self.dsp.start() def supports_type(self, t): - return t == 'audio' + return t == "audio" def receive_output(self, type, read_fn): if self.props["csdr_dynamic_bufsize"]: diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 082e59f..e18257e 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -255,7 +255,7 @@ class WsjtParser(object): # '0052 -29 2.6 0.001486 0 G02CWT IO92 23' wsjt_msg = msg[29:].strip() self.parseWsprMessage(wsjt_msg) - Metrics.getSharedInstance().pushDecodes(self.band, 'WSPR') + Metrics.getSharedInstance().pushDecodes(self.band, "WSPR") return { "timestamp": self.parse_timestamp(msg[0:4], "%H%M"), "db": float(msg[5:8]), From b0056a4677f9438a557c06baa1e4864739ce319b Mon Sep 17 00:00:00 2001 From: Jakob Ketterl Date: Sun, 11 Aug 2019 11:39:35 +0200 Subject: [PATCH 118/118] disable services by default --- config_webrx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config_webrx.py b/config_webrx.py index 044dc85..f75d3e3 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -252,5 +252,5 @@ map_position_retention_time = 2 * 60 * 60 temporary_directory = "/tmp" -services_enabled = True +services_enabled = False services_decoders = ["ft8", "ft4", "wspr"]
    UTC dB DTFreqFreq Message
    ' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + msg['db'] + '' + msg['dt'] + '' + msg['freq'] + '' + msg['freq'] + '' + linkedmsg + '