diff --git a/README.md b/README.md index d308c36..68178af 100644 --- a/README.md +++ b/README.md @@ -9,38 +9,42 @@ 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) +- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9) -![OpenWebRX 3D waterfall](http://blog.sdr.hu/images/openwebrx/screenshot-3d.gif) +**News (2019-07-21 by DD5JFK)** +- Latest Features: + - 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 -**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 (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 (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 (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 (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,22 +54,49 @@ 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-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. + +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) + +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 - + ./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): -- 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 +117,6 @@ If you have any problems installing OpenWebRX, you should check out the summary). diff --git a/bands.json b/bands.json new file mode 100644 index 0000000..30a26bb --- /dev/null +++ b/bands.json @@ -0,0 +1,189 @@ +[ + { + "name": "160m", + "lower_bound": 1810000, + "upper_bound": 2000000, + "frequencies": { + "psk31": 1838000, + "ft8": 1840000, + "wspr": 1836600, + "jt65": 1838000, + "jt9": 1839000 + } + }, + { + "name": "80m", + "lower_bound": 3500000, + "upper_bound": 3800000, + "frequencies": { + "psk31": 3580000, + "ft8": 3573000, + "wspr": 3592600, + "jt65": 3570000, + "jt9": 3572000, + "ft4": [3568000, 3568000] + } + }, + { + "name": "60m", + "lower_bound": 5351500, + "upper_bound": 5366500, + "frequencies": { + "ft8": 5357000, + "wspr": 5287200 + } + }, + { + "name": "40m", + "lower_bound": 7000000, + "upper_bound": 7200000, + "frequencies": { + "psk31": 7040000, + "ft8": 7074000, + "wspr": 7038600, + "jt65": 7076000, + "jt9": 7078000, + "ft4": 7047500 + } + }, + { + "name": "30m", + "lower_bound": 10100000, + "upper_bound": 10150000, + "frequencies": { + "psk31": 10141000, + "ft8": 10136000, + "wspr": 10138700, + "jt65": 10138000, + "jt9": 10140000, + "ft4": 10140000 + } + }, + { + "name": "20m", + "lower_bound": 14000000, + "upper_bound": 14350000, + "frequencies": { + "psk31": 14070000, + "ft8": 14074000, + "wspr": 14095600, + "jt65": 14076000, + "jt9": 14078000, + "ft4": 14080000 + } + }, + { + "name": "17m", + "lower_bound": 18068000, + "upper_bound": 18168000, + "frequencies": { + "psk31": 18098000, + "ft8": 18100000, + "wspr": 18104600, + "jt65": 18102000, + "jt9": 18104000, + "ft4": 18104000 + } + }, + { + "name": "15m", + "lower_bound": 21000000, + "upper_bound": 21450000, + "frequencies": { + "psk31": 21070000, + "ft8": 21074000, + "wspr": 21094600, + "jt65": 21076000, + "jt9": 21078000, + "ft4": 21140000 + } + }, + { + "name": "12m", + "lower_bound": 24890000, + "upper_bound": 24990000, + "frequencies": { + "psk31": 24920000, + "ft8": 24915000, + "wspr": 24924600, + "jt65": 24917000, + "jt9": 24919000, + "ft4": 24919000 + } + }, + { + "name": "10m", + "lower_bound": 28000000, + "upper_bound": 29700000, + "frequencies": { + "psk31": [28070000, 28120000], + "ft8": 28074000, + "wspr": 28124600, + "jt65": 28076000, + "jt9": 28078000, + "ft4": 28180000 + } + }, + { + "name": "6m", + "lower_bound": 50030000, + "upper_bound": 51000000, + "frequencies": { + "psk31": 50305000, + "ft8": 50313000, + "wspr": 50293000, + "jt65": 50310000, + "jt9": 50312000, + "ft4": 50318000 + } + }, + { + "name": "4m", + "lower_bound": 70150000, + "upper_bound": 70200000, + "frequencies": { + "wspr": 70091000 + } + }, + { + "name": "2m", + "lower_bound": 144000000, + "upper_bound": 146000000, + "frequencies": { + "wspr": 144489000, + "ft8": 144174000, + "ft4": 144170000, + "jt65": 144120000 + } + }, + { + "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/config_webrx.py b/config_webrx.py index 0613278..f75d3e3 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,14 +222,35 @@ 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 = "" + +# 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 + +temporary_directory = "/tmp" + +services_enabled = False +services_decoders = ["ft8", "ft4", "wspr"] diff --git a/csdr.py b/csdr.py old mode 100755 new mode 100644 index 93e05d0..6b80da5 --- a/csdr.py +++ b/csdr.py @@ -21,33 +21,56 @@ 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, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper import logging + logger = logging.getLogger(__name__) + class output(object): - def add_output(self, type, read_fn): - pass - def reset(self): + 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 receive_output(self, t, read_fn): 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 + + def supports_type(self, t): + return True + + 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,105 +90,165 @@ 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() self.output = output + self.temporary_directory = "/tmp" - 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 | " + def set_temporary_directory(self, what): + self.temporary_directory = what + + 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 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}" - if self.fft_compression=="adpcm": - chain += " | csdr compress_fft_adpcm_f_u8 {fft_size}" + 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}"] 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", + ] + 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} | " - chain += "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 "" + 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" + 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 == "packet": - chain += "csdr fmdemod_quadri_cf | " + chain += ["csdr fmdemod_quadri_cf"] chain += last_decimation_block - chain += "csdr convert_f_s16 | " - chain += "direwolf -r {audio_rate} - 1>&2" + chain += [ + "csdr convert_f_s16", + "direwolf -r {audio_rate} - 1>&2" + ] 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"] + # 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 += [ + "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" + 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: + 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: return self.secondary_demodulator = what + self.calculate_decimation() 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): @@ -175,7 +258,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): @@ -183,55 +266,82 @@ 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) - self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft) + if not self.secondary_demodulator: + return + logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) + 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.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(), 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("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" + 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())), ) - 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) - 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 + ) 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.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) + elif smd == "jt65": + 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.send_output("wsjt_demod", chopper.read) + else: + 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 - 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 - if self.secondary_processes_running: - self.secondary_shift_pipe_file.write("%g\n"%(-float(self.secondary_offset_freq)/self.if_samp_rate())) + 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.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: @@ -250,42 +360,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 @@ -296,61 +411,73 @@ class dsp(object): def get_audio_rate(self): if self.isDigitalVoice() or self.isPacket(): return 48000 + elif self.isWsjtMode(): + 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): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"] + def isPacket(self, demodulator = None): if demodulator is None: demodulator = self.get_demodulator() return demodulator == "packet" - 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() @@ -358,12 +485,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() or self.isPacket() 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() @@ -379,7 +506,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: @@ -387,64 +514,94 @@ 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 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()") def start(self): self.modification_lock.acquire() - if (self.running): + if self.running: self.modification_lock.release() return self.running = True - command_base=self.chain(self.demodulator) + command_base = " | ".join(self.chain(self.demodulator)) - #create control pipes for csdr - self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self)) + # 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(), audio_rate = self.get_audio_rate(), - dmr_control_pipe = self.dmr_control_pipe) + # 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"; - self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) + logger.debug("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" + + 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() 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)) + 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: @@ -465,24 +622,28 @@ 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) + + 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") + 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) + + self.output.send_output("meta", read_meta) if self.dmr_control_pipe: self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w") @@ -503,10 +664,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/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..2c7883e 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,10 @@ rm -rf csdr git clone https://github.com/szechyjs/mbelib.git cmakebuild mbelib +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 @@ -75,4 +79,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 diff --git a/htdocs/css/features.css b/htdocs/css/features.css new file mode 100644 index 0000000..7b0b008 --- /dev/null +++ b/htdocs/css/features.css @@ -0,0 +1,12 @@ +@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; +} \ No newline at end of file diff --git a/htdocs/css/map.css b/htdocs/css/map.css new file mode 100644 index 0000000..5d478cd --- /dev/null +++ b/htdocs/css/map.css @@ -0,0 +1,57 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +/* expandable photo not implemented on map page */ +#webrx-top-photo-clip { + max-height: 67px; +} + +body { + display: flex; + flex-direction: column; +} + +#webrx-top-container { + flex: none; +} + +.openwebrx-map { + flex: 1 1 auto; +} + +h3 { + margin: 10px 0; + text-align: center; +} + +ul { + margin-block-start: 5px; + margin-block-end: 5px; + padding-inline-start: 25px; +} + +.openwebrx-map-legend { + background-color: #fff; + padding: 10px; + margin: 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; + border-width: 2px; + border-style: solid; +} + +.openwebrx-map-legend select { + background-color: #FFF; + border-color: #DDD; + padding: 5px; +} diff --git a/htdocs/css/openwebrx-globals.css b/htdocs/css/openwebrx-globals.css new file mode 100644 index 0000000..41ef284 --- /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; +} + diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css new file mode 100644 index 0000000..ef0a129 --- /dev/null +++ b/htdocs/css/openwebrx-header.css @@ -0,0 +1,203 @@ +#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; + box-sizing: content-box; +} + +#webrx-rx-avatar +{ + cursor:pointer; + width: 46px; + height: 46px; + padding: 4px; + border-radius: 8px; + box-sizing: content-box; +} + +#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/openwebrx.css b/htdocs/css/openwebrx.css similarity index 80% rename from htdocs/openwebrx.css rename to htdocs/css/openwebrx.css index 5624d79..cd1498b 100644 --- a/htdocs/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -18,13 +18,10 @@ along with this program. If not, see . */ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); -html, body -{ - margin: 0; - padding: 0; - height: 100%; - font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; +html, body { overflow: hidden; } @@ -147,182 +144,16 @@ 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; -} - -#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; - 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; @@ -331,14 +162,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; @@ -428,15 +259,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; } @@ -533,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; @@ -637,47 +482,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 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; @@ -812,7 +616,7 @@ img.openwebrx-mirror-img #openwebrx-secondary-demod-listbox { - width: 201px; + width: 174px; height: 27px; padding-left:3px; } @@ -951,7 +755,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; @@ -993,11 +797,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 * { @@ -1005,7 +809,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; @@ -1013,3 +817,62 @@ 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; + padding: 1px 3px; +} + +#openwebrx-panel-wsjt-message .message { + width: 380px; +} + +#openwebrx-panel-wsjt-message .decimal { + text-align: right; + 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, +#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="ft4"] #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="jt65"] #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/features.html b/htdocs/features.html new file mode 100644 index 0000000..6e1eb55 --- /dev/null +++ b/htdocs/features.html @@ -0,0 +1,21 @@ + + OpenWebRX Feature report + + + + + + + ${header} +
+

OpenWebRX Feature Report

+ + + + + + + +
FeatureRequirementDescriptionAvailable
+
+ \ No newline at end of file diff --git a/htdocs/features.js b/htdocs/features.js new file mode 100644 index 0000000..6da77c8 --- /dev/null +++ b/htdocs/features.js @@ -0,0 +1,24 @@ +$(function(){ + var converter = new showdown.Converter(); + $.ajax('/api/features').done(function(data){ + var $table = $('table.features'); + $.each(data, function(name, details) { + var 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/htdocs/gfx/openwebrx-panel-map.png b/htdocs/gfx/openwebrx-panel-map.png new file mode 100644 index 0000000..81ec9e2 Binary files /dev/null and b/htdocs/gfx/openwebrx-panel-map.png differ diff --git a/htdocs/gfx/openwebrx-top-photo.jpg b/htdocs/gfx/openwebrx-top-photo.jpg index cf521c7..afc8e7e 100644 Binary files a/htdocs/gfx/openwebrx-top-photo.jpg and b/htdocs/gfx/openwebrx-top-photo.jpg differ 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/include/header.include.html b/htdocs/include/header.include.html new file mode 100644 index 0000000..c7efe13 --- /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 022e2ac..2629614 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -25,43 +25,15 @@ - - - - + + + +
-
-
- -
- - -
- -
-
-
-
-
-
- - -
-
-
    -

  • Status
  • -

  • Log
  • -

  • Receiver
  • -
-
-
-
-
-
-
+ ${header}
@@ -111,7 +83,19 @@ +
+ + + + + +
@@ -160,7 +144,7 @@ Under construction
We're working on the code right now, so the application might fail.
-
+
@@ -171,6 +155,16 @@
+ + + + + + + + + +
UTCdBDTFreqMessage
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 + + + + + + + + + ${header} +
+
+

Colors

+ +
+
+ + diff --git a/htdocs/map.js b/htdocs/map.js new file mode 100644 index 0000000..7d98f49 --- /dev/null +++ b/htdocs/map.js @@ -0,0 +1,340 @@ +(function(){ + var protocol = 'ws'; + if (window.location.toString().startsWith('https://')) { + protocol = 'wss'; + } + + var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ + var s = v.split('='); + var 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 expectedLocator; + if (query.locator) expectedLocator = query.locator; + + var ws_url = protocol + "://" + (window.location.origin.split("://")[1]) + "/ws/"; + if (!("WebSocket" in window)) return; + + var map; + var markers = {}; + var rectangles = {}; + var updateQueue = []; + + // reasonable default; will be overriden by server + var retention_time = 2 * 60 * 60 * 1000; + var strokeOpacity = 0.8; + var fillOpacity = 0.35; + + var colorKeys = {}; + var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl'); + var getColor = function(id){ + if (!id) return "#000000"; + if (!colorKeys[id]) { + var keys = Object.keys(colorKeys); + keys.push(id); + keys.sort(); + var colors = colorScale.colors(keys.length); + colorKeys = {}; + keys.forEach(function(key, index) { + colorKeys[key] = colors[index]; + }); + reColor(); + updateLegend(); + } + 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(colorAccessor(r)); + r.setOptions({ + strokeColor: color, + fillColor: color + }); + }); + } + + 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 + '
  • '; + }); + $(".openwebrx-map-legend .content").html('
      ' + lis.join('') + '
    '); + } + + var processUpdates = function(updates) { + if (!map) { + updateQueue = updateQueue.concat(updates); + return; + } + updates.forEach(function(update){ + + switch (update.location.type) { + case 'latlon': + var pos = new google.maps.LatLng(update.location.lat, update.location.lon); + var marker; + if (markers[update.callsign]) { + 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({ + position: pos, + map: map, + title: update.callsign + }, 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()) { + map.panTo(pos); + showMarkerInfoWindow(update.callsign, 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]) * 2; + var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); + var rectangle; + // 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 { + rectangle = new google.maps.Rectangle(); + rectangle.addListener('click', function(){ + showLocatorInfoWindow(this.locator, this.center); + }); + rectangles[update.callsign] = rectangle; + } + rectangle.setOptions($.extend({ + strokeColor: color, + strokeWeight: 2, + fillColor: color, + map: map, + bounds:{ + north: lat, + south: lat + 1, + west: lon, + east: lon + 2 + } + }, getRectangleOpacityOptions(update.lastseen) )); + rectangle.lastseen = update.lastseen; + 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); + showLocatorInfoWindow(expectedLocator, center); + delete(expectedLocator); + } + break; + } + }); + }; + + var clearMap = function(){ + var reset = function(callsign, item) { item.setMap(); }; + $.each(markers, reset); + $.each(rectangles, reset); + markers = {}; + 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){ + 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 + }); + map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push($(".openwebrx-map-legend")[0]); + }); + 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(); + 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 + ws.onclose = function () {}; + ws.close(); + }; + + /* + ws.onerror = function(){ + console.info("websocket error"); + }; + */ + }; + + connect(); + + var infowindow; + 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, band: r.band} + }).filter(function(d) { + return d.locator == locator; + }).sort(function(a, b){ + return b.lastseen - a.lastseen; + }); + infowindow.setContent( + '

    Locator: ' + locator + '

    ' + + '
    Active Callsigns:
    ' + + '
      ' + + inLocator.map(function(i){ + var timestring = moment(i.lastseen).fromNow(); + var message = i.callsign + ' (' + timestring + ' using ' + i.mode; + if (i.band) message += ' on ' + i.band; + message += ')'; + return '
    • ' + message + '
    • ' + }).join("") + + '
    ' + ); + infowindow.setPosition(pos); + 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 + ' using ' + marker.mode + '
    ' + ); + infowindow.open(map, marker); + } + + var getScale = function(lastseen) { + var age = new Date().getTime() - lastseen; + var scale = 1; + if (age >= 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/htdocs/openwebrx.js b/htdocs/openwebrx.js index 505aef2..400fa89 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; @@ -1250,6 +1251,13 @@ function on_ws_recv(evt) case "metadata": update_metadata(json.value); break; + 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); } @@ -1315,6 +1323,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': @@ -1356,8 +1387,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 : ""; @@ -1377,6 +1408,56 @@ 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']; + 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] + ''; + } 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($( + '' + + '' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '' + + '' + msg['db'] + '' + + '' + msg['dt'] + '' + + '' + msg['freq'] + '' + + '' + linkedmsg + '' + + '' + )); + $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); + 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; + if (toRemove <= 0) return; + $elements.slice(0, toRemove).remove(); + }, 15000); +} + function hide_digitalvoice_panels() { $(".openwebrx-meta-panel").each(function(_, p){ toggle_panel(p.id, false); @@ -1436,8 +1517,9 @@ 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); + reconnect_timeout = false; } var was_error=0; @@ -1818,6 +1900,8 @@ function audio_init() } +var reconnect_timeout = false; + function on_ws_closed() { try @@ -1826,9 +1910,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) @@ -2332,6 +2423,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"; @@ -2344,6 +2442,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(); @@ -2351,7 +2450,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) { @@ -2638,12 +2739,19 @@ function demodulator_digital_replace(subtype) { case "bpsk31": case "rtty": + case "ft8": + case "wspr": + case "jt65": + case "jt9": + case "ft4": secondary_demod_start(subtype); demodulator_analog_replace('usb', true); demodulator_buttons_update(); break; } + $('#openwebrx-panel-digimodes').attr('data-mode', subtype); toggle_panel("openwebrx-panel-digimodes", true); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4'].indexOf(subtype) >= 0); } function secondary_demod_create_canvas() @@ -2698,6 +2806,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) @@ -2705,6 +2814,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) @@ -2762,6 +2872,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 @@ -2802,19 +2913,23 @@ 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'); + case "ft8": + case "wspr": + case "jt65": + case "jt9": + case "ft4": + demodulator_digital_replace(sdm); break; } + update_dial_button(); } function secondary_demod_listbox_update() 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. -
    -
    -
    - - - diff --git a/openwebrx.py b/openwebrx.py old mode 100644 new mode 100755 index 99b1419..9fa685b --- a/openwebrx.py +++ b/openwebrx.py @@ -1,13 +1,17 @@ +#!/usr/bin/env python3 + 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 +from owrx.service import ServiceManager 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 +19,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 +48,9 @@ Author contact info: Andras Retzler, HA7ILM updater = SdrHuUpdater() updater.start() - server = ThreadedHttpServer(('0.0.0.0', pm.getPropertyValue("web_port")), RequestHandler) + 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 new file mode 100644 index 0000000..bd74474 --- /dev/null +++ b/owrx/bands.py @@ -0,0 +1,65 @@ +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 + + 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 + + @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 findBands(self, freq): + return [band for band in self.bands if band.inBand(freq)] + + def findBand(self, freq): + bands = self.findBands(freq) + if bands: + return bands[0] + else: + return None + + def collectDialFrequencies(self, range): + return [e for b in self.bands for e in b.getDialFrequencies(range)] 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 67ee96b..5008e36 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -1,20 +1,59 @@ 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 import logging + logger = logging.getLogger(__name__) -class OpenWebRxClient(object): - config_keys = ["waterfall_colors", "waterfall_min_level", "waterfall_max_level", - "waterfall_auto_level_margin", "lfo_offset", "samp_rate", "fft_size", "fft_fps", - "audio_compression", "fft_compression", "max_clients", "start_mod", - "client_audio_buffer_size", "start_freq", "center_freq", "mathbox_waterfall_colors", - "mathbox_waterfall_history_length", "mathbox_waterfall_frequency_resolution"] + +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): + super().__init__(conn) + self.dsp = None self.sdr = None self.configSub = None @@ -26,12 +65,23 @@ class OpenWebRxClient(object): 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() @@ -39,9 +89,9 @@ class OpenWebRxClient(object): 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() @@ -53,14 +103,23 @@ 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) + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + frequencyRange = (cf - srh, cf + srh) + self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange)) + self.configSub = configProps.wire(sendConfig) sendConfig(None, None) @@ -78,8 +137,7 @@ class OpenWebRxClient(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: @@ -90,8 +148,11 @@ class OpenWebRxClient(object): 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 @@ -99,41 +160,71 @@ class OpenWebRxClient(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}) + 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) + 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}) + 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}) + + def write_dial_frequendies(self, frequencies): + self.protected_send({"type": "dial_frequencies", "value": frequencies}) + + +class MapConnection(Client): + def __init__(self, conn): + super().__init__(conn) + + pm = PropertyManager.getSharedInstance() + self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__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): @@ -142,12 +233,21 @@ class WebSocketMessageHandler(object): self.dsp = None 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" + 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)) logger.debug("client connection intitialized") - self.client = OpenWebRxClient(conn) + 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) return diff --git a/owrx/controllers.py b/owrx/controllers.py index 774ba9b..f7ce7e0 100644 --- a/owrx/controllers.py +++ b/owrx/controllers.py @@ -1,20 +1,27 @@ 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 from owrx.connection import WebSocketMessageHandler from owrx.version import openwebrx_version +from owrx.feature import FeatureDetector +from owrx.metrics import Metrics 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 - def send_response(self, content, code = 200, content_type = "text/html", last_modified: datetime = None, max_age = None): + 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: self.handler.send_header("Content-Type", content_type) @@ -23,15 +30,10 @@ 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) - 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): @@ -47,41 +49,90 @@ 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.matches.group(1) + filename = self.request.matches.group(1) self.serve_file(filename) -class IndexController(AssetsController): + +class TemplateController(Controller): + def render_template(self, file, **vars): + 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") + + 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_file("index.html", content_type = "text/html") + 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_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") + + +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()) - conn.send("CLIENT DE SERVER openwebrx.py") # enter read loop conn.read_loop() diff --git a/owrx/feature.py b/owrx/feature.py index ff72fe0..74b1a37 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -4,29 +4,52 @@ from functools import reduce from operator import and_ import re from distutils.version import LooseVersion +import inspect import logging + logger = logging.getLogger(__name__) class UnknownFeatureException(Exception): pass + 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" ], - "packet": [ "direwolf" ] + "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"], + "packet": [ "direwolf" ], } def feature_availability(self): 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 { + "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): return self.has_requirements(self.get_requirements(feature)) @@ -34,50 +57,89 @@ 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 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): - return self.command_is_runnable('nc --help') + """ + 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") @@ -85,18 +147,19 @@ 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 (.*)$') + digiham_version_regex = re.compile("^digiham version (.*)$") + def check_digiham_version(command): try: process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) @@ -105,22 +168,52 @@ 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): + """ + 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_direwolf(self): return self.command_is_runnable("direwolf --help") 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") + + 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 reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) diff --git a/owrx/http.py b/owrx/http.py index ca7d357..ce96acc 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,17 +1,37 @@ -from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController +from owrx.controllers import ( + StatusController, + IndexController, + AssetsController, + WebSocketController, + MapController, + FeatureController, + ApiController, + MetricsController, +) 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): + self.query = query + self.matches = matches + + class Router(object): mappings = [ {"route": "/", "controller": IndexController}, @@ -20,8 +40,13 @@ 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}, + {"route": "/features", "controller": FeatureController}, + {"route": "/api/features", "controller": ApiController}, + {"route": "/metrics", "controller": MetricsController}, ] + def find_controller(self, path): for m in Router.mappings: if "route" in m: @@ -32,11 +57,17 @@ class Router(object): matches = regex.match(path) 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.") diff --git a/owrx/map.py b/owrx/map.py new file mode 100644 index 0000000..9908d7a --- /dev/null +++ b/owrx/map.py @@ -0,0 +1,108 @@ +from datetime import datetime, timedelta +import threading, time +from owrx.config import PropertyManager +from owrx.bands import Band + +import logging + +logger = logging.getLogger(__name__) + + +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 = {} + + 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): + for c in self.clients: + c.write_update(update) + + 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() + ] + ) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + + 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, + } + ] + ) + + 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): + 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..1d7a63f 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -4,36 +4,43 @@ import json from datetime import datetime, timedelta import logging import threading +from owrx.map import Map, LatLngLocation logger = logging.getLogger(__name__) + class DmrCache(object): sharedInstance = None + @staticmethod def getSharedInstance(): if DmrCache.sharedInstance is None: DmrCache.sharedInstance = DmrCache() return DmrCache.sharedInstance + 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"] class DmrMetaEnricher(object): def __init__(self): self.threads = {} + def downloadRadioIdData(self, id): cache = DmrCache.getSharedInstance() try: @@ -44,9 +51,12 @@ 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 + 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): @@ -60,10 +70,17 @@ 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 + loc = LatLngLocation(float(meta["lat"]), float(meta["lon"])) + Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF") + return None + + class MetaParser(object): - enrichers = { - "DMR": DmrMetaEnricher() - } + enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher()} def __init__(self, handler): self.handler = handler @@ -76,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/metrics.py b/owrx/metrics.py new file mode 100644 index 0000000..11f503f --- /dev/null +++ b/owrx/metrics.py @@ -0,0 +1,30 @@ +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/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/service.py b/owrx/service.py new file mode 100644 index 0000000..b64e504 --- /dev/null +++ b/owrx/service.py @@ -0,0 +1,118 @@ +import threading +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 + +logger = logging.getLogger(__name__) + + +class ServiceOutput(output): + def __init__(self, frequency): + self.frequency = frequency + + 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): + self.services = [] + self.source = source + self.startupTimer = None + self.source.addClient(self) + self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange) + self.scheduleServiceStartup() + + def onSdrAvailable(self): + self.scheduleServiceStartup() + + def onSdrUnavailable(self): + self.stopServices() + + def isSupported(self, mode): + return mode in PropertyManager.getSharedInstance()["services_decoders"] + + 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): + self.stopServices() + if not self.source.isAvailable(): + return + 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") + 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.nc_port = self.source.getPort() + 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): + if not PropertyManager.getSharedInstance()["services_enabled"]: + return + for source in SdrService.getSources().values(): + ServiceHandler(source) + + +class Service(object): + pass + + +class WsjtService(Service): + pass diff --git a/owrx/source.py b/owrx/source.py index 7d62388..8f5dc65 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 @@ -13,10 +14,12 @@ import logging logger = logging.getLogger(__name__) + class SdrService(object): sdrProps = None sources = {} lastPort = None + @staticmethod def getNextPort(): pm = PropertyManager.getSharedInstance() @@ -28,50 +31,70 @@ 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 +class SdrSourceException(Exception): + pass + + class SdrSource(object): def __init__(self, props, port): self.props = props @@ -84,6 +107,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 @@ -101,15 +125,16 @@ 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": continue + if key == "name": + continue self.props[key] = value def getProfiles(self): @@ -133,7 +158,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() @@ -141,36 +168,54 @@ 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) + available = False + def wait_for_process_to_end(): rc = self.process.wait() 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: + retries = 1000 + 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() @@ -200,6 +245,7 @@ class SdrSource(object): def addClient(self, c): self.clients.append(c) self.start() + def removeClient(self, c): try: self.clients.remove(c) @@ -235,6 +281,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-" @@ -242,39 +289,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" + "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) @@ -287,14 +349,19 @@ 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), 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.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages) + props.getProperty("temporary_directory").wire(dsp.set_temporary_directory), + props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), ] set_fft_averages(None, None) @@ -309,25 +376,15 @@ class SpectrumThread(csdr.output): if self.sdrSource.isAvailable(): self.dsp.start() - def add_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 + 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() @@ -338,20 +395,36 @@ 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 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", - "csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", - "dmr_filter" - ).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() @@ -366,6 +439,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), @@ -378,28 +454,35 @@ 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.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) @@ -410,30 +493,19 @@ 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, "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] - 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() @@ -453,8 +525,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: @@ -482,21 +556,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): @@ -514,11 +590,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: @@ -549,4 +628,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 a247b2a..c773cf9 100644 --- a/owrx/websocket.py +++ b/owrx/websocket.py @@ -3,48 +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 > 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): # 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: @@ -52,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 new file mode 100644 index 0000000..e18257e --- /dev/null +++ b/owrx/wsjt.py @@ -0,0 +1,277 @@ +import threading +import wave +from datetime import datetime, timedelta, date, timezone +import time +import sched +import subprocess +import os +from multiprocessing.connection import Pipe +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 + +logger = logging.getLogger(__name__) + + +class WsjtChopper(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) + self.fileQueue = [] + (self.outputReader, self.outputWriter) = Pipe() + self.doRun = True + super().__init__() + + 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) + ) + wavefile = wave.open(filename, "wb") + wavefile.setnchannels(1) + wavefile.setsampwidth(2) + wavefile.setframerate(12000) + return (filename, wavefile) + + def getNextDecodingTime(self): + t = datetime.now() + 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() + + 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): + self.switchingLock.acquire() + file = self.wavefile + filename = self.wavefilename + (self.wavefilename, self.wavefile) = self.getWaveFile() + self.switchingLock.release() + + file.close() + 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): + 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): + break + self.outputWriter.send(line) + rc = decoder.wait() + if rc != 0: + logger.warning("decoder return code: %i", rc) + os.unlink(file) + + 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("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): + self.doRun = False + else: + self.switchingLock.acquire() + self.wavefile.writeframes(data) + self.switchingLock.release() + + self.decode() + logger.debug("WSJT chopper shutting down") + 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: + return self.outputReader.recv() + except EOFError: + return None + + +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): + # TODO expose decoding quality parameters through config + return ["jt9", "--ft8", "-d", "3", file] + + +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): + # TODO expose decoding quality parameters through config + return ["wsprd", "-d", file] + + +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): + # TODO expose decoding quality parameters through config + return ["jt9", "--jt65", "-d", "3", file] + + +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): + # TODO expose decoding quality parameters through config + 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]+)") + + def __init__(self, handler): + self.handler = handler + self.dial_freq = None + self.band = None + + modes = {"~": "FT8", "#": "JT65", "@": "JT9", "+": "FT4"} + + 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 + + modes = list(WsjtParser.modes.keys()) + if msg[21] in modes or msg[19] in modes: + out = self.parse_from_jt9(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) + return int(datetime.combine(date.today(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000) + + def parse_from_jt9(self, msg): + # ft8 sample + # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' + # jt65 sample + # '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: + dateformat = "%H%M%S" + 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() + self.parseLocator(wsjt_msg, mode) + Metrics.getSharedInstance().pushDecodes(self.band, mode) + 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) + 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)), mode, self.band) + + def parse_from_wsprd(self, msg): + # wspr sample + # '2600 -24 0.4 0.001492 -1 G8AXA JO01 33' + # '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]), + "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) + if m is None: + return + 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) 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() -