Merge branch 'develop' into packet

This commit is contained in:
Jakob Ketterl 2019-08-11 11:53:29 +02:00
commit 5fbbd897b5
47 changed files with 3106 additions and 1055 deletions

View File

@ -9,38 +9,42 @@ OpenWebRX is a multi-user SDR receiver software with a web interface.
It has the following features: It has the following features:
- <a href="https://github.com/simonyiszk/csdr">csdr</a> 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, - 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 extensively uses HTML5 features like WebSocket, Web Audio API, and &lt;canvas&gt;, - it works in Google Chrome, Chromium and Mozilla Firefox
- it works in Google Chrome, Chromium (above version 37) and Mozilla Firefox (above version 28), - currently supports RTL-SDR, HackRF, SDRplay, AirSpy
- currently supports RTL-SDR, HackRF, SDRplay, AirSpy and many other devices, see the <a href="https://github.com/simonyiszk/openwebrx/wiki/">OpenWebRX Wiki</a>, - Multiple SDR devices can be used simultaneously
- it has a 3D waterfall display: - [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)** **News (2019-07-13 by DD5JFK)**
- My BSc. thesis written on OpenWebRX is <a href="https://sdr.hu/static/bsc-thesis.pdf">available here.</a> - Latest Features:
- Several bugs were fixed to improve reliability and stability. - FT8 Integration (using wsjt-x demodulators)
- 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`.) - New Map Feature that shows both decoded grid squares from FT8 and Locations decoded from YSF digital voice
- OpenWebRX now uses <a href="https://github.com/simonyiszk/csdr#sdrjs">sdr.js</a> (*libcsdr* compiled to JavaScript) for some client-side DSP tasks. - New Feature report that will show what functionality is available
- Receivers can now be listed on <a href="http://sdr.hu/">SDR.hu</a>. - There's a new Raspbian SD Card image available (see below)
- License for OpenWebRX is now Affero GPL v3.
**News (2016-02-14)** **News (2019-06-30 by DD5JFK)**
- 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. - 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.
- Also we use *ncat* instead of *rtl_mus*, and it is 3 times faster in some cases. - My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official version.
- OpenWebRX now supports URLs like: `http://localhost:8073/#freq=145555000,mod=usb` - I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there!
- UI improvements were made, thanks to John Seamons and Gnoxter. - 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)** > When upgrading OpenWebRX, please make sure that you also upgrade *csdr* and *digiham*!
- *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 <a href="https://github.com/rxseger/rx_tools">rx_tools</a>, see the <a href="https://github.com/simonyiszk/openwebrx/wiki/Using-rx_tools-with-OpenWebRX">OpenWebRX Wiki</a> 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*!
## OpenWebRX servers on SDR.hu ## OpenWebRX servers on SDR.hu
@ -50,22 +54,49 @@ It has the following features:
## Setup ## 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: First you will need to install the dependencies:
- <a href="https://github.com/simonyiszk/csdr">libcsdr</a> - [csdr](https://github.com/simonyiszk/csdr)
- <a href="http://sdr.osmocom.org/trac/wiki/rtl-sdr">rtl-sdr</a> - [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: 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 <a href="http://localhost:8073">http://localhost:8073</a>. You can now open the GUI at <a href="http://localhost:8073">http://localhost:8073</a>.
Please note that the server is also listening on the following ports (on localhost only): 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`. 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 <a href=
Sometimes the actual error message is not at the end of the terminal output, you may have to look at the whole output to find it. Sometimes the actual error message is not at the end of the terminal output, you may have to look at the whole output to find it.
If you want to run OpenWebRX on a remote server instead of *localhost*, do not forget to set *server_hostname* in `config_webrx.py`.
## Licensing ## Licensing
OpenWebRX is available under Affero GPL v3 license (<a href="https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0)">summary</a>). OpenWebRX is available under Affero GPL v3 license (<a href="https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0)">summary</a>).

189
bands.json Normal file
View File

@ -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
}
]

View File

@ -69,7 +69,9 @@ server_hostname="localhost"
# ==== DSP/RX settings ==== # ==== DSP/RX settings ====
fft_fps = 9 fft_fps = 9
fft_size = 4096 # Should be power of 2 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_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" audio_compression = "adpcm" # valid values: "adpcm", "none"
fft_compression = "adpcm" # valid values: "adpcm", "none" fft_compression = "adpcm" # valid values: "adpcm", "none"
@ -116,7 +118,7 @@ sdrs = {
"rf_gain": 30, "rf_gain": 30,
"samp_rate": 2400000, "samp_rate": 2400000,
"start_freq": 439275000, "start_freq": 439275000,
"start_mod": "nfm" "start_mod": "nfm",
}, },
"2m": { "2m": {
"name": "2m komplett", "name": "2m komplett",
@ -124,9 +126,9 @@ sdrs = {
"rf_gain": 30, "rf_gain": 30,
"samp_rate": 2400000, "samp_rate": 2400000,
"start_freq": 145725000, "start_freq": 145725000,
"start_mod": "nfm" "start_mod": "nfm",
} },
} },
}, },
"sdrplay": { "sdrplay": {
"name": "SDRPlay RSP2", "name": "SDRPlay RSP2",
@ -140,7 +142,7 @@ sdrs = {
"samp_rate": 500000, "samp_rate": 500000,
"start_freq": 14070000, "start_freq": 14070000,
"start_mod": "usb", "start_mod": "usb",
"antenna": "Antenna A" "antenna": "Antenna A",
}, },
"30m": { "30m": {
"name": "30m", "name": "30m",
@ -148,7 +150,7 @@ sdrs = {
"rf_gain": 4, "rf_gain": 4,
"samp_rate": 250000, "samp_rate": 250000,
"start_freq": 10142000, "start_freq": 10142000,
"start_mod": "usb" "start_mod": "usb",
}, },
"40m": { "40m": {
"name": "40m", "name": "40m",
@ -157,7 +159,7 @@ sdrs = {
"samp_rate": 500000, "samp_rate": 500000,
"start_freq": 7070000, "start_freq": 7070000,
"start_mod": "usb", "start_mod": "usb",
"antenna": "Antenna A" "antenna": "Antenna A",
}, },
"80m": { "80m": {
"name": "80m", "name": "80m",
@ -166,7 +168,7 @@ sdrs = {
"samp_rate": 500000, "samp_rate": 500000,
"start_freq": 3570000, "start_freq": 3570000,
"start_mod": "usb", "start_mod": "usb",
"antenna": "Antenna A" "antenna": "Antenna A",
}, },
"49m": { "49m": {
"name": "49m Broadcast", "name": "49m Broadcast",
@ -175,14 +177,12 @@ sdrs = {
"samp_rate": 500000, "samp_rate": 500000,
"start_freq": 6070000, "start_freq": 6070000,
"start_mod": "am", "start_mod": "am",
"antenna": "Antenna A" "antenna": "Antenna A",
} },
} },
}, },
# this one is just here to test feature detection # this one is just here to test feature detection
"test": { "test": {"type": "test"},
"type": "test"
}
} }
# ==== Misc settings ==== # ==== Misc settings ====
@ -192,14 +192,17 @@ client_audio_buffer_size = 5
# - also increase the latency # - also increase the latency
# - decrease the chance of audio underruns # - 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 ==== # ==== 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: ### default theme by teejez:
waterfall_colors = [0x000000ff,0x0000ffff,0x00ffffff,0x00ff00ff,0xffff00ff,0xff0000ff,0xff00ffff,0xffffffff] waterfall_colors = [0x000000FF, 0x0000FFFF, 0x00FFFFFF, 0x00FF00FF, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFFFF]
waterfall_min_level = -88 # in dB waterfall_min_level = -88 # in dB
waterfall_max_level = -20 waterfall_max_level = -20
waterfall_auto_level_margin = (5, 40) waterfall_auto_level_margin = (5, 40)
@ -221,7 +224,16 @@ waterfall_auto_level_margin = (5, 40)
# 3D view settings # 3D view settings
mathbox_waterfall_frequency_resolution = 128 # bins mathbox_waterfall_frequency_resolution = 128 # bins
mathbox_waterfall_history_length = 10 # seconds mathbox_waterfall_history_length = 10 # seconds
mathbox_waterfall_colors = [0x000000ff,0x2e6893ff,0x69a5d0ff,0x214b69ff,0x9dc4e0ff,0xfff775ff,0xff8a8aff,0xb20000ff] mathbox_waterfall_colors = [
0x000000FF,
0x2E6893FF,
0x69A5D0FF,
0x214B69FF,
0x9DC4E0FF,
0xFFF775FF,
0xFF8A8AFF,
0xB20000FF,
]
# === Experimental settings === # === Experimental settings ===
# Warning! The settings below are very experimental. # Warning! The settings below are very experimental.
@ -230,3 +242,15 @@ csdr_print_bufsizes = False # This prints the buffer sizes used for csdr proces
csdr_through = False # Setting this True will print out how much data is going into the DSP chains. 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"]

366
csdr.py Executable file → Normal file
View File

@ -21,23 +21,46 @@ OpenWebRX csdr plugin: do the signal processing with csdr
""" """
import subprocess import subprocess
import time
import os import os
import signal import signal
import threading import threading
from functools import partial from functools import partial
from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class output(object): class output(object):
def add_output(self, type, read_fn): def send_output(self, t, read_fn):
pass if not self.supports_type(t):
def reset(self): # 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 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): class dsp(object):
def __init__(self, output): def __init__(self, output):
self.samp_rate = 250000 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
@ -67,98 +90,158 @@ class dsp(object):
self.secondary_fft_size = 1024 self.secondary_fft_size = 1024
self.secondary_process_fft = None self.secondary_process_fft = None
self.secondary_process_demod = None self.secondary_process_demod = None
self.pipe_names=["bpf_pipe", "shift_pipe", "squelch_pipe", "smeter_pipe", "meta_pipe", "iqtee_pipe", self.pipe_names = [
"iqtee2_pipe", "dmr_control_pipe"] "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_pipe_names = ["secondary_shift_pipe"]
self.secondary_offset_freq = 1000 self.secondary_offset_freq = 1000
self.unvoiced_quality = 1 self.unvoiced_quality = 1
self.modification_lock = threading.Lock() self.modification_lock = threading.Lock()
self.output = output self.output = output
self.temporary_directory = "/tmp"
def set_temporary_directory(self, what):
self.temporary_directory = what
def chain(self, which): def chain(self, which):
chain ="nc -v 127.0.0.1 {nc_port} | " chain = ["nc -v 127.0.0.1 {nc_port}"]
if self.csdr_dynamic_bufsize: chain += "csdr setbuf {start_bufsize} | " if self.csdr_dynamic_bufsize:
if self.csdr_through: chain +="csdr through | " chain += ["csdr setbuf {start_bufsize}"]
if self.csdr_through:
chain += ["csdr through"]
if which == "fft": if which == "fft":
chain += "csdr fft_cc {fft_size} {fft_block_size} | " + \ chain += [
("csdr logpower_cf -70 | " if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages} | ") + \ "csdr fft_cc {fft_size} {fft_block_size}",
"csdr fft_exchange_sides_ff {fft_size}" "csdr logpower_cf -70"
if self.fft_averages == 0
else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}",
"csdr fft_exchange_sides_ff {fft_size}",
]
if self.fft_compression == "adpcm": if self.fft_compression == "adpcm":
chain += " | csdr compress_fft_adpcm_f_u8 {fft_size}" chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"]
return chain return chain
chain += "csdr shift_addition_cc --fifo {shift_pipe} | " chain += [
chain += "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING | " "csdr shift_addition_cc --fifo {shift_pipe}",
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} | " "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: if self.secondary_demodulator:
chain += "csdr tee {iqtee_pipe} | " if self.output.supports_type("secondary_fft"):
chain += "csdr tee {iqtee2_pipe} | " 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 # 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": if which == "nfm":
chain += "csdr fmdemod_quadri_cf | csdr limit_ff | " chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"]
chain += last_decimation_block 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): elif self.isDigitalVoice(which):
chain += "csdr fmdemod_quadri_cf | dc_block | " chain += ["csdr fmdemod_quadri_cf", "dc_block "]
chain += last_decimation_block chain += last_decimation_block
# dsd modes # dsd modes
if which in ["dstar", "nxdn"]: if which in ["dstar", "nxdn"]:
chain += "csdr limit_ff | csdr convert_f_s16 | " chain += ["csdr limit_ff", "csdr convert_f_s16"]
if which == "dstar": if which == "dstar":
chain += "dsd -fd" chain += ["dsd -fd -i - -o - -u {unvoiced_quality} -g -1 "]
elif which == "nxdn": elif which == "nxdn":
chain += "dsd -fi" chain += ["dsd -fi -i - -o - -u {unvoiced_quality} -g -1 "]
chain += " -i - -o - -u {unvoiced_quality} -g -1 | CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f | " chain += ["CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f"]
max_gain = 5 max_gain = 5
# digiham modes # digiham modes
else: else:
chain += "rrc_filter | gfsk_demodulator | " chain += ["rrc_filter", "gfsk_demodulator"]
if which == "dmr": 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": 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 max_gain = 0.0005
chain += "digitalvoice_filter -f | " chain += [
chain += "CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain} | ".format(max_gain=max_gain) "digitalvoice_filter -f",
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 - " "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": elif which == "packet":
chain += "csdr fmdemod_quadri_cf | " chain += ["csdr fmdemod_quadri_cf"]
chain += last_decimation_block chain += last_decimation_block
chain += "csdr convert_f_s16 | " chain += [
chain += "direwolf -r {audio_rate} - 1>&2" "csdr convert_f_s16",
"direwolf -r {audio_rate} - 1>&2"
]
elif which == "am": elif which == "am":
chain += "csdr amdemod_cf | csdr fastdcblock_ff | " chain += ["csdr amdemod_cf", "csdr fastdcblock_ff"]
chain += last_decimation_block 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": elif which == "ssb":
chain += "csdr realpart_cf | " chain += ["csdr realpart_cf"]
chain += last_decimation_block 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": if self.audio_compression == "adpcm":
chain += " | csdr encode_ima_adpcm_i16_u8" chain += ["csdr encode_ima_adpcm_i16_u8"]
return chain return chain
def secondary_chain(self, which): def secondary_chain(self, which):
secondary_chain_base = "cat {input_pipe} | " secondary_chain_base = "cat {input_pipe} | "
if which == "fft": 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": elif which == "bpsk31":
return secondary_chain_base + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | " + \ return (
"csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | " + \ secondary_chain_base
"csdr simple_agc_cc 0.001 0.5 | " + \ + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | "
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \ + "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | "
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \ + "csdr simple_agc_cc 0.001 0.5 | "
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8" + "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): def set_secondary_demodulator(self, what):
if self.get_secondary_demodulator() == what: if self.get_secondary_demodulator() == what:
return return
self.secondary_demodulator = what self.secondary_demodulator = what
self.calculate_decimation()
self.restart() self.restart()
def secondary_fft_block_size(self): 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): def secondary_decimation(self):
return 1 # currently unused return 1 # currently unused
@ -183,18 +266,12 @@ class dsp(object):
return 31.25 return 31.25
def start_secondary_demodulator(self): def start_secondary_demodulator(self):
if not self.secondary_demodulator: return if not self.secondary_demodulator:
logger.debug("[openwebrx] starting secondary demodulator from IF input sampled at %d"%self.if_samp_rate()) return
secondary_command_fft=self.secondary_chain("fft") logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
secondary_command_demod = self.secondary_chain(self.secondary_demodulator) secondary_command_demod = self.secondary_chain(self.secondary_demodulator)
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft) self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
secondary_command_fft=secondary_command_fft.format(
input_pipe=self.iqtee_pipe,
secondary_fft_input_size=self.secondary_fft_size,
secondary_fft_size=self.secondary_fft_size,
secondary_fft_block_size=self.secondary_fft_block_size(),
)
secondary_command_demod = secondary_command_demod.format( secondary_command_demod = secondary_command_demod.format(
input_pipe=self.iqtee2_pipe, input_pipe=self.iqtee2_pipe,
secondary_shift_pipe=self.secondary_shift_pipe, secondary_shift_pipe=self.secondary_shift_pipe,
@ -202,22 +279,54 @@ class dsp(object):
secondary_samples_per_bits=self.secondary_samples_per_bits(), secondary_samples_per_bits=self.secondary_samples_per_bits(),
secondary_bpf_cutoff=self.secondary_bpf_cutoff(), secondary_bpf_cutoff=self.secondary_bpf_cutoff(),
secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(),
if_samp_rate=self.if_samp_rate() if_samp_rate=self.if_samp_rate(),
last_decimation=self.last_decimation,
) )
logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (fft) = %s", secondary_command_fft) logger.debug("secondary command (demod) = %s", secondary_command_demod)
logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (demod) = %s", secondary_command_demod)
my_env = os.environ.copy() my_env = os.environ.copy()
# if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; # 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.csdr_print_bufsizes:
self.secondary_process_fft = subprocess.Popen(secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) my_env["CSDR_PRINT_BUFSIZES"] = "1"
logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (fft)") if self.output.supports_type("secondary_fft"):
self.secondary_process_demod = subprocess.Popen(secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) #TODO digimodes secondary_command_fft = self.secondary_chain("fft")
logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)") #TODO digimodes secondary_command_fft = secondary_command_fft.format(
input_pipe=self.iqtee_pipe,
secondary_fft_input_size=self.secondary_fft_size,
secondary_fft_size=self.secondary_fft_size,
secondary_fft_block_size=self.secondary_fft_block_size(),
)
logger.debug("secondary command (fft) = %s", secondary_command_fft)
self.secondary_process_fft = subprocess.Popen(
secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env
)
self.output.send_output(
"secondary_fft",
partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())),
)
self.secondary_process_demod = subprocess.Popen(
secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env
)
self.secondary_processes_running = True self.secondary_processes_running = True
self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read()))) if self.isWsjtMode():
self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) 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 # open control pipes for csdr and send initialization data
if self.secondary_shift_pipe != None: # TODO digimodes if self.secondary_shift_pipe != None: # TODO digimodes
@ -226,12 +335,13 @@ class dsp(object):
def set_secondary_offset_freq(self, value): def set_secondary_offset_freq(self, value):
self.secondary_offset_freq = value self.secondary_offset_freq = value
if self.secondary_processes_running: if self.secondary_processes_running and hasattr(self, "secondary_shift_pipe_file"):
self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate())) self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate()))
self.secondary_shift_pipe_file.flush() self.secondary_shift_pipe_file.flush()
def stop_secondary_demodulator(self): 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) self.try_delete_pipes(self.secondary_pipe_names)
if self.secondary_process_fft: if self.secondary_process_fft:
try: try:
@ -261,17 +371,22 @@ class dsp(object):
self.fft_compression = what self.fft_compression = what
def get_fft_bytes_to_read(self): def get_fft_bytes_to_read(self):
if self.fft_compression=="none": return self.fft_size*4 if self.fft_compression == "none":
if self.fft_compression=="adpcm": return (self.fft_size/2)+(10/2) 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): def get_secondary_fft_bytes_to_read(self):
if self.fft_compression=="none": return self.secondary_fft_size*4 if self.fft_compression == "none":
if self.fft_compression=="adpcm": return (self.secondary_fft_size/2)+(10/2) 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): def set_samp_rate(self, samp_rate):
self.samp_rate = samp_rate self.samp_rate = samp_rate
self.calculate_decimation() self.calculate_decimation()
if self.running: self.restart() if self.running:
self.restart()
def calculate_decimation(self): def calculate_decimation(self):
(self.decimation, self.last_decimation, _) = self.get_decimation(self.samp_rate, self.get_audio_rate()) (self.decimation, self.last_decimation, _) = self.get_decimation(self.samp_rate, self.get_audio_rate())
@ -296,6 +411,8 @@ class dsp(object):
def get_audio_rate(self): def get_audio_rate(self):
if self.isDigitalVoice() or self.isPacket(): if self.isDigitalVoice() or self.isPacket():
return 48000 return 48000
elif self.isWsjtMode():
return 12000
return self.get_output_rate() return self.get_output_rate()
def isDigitalVoice(self, demodulator=None): def isDigitalVoice(self, demodulator=None):
@ -303,6 +420,11 @@ class dsp(object):
demodulator = self.get_demodulator() demodulator = self.get_demodulator()
return demodulator in ["dmr", "dstar", "nxdn", "ysf"] 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): def isPacket(self, demodulator = None):
if demodulator is None: if demodulator is None:
demodulator = self.get_demodulator() demodulator = self.get_demodulator()
@ -313,7 +435,8 @@ class dsp(object):
self.calculate_decimation() self.calculate_decimation()
def set_demodulator(self, demodulator): def set_demodulator(self, demodulator):
if (self.demodulator == demodulator): return if self.demodulator == demodulator:
return
self.demodulator = demodulator self.demodulator = demodulator
self.calculate_decimation() self.calculate_decimation()
self.restart() self.restart()
@ -334,8 +457,10 @@ class dsp(object):
self.restart() self.restart()
def fft_block_size(self): def fft_block_size(self):
if self.fft_averages == 0: return self.samp_rate/self.fft_fps if self.fft_averages == 0:
else: return self.samp_rate/self.fft_fps/self.fft_averages 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): def set_offset_freq(self, offset_freq):
self.offset_freq = offset_freq self.offset_freq = offset_freq
@ -350,7 +475,9 @@ class dsp(object):
self.high_cut = high_cut self.high_cut = high_cut
if self.running: if self.running:
self.modification_lock.acquire() 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.bpf_pipe_file.flush()
self.modification_lock.release() self.modification_lock.release()
@ -401,50 +528,80 @@ class dsp(object):
for pipe_name in 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: 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: except Exception:
logger.exception("try_delete_pipes()") logger.exception("try_delete_pipes()")
def start(self): def start(self):
self.modification_lock.acquire() self.modification_lock.acquire()
if (self.running): if self.running:
self.modification_lock.release() self.modification_lock.release()
return return
self.running = True self.running = True
command_base=self.chain(self.demodulator) command_base = " | ".join(self.chain(self.demodulator))
# create control pipes for csdr # create control pipes for csdr
self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self)) self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self))
self.try_create_pipes(self.pipe_names, command_base) self.try_create_pipes(self.pipe_names, command_base)
# run the command # run the command
command=command_base.format( bpf_pipe=self.bpf_pipe, shift_pipe=self.shift_pipe, decimation=self.decimation, command = command_base.format(
last_decimation=self.last_decimation, fft_size=self.fft_size, fft_block_size=self.fft_block_size(), fft_averages=self.fft_averages, bpf_pipe=self.bpf_pipe,
bpf_transition_bw=float(self.bpf_transition_bw)/self.if_samp_rate(), ddc_transition_bw=self.ddc_transition_bw(), shift_pipe=self.shift_pipe,
flowcontrol=int(self.samp_rate*2), start_bufsize=self.base_bufsize*self.decimation, nc_port=self.nc_port, decimation=self.decimation,
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, last_decimation=self.last_decimation,
output_rate = self.get_output_rate(), smeter_report_every = int(self.if_samp_rate()/6000), fft_size=self.fft_size,
unvoiced_quality = self.get_unvoiced_quality(), audio_rate = self.get_audio_rate(), fft_block_size=self.fft_block_size(),
dmr_control_pipe = self.dmr_control_pipe) 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) logger.debug("Command = %s", command)
my_env = os.environ.copy() my_env = os.environ.copy()
if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1"; if self.csdr_dynamic_bufsize:
if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1"; my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1"
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) 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(): def watch_thread():
rc = self.process.wait() rc = self.process.wait()
logger.debug("dsp thread ended with rc=%d", rc) 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") logger.debug("restarting since rc = 0, self.running = true, and no modification")
self.restart() 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 # open control pipes for csdr
if self.bpf_pipe: if self.bpf_pipe:
@ -466,23 +623,27 @@ class dsp(object):
self.set_bpf(self.low_cut, self.high_cut) self.set_bpf(self.low_cut, self.high_cut)
if self.smeter_pipe: 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(): def read_smeter():
raw = self.smeter_pipe_file.readline() raw = self.smeter_pipe_file.readline()
if len(raw) == 0: if len(raw) == 0:
return None return None
else: else:
return float(raw.rstrip("\n")) return float(raw.rstrip("\n"))
self.output.add_output("smeter", read_smeter)
self.output.send_output("smeter", read_smeter)
if self.meta_pipe != None: if self.meta_pipe != None:
# TODO make digiham output unicode and then change this here # 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(): def read_meta():
raw = self.meta_pipe_file.readline() raw = self.meta_pipe_file.readline()
if len(raw) == 0: if len(raw) == 0:
return None return None
else: else:
return raw.rstrip("\n") return raw.rstrip("\n")
self.output.add_output("meta", read_meta)
self.output.send_output("meta", read_meta)
if self.dmr_control_pipe: if self.dmr_control_pipe:
self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w") self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w")
@ -503,10 +664,11 @@ class dsp(object):
self.modification_lock.release() self.modification_lock.release()
def restart(self): def restart(self):
if not self.running: return if not self.running:
return
self.stop() self.stop()
self.start() self.start()
def __del__(self): def __del__(self):
self.stop() self.stop()
del(self.process) del self.process

View File

@ -14,8 +14,8 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="libusb fftw" STATIC_PACKAGES="libusb fftw udev"
BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev fftw-dev" BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev fftw-dev"
apk add --no-cache $STATIC_PACKAGES apk add --no-cache $STATIC_PACKAGES
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES

View File

@ -14,8 +14,8 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="libusb" STATIC_PACKAGES="libusb udev"
BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev" BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev"
apk add --no-cache $STATIC_PACKAGES apk add --no-cache $STATIC_PACKAGES
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES apk add --no-cache --virtual .build-deps $BUILD_PACKAGES

View File

@ -14,8 +14,10 @@ function cmakebuild() {
cd /tmp 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 apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
git clone https://github.com/pothosware/SoapySDR git clone https://github.com/pothosware/SoapySDR

View File

@ -14,8 +14,8 @@ function cmakebuild() {
cd /tmp cd /tmp
STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack" 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" 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 $STATIC_PACKAGES
apk add --no-cache --virtual .build-deps $BUILD_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 git clone https://git.code.sf.net/p/itpp/git itpp
cmakebuild itpp cmakebuild itpp
git clone https://github.com/simonyiszk/csdr.git git clone https://github.com/jketterl/csdr.git -b 48khz_filter
cd csdr cd csdr
patch -Np1 <<'EOF' patch -Np1 <<'EOF'
--- a/csdr.c --- a/csdr.c
@ -68,6 +68,10 @@ rm -rf csdr
git clone https://github.com/szechyjs/mbelib.git git clone https://github.com/szechyjs/mbelib.git
cmakebuild mbelib 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 git clone https://github.com/jketterl/digiham.git
cmakebuild digiham cmakebuild digiham
@ -75,4 +79,10 @@ cmakebuild digiham
git clone https://github.com/f4exb/dsd.git git clone https://github.com/f4exb/dsd.git
cmakebuild dsd 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 apk del .build-deps

12
htdocs/css/features.css Normal file
View File

@ -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;
}

57
htdocs/css/map.css Normal file
View File

@ -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;
}

View File

@ -0,0 +1,8 @@
html, body
{
margin: 0;
padding: 0;
height: 100%;
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
}

View File

@ -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;
}

View File

@ -18,13 +18,10 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
@import url("openwebrx-header.css");
@import url("openwebrx-globals.css");
html, body html, body {
{
margin: 0;
padding: 0;
height: 100%;
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
overflow: hidden; overflow: hidden;
} }
@ -147,182 +144,16 @@ input[type=range]:focus::-ms-fill-upper
background: #B6B6B6; 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 #webrx-page-container
{ {
min-height:100%; min-height:100%;
position:relative; 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 #openwebrx-scale-container
{ {
height: 47px; height: 47px;
background-image: url("gfx/openwebrx-scale-background.png"); background-image: url("../gfx/openwebrx-scale-background.png");
background-repeat: repeat-x; background-repeat: repeat-x;
overflow: hidden; overflow: hidden;
z-index:1000; z-index:1000;
@ -331,14 +162,14 @@ input[type=range]:focus::-ms-fill-upper
#webrx-canvas-container #webrx-canvas-container
{ {
/*background-image:url('gfx/openwebrx-blank-background-1.jpg');*/ /*background-image:url('../gfx/openwebrx-blank-background-1.jpg');*/
position: relative; position: relative;
height: 2000px; height: 2000px;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
/*background-color: #646464;*/ /*background-color: #646464;*/
/*background-image: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(0,0,0,1) 100%);*/ /*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-repeat: no-repeat;
background-color: #1e5f7f; background-color: #1e5f7f;
cursor: crosshair; cursor: crosshair;
@ -428,15 +259,15 @@ input[type=range]:focus::-ms-fill-upper
/* removed non-free fonts like that: */ /* removed non-free fonts like that: */
/*@font-face { /*@font-face {
font-family: 'unibody_8_pro_regregular'; font-family: 'unibody_8_pro_regregular';
src: url('gfx/unibody8pro-regular-webfont.eot'); src: url('../gfx/unibody8pro-regular-webfont.eot');
src: url('gfx/unibody8pro-regular-webfont.ttf'); src: url('../gfx/unibody8pro-regular-webfont.ttf');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
}*/ }*/
@font-face { @font-face {
font-family: 'expletus-sans-medium'; 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-weight: normal;
font-style: normal; font-style: normal;
} }
@ -533,6 +364,20 @@ input[type=range]:focus::-ms-fill-upper
text-align: center; 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 .openwebrx-square-button img
{ {
height: 27px; height: 27px;
@ -637,47 +482,6 @@ img.openwebrx-mirror-img
height: 20px; 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 #openwebrx-panel-receiver
{ {
width:110px; width:110px;
@ -812,7 +616,7 @@ img.openwebrx-mirror-img
#openwebrx-secondary-demod-listbox #openwebrx-secondary-demod-listbox
{ {
width: 201px; width: 174px;
height: 27px; height: 27px;
padding-left:3px; padding-left:3px;
} }
@ -951,7 +755,7 @@ img.openwebrx-mirror-img
.openwebrx-meta-slot.muted:before { .openwebrx-meta-slot.muted:before {
display: block; display: block;
content: ""; content: "";
background-image: url("gfx/openwebrx-mute.png"); background-image: url("../gfx/openwebrx-mute.png");
width:100%; width:100%;
height:133px; height:133px;
background-position: center; background-position: center;
@ -993,11 +797,11 @@ img.openwebrx-mirror-img
} }
.openwebrx-meta-slot.active .openwebrx-meta-user-image { .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 { .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 * { .openwebrx-dmr-timeslot-panel * {
@ -1005,7 +809,7 @@ img.openwebrx-mirror-img
} }
.openwebrx-maps-pin { .openwebrx-maps-pin {
background-image: url("gfx/google_maps_pin.svg"); background-image: url("../gfx/google_maps_pin.svg");
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
width: 15px; width: 15px;
@ -1013,3 +817,62 @@ img.openwebrx-mirror-img
background-size: contain; background-size: contain;
display: inline-block; 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;
}

21
htdocs/features.html Normal file
View File

@ -0,0 +1,21 @@
<HTML><HEAD>
<TITLE>OpenWebRX Feature report</TITLE>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link rel="stylesheet" href="static/css/features.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.0/showdown.min.js"></script>
<script src="static/lib/jquery-3.2.1.min.js"></script>
<script src="static/features.js"></script>
</HEAD><BODY>
${header}
<div class="container">
<h1>OpenWebRX Feature Report</h1>
<table class="features table">
<tr>
<th>Feature</th>
<th>Requirement</th>
<th>Description</th>
<th>Available</th>
</tr>
</table>
</div>
</BODY></HTML>

24
htdocs/features.js Normal file
View File

@ -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 '<tr>' +
'<td></td>' +
'<td>' + name + '</td>' +
'<td>' + converter.makeHtml(r.description) + '</td>' +
'<td>' + (r.available ? 'YES' : 'NO') + '</td>' +
'</tr>';
});
$table.append(
'<tr>' +
'<td colspan=2>' + name + '</td>' +
'<td>' + converter.makeHtml(details.description) + '</td>' +
'<td>' + (details.available ? 'YES' : 'NO') + '</td>' +
'</tr>' +
requirements.join("")
);
})
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -1,85 +0,0 @@
<html>
<!--
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
html, body
{
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
width: 100%;
text-align: center;
margin: 0;
padding: 0;
}
img.logo
{
margin-top: 120px;
}
div.frame
{
text-align: left;
margin:0px auto;
width: 800px;
}
div.panel
{
text-align: center;
background-color:#777777;
border-radius: 15px;
padding: 12px;
font-weight: bold;
color: White;
font-size: 13pt;
/*text-shadow: 1px 1px 4px #444;*/
font-family: sans;
}
div.alt
{
font-size: 10pt;
padding-top: 10px;
}
body div a
{
color: #5ca8ff;
text-shadow: none;
}
span.browser
{
}
</style>
</head>
<body>
<div class="frame">
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
<div class="panel">
Sorry, the receiver is inactive due to internal error.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,30 @@
<div id="webrx-top-container">
<div id="webrx-top-photo-clip">
<img src="static/gfx/openwebrx-top-photo.jpg" id="webrx-top-photo"/>
<div id="webrx-top-bar" class="webrx-top-bar-parts">
<a href="https://sdr.hu/openwebrx" target="_blank"><img src="static/gfx/openwebrx-top-logo.png" id="webrx-top-logo" /></a>
<a href="http://ha5kfu.sch.bme.hu/" target="_blank"><img src="static/gfx/openwebrx-ha5kfu-top-logo.png" id="webrx-ha5kfu-top-logo" /></a>
<div id="webrx-rx-avatar-background">
<img id="webrx-rx-avatar" class="openwebrx-photo-trigger" src="static/gfx/openwebrx-avatar.png"/>
</div>
<div id="webrx-rx-texts">
<div id="webrx-rx-title" class="openwebrx-photo-trigger"></div>
<div id="webrx-rx-desc" class="openwebrx-photo-trigger"></div>
</div>
<div id="openwebrx-rx-details-arrow">
<a id="openwebrx-rx-details-arrow-up" class="openwebrx-photo-trigger"><img src="static/gfx/openwebrx-rx-details-arrow-up.png" /></a>
<a id="openwebrx-rx-details-arrow-down" class="openwebrx-photo-trigger"><img src="static/gfx/openwebrx-rx-details-arrow.png" /></a>
</div>
<section id="openwebrx-main-buttons">
<ul>
<li data-toggle-panel="openwebrx-panel-status"><img src="static/gfx/openwebrx-panel-status.png" /><br/>Status</li>
<li data-toggle-panel="openwebrx-panel-log"><img src="static/gfx/openwebrx-panel-log.png" /><br/>Log</li>
<li data-toggle-panel="openwebrx-panel-receiver"><img src="static/gfx/openwebrx-panel-receiver.png" /><br/>Receiver</li>
<li><a href="/map" target="_blank"><img src="static/gfx/openwebrx-panel-map.png" /><br/>Map</a></li>
</ul>
</section>
</div>
<div id="webrx-rx-photo-title"></div>
<div id="webrx-rx-photo-desc"></div>
</div>
</div>

View File

@ -25,43 +25,15 @@
<script src="static/sdr.js"></script> <script src="static/sdr.js"></script>
<script src="static/mathbox-bundle.min.js"></script> <script src="static/mathbox-bundle.min.js"></script>
<script src="static/openwebrx.js"></script> <script src="static/openwebrx.js"></script>
<script src="static/jquery-3.2.1.min.js"></script> <script src="static/lib/jquery-3.2.1.min.js"></script>
<script src="static/jquery.nanoscroller.js"></script> <script src="static/lib/jquery.nanoscroller.js"></script>
<link rel="stylesheet" type="text/css" href="static/nanoscroller.css" /> <link rel="stylesheet" type="text/css" href="static/lib/nanoscroller.css" />
<link rel="stylesheet" type="text/css" href="static/openwebrx.css" /> <link rel="stylesheet" type="text/css" href="static/css/openwebrx.css" />
<meta charset="utf-8"> <meta charset="utf-8">
</head> </head>
<body onload="openwebrx_init();"> <body onload="openwebrx_init();">
<div id="webrx-page-container"> <div id="webrx-page-container">
<div id="webrx-top-container"> ${header}
<div id="webrx-top-photo-clip">
<img src="static/gfx/openwebrx-top-photo.jpg" id="webrx-top-photo"/>
<div id="webrx-top-bar" class="webrx-top-bar-parts">
<a href="https://sdr.hu/openwebrx" target="_blank"><img src="static/gfx/openwebrx-top-logo.png" id="webrx-top-logo" /></a>
<a href="http://ha5kfu.sch.bme.hu/" target="_blank"><img src="static/gfx/openwebrx-ha5kfu-top-logo.png" id="webrx-ha5kfu-top-logo" /></a>
<div id="webrx-rx-avatar-background">
<img id="webrx-rx-avatar" src="static/gfx/openwebrx-avatar.png" onclick="toggle_rx_photo();"/>
</div>
<div id="webrx-rx-texts">
<div id="webrx-rx-title" onclick="toggle_rx_photo();"></div>
<div id="webrx-rx-desc" onclick="toggle_rx_photo();"></div>
</div>
<div id="openwebrx-rx-details-arrow">
<a id="openwebrx-rx-details-arrow-up" onclick="toggle_rx_photo();"><img src="static/gfx/openwebrx-rx-details-arrow-up.png" /></a>
<a id="openwebrx-rx-details-arrow-down" onclick="toggle_rx_photo();"><img src="static/gfx/openwebrx-rx-details-arrow.png" /></a>
</div>
<section id="openwebrx-main-buttons">
<ul>
<li onmouseup="toggle_panel('openwebrx-panel-status');"><img src="static/gfx/openwebrx-panel-status.png" /><br/>Status</li>
<li onmouseup="toggle_panel('openwebrx-panel-log');"><img src="static/gfx/openwebrx-panel-log.png" /><br/>Log</li>
<li onmouseup="toggle_panel('openwebrx-panel-receiver');"><img src="static/gfx/openwebrx-panel-receiver.png" /><br/>Receiver</li>
</ul>
</section>
</div>
<div id="webrx-rx-photo-title"></div>
<div id="webrx-rx-photo-desc"></div>
</div>
</div>
<div id="webrx-main-container"> <div id="webrx-main-container">
<div id="openwebrx-scale-container"> <div id="openwebrx-scale-container">
<canvas id="openwebrx-scale-canvas" width="0" height="0"></canvas> <canvas id="openwebrx-scale-canvas" width="0" height="0"></canvas>
@ -111,7 +83,19 @@
<select id="openwebrx-secondary-demod-listbox" onchange="secondary_demod_listbox_changed();"> <select id="openwebrx-secondary-demod-listbox" onchange="secondary_demod_listbox_changed();">
<option value="none"></option> <option value="none"></option>
<option value="bpsk31">BPSK31</option> <option value="bpsk31">BPSK31</option>
<option value="ft8" data-feature="wsjt-x">FT8</option>
<option value="wspr" data-feature="wsjt-x">WSPR</option>
<option value="jt65" data-feature="wsjt-x">JT65</option>
<option value="jt9" data-feature="wsjt-x">JT9</option>
<option value="ft4" data-feature="wsjt-x">FT4</option>
</select> </select>
<div id="openwebrx-secondary-demod-dial-button" class="openwebrx-button openwebrx-dial-button" onclick="dial_button_click();">
<svg version="1.1" id="Layer_1" x="0px" y="0px" width="246px" height="246px" viewBox="0 0 246 246" xmlns="http://www.w3.org/2000/svg">
<g id="ph_dial_1_" transform="matrix(1, 0, 0, 1, -45.398312, -50.931698)">
<path id="ph_dial" d="M238.875,190.125c3.853,7.148,34.267,4.219,50.242,2.145c0.891-5.977,1.508-12.043,1.508-18.27 c0-67.723-54.901-122.625-122.625-122.625c-67.723,0-122.625,54.902-122.625,122.625c0,67.723,54.902,122.625,122.625,122.625 c51.06,0,94.797-31.227,113.25-75.609c-13.969-9.668-41.625-18.891-41.625-18.891c-5.25,0-10.5-3-12.75-8.25 S233.625,180.375,238.875,190.125z M220.465,175.313c0,28.478-23.086,51.563-51.563,51.563c-28.478,0-51.563-23.086-51.563-51.563 c0-28.477,23.086-51.563,51.563-51.563C197.379,123.75,220.465,146.836,220.465,175.313z M185.25,64.125 c10.563,0,19.125,8.563,19.125,19.125s-8.563,19.125-19.125,19.125c-10.562,0-19.125-8.563-19.125-19.125 S174.688,64.125,185.25,64.125z M142.875,69C153.438,69,162,77.563,162,88.125s-8.563,19.125-19.125,19.125 c-10.562,0-19.125-8.563-19.125-19.125S132.313,69,142.875,69z M106.5,91.875c10.563,0,19.125,8.563,19.125,19.125 s-8.563,19.125-19.125,19.125c-10.562,0-19.125-8.562-19.125-19.125S95.938,91.875,106.5,91.875z M81.375,126.75 c10.563,0,19.125,8.563,19.125,19.125S91.938,165,81.375,165c-10.563,0-19.125-8.563-19.125-19.125S70.813,126.75,81.375,126.75z M58.125,188.625c0-10.559,8.563-19.125,19.125-19.125c10.563,0,19.125,8.566,19.125,19.125S87.813,207.75,77.25,207.75 C66.687,207.75,58.125,199.184,58.125,188.625z M75.75,229.875c0-10.559,8.563-19.125,19.125-19.125 c10.563,0,19.125,8.566,19.125,19.125S105.438,249,94.875,249C84.312,249,75.75,240.434,75.75,229.875z M126.375,276 c-10.563,0-19.125-8.566-19.125-19.125s8.563-19.125,19.125-19.125c10.563,0,19.125,8.566,19.125,19.125S136.938,276,126.375,276z M168,288c-10.563,0-19.125-8.566-19.125-19.125S157.438,249.75,168,249.75c10.563,0,19.125,8.566,19.125,19.125 S178.563,288,168,288z M210.375,276c-10.563,0-19.125-8.566-19.125-19.125s8.563-19.125,19.125-19.125 c10.563,0,19.125,8.566,19.125,19.125S220.938,276,210.375,276z M243.375,210.75c10.563,0,19.125,8.566,19.125,19.125 S253.938,249,243.375,249c-10.563,0-19.125-8.566-19.125-19.125S232.813,210.75,243.375,210.75z"/>
</g>
</svg>
</div>
</div> </div>
<div class="openwebrx-panel-line"> <div class="openwebrx-panel-line">
<div title="Mute on/off" id="openwebrx-mute-off" class="openwebrx-button" onclick="toggleMute();"><img src="static/gfx/openwebrx-speaker.png" class="openwebrx-sliderbtn-img" id="openwebrx-mute-img"></div> <div title="Mute on/off" id="openwebrx-mute-off" class="openwebrx-button" onclick="toggleMute();"><img src="static/gfx/openwebrx-speaker.png" class="openwebrx-sliderbtn-img" id="openwebrx-mute-img"></div>
@ -160,7 +144,7 @@
<span style="font-size: 15pt; font-weight: bold;">Under construction</span> <span style="font-size: 15pt; font-weight: bold;">Under construction</span>
<br />We're working on the code right now, so the application might fail. <br />We're working on the code right now, so the application might fail.
</div> </div>
<div class="openwebrx-panel" id="openwebrx-panel-digimodes" data-panel-name="digimodes" data-panel-pos="left" data-panel-order="2" data-panel-size="619,210"> <div class="openwebrx-panel" id="openwebrx-panel-digimodes" data-panel-name="digimodes" data-panel-pos="left" data-panel-order="3" data-panel-size="619,210">
<div id="openwebrx-digimode-canvas-container"> <div id="openwebrx-digimode-canvas-container">
<div id="openwebrx-digimode-select-channel"></div> <div id="openwebrx-digimode-select-channel"></div>
</div> </div>
@ -171,6 +155,16 @@
</div> </div>
</div> </div>
</div> </div>
<table class="openwebrx-panel" id="openwebrx-panel-wsjt-message" data-panel-name="wsjt-message" data-panel-pos="left" data-panel-order="2" data-panel-size="619,200">
<thead><tr>
<th>UTC</th>
<th class="decimal">dB</th>
<th class="decimal">DT</th>
<th class="decimal freq">Freq</th>
<th class="message">Message</th>
</tr></thead>
<tbody></tbody>
</table>
<div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" data-panel-name="metadata-ysf" data-panel-pos="left" data-panel-order="2" data-panel-size="145,220"> <div class="openwebrx-panel openwebrx-meta-panel" id="openwebrx-panel-metadata-ysf" data-panel-name="metadata-ysf" data-panel-pos="left" data-panel-order="2" data-panel-size="145,220">
<div class="openwebrx-meta-frame"> <div class="openwebrx-meta-frame">
<div class="openwebrx-meta-slot"> <div class="openwebrx-meta-slot">

58
htdocs/lib/chroma.min.js vendored Normal file

File diff suppressed because one or more lines are too long

143
htdocs/lib/nite-overlay.js Normal file
View File

@ -0,0 +1,143 @@
/* Nite v1.7
* A tiny library to create a night overlay over the map
* Author: Rossen Georgiev @ https://github.com/rossengeorgiev
* Requires: GMaps API 3
*/
var nite = {
map: null,
date: null,
sun_position: null,
earth_radius_meters: 6371008,
marker_twilight_civil: null,
marker_twilight_nautical: null,
marker_twilight_astronomical: null,
marker_night: null,
init: function(map) {
if(typeof google === 'undefined'
|| typeof google.maps === 'undefined') throw "Nite Overlay: no google.maps detected";
this.map = map;
this.sun_position = this.calculatePositionOfSun();
this.marker_twilight_civil = new google.maps.Circle({
map: this.map,
center: this.getShadowPosition(),
radius: this.getShadowRadiusFromAngle(0.566666),
fillColor: "#000",
fillOpacity: 0.1,
strokeOpacity: 0,
clickable: false,
editable: false
});
this.marker_twilight_nautical = new google.maps.Circle({
map: this.map,
center: this.getShadowPosition(),
radius: this.getShadowRadiusFromAngle(6),
fillColor: "#000",
fillOpacity: 0.1,
strokeOpacity: 0,
clickable: false,
editable: false
});
this.marker_twilight_astronomical = new google.maps.Circle({
map: this.map,
center: this.getShadowPosition(),
radius: this.getShadowRadiusFromAngle(12),
fillColor: "#000",
fillOpacity: 0.1,
strokeOpacity: 0,
clickable: false,
editable: false
});
this.marker_night = new google.maps.Circle({
map: this.map,
center: this.getShadowPosition(),
radius: this.getShadowRadiusFromAngle(18),
fillColor: "#000",
fillOpacity: 0.1,
strokeOpacity: 0,
clickable: false,
editable: false
});
},
getShadowRadiusFromAngle: function(angle) {
var shadow_radius = this.earth_radius_meters * Math.PI * 0.5;
var twilight_dist = ((this.earth_radius_meters * 2 * Math.PI) / 360) * angle;
return shadow_radius - twilight_dist;
},
getSunPosition: function() {
return this.sun_position;
},
getShadowPosition: function() {
return (this.sun_position) ? new google.maps.LatLng(-this.sun_position.lat(), this.sun_position.lng() + 180) : null;
},
refresh: function() {
if(!this.isVisible()) return;
this.sun_position = this.calculatePositionOfSun(this.date);
var shadow_position = this.getShadowPosition();
this.marker_twilight_civil.setCenter(shadow_position);
this.marker_twilight_nautical.setCenter(shadow_position);
this.marker_twilight_astronomical.setCenter(shadow_position);
this.marker_night.setCenter(shadow_position);
},
jday: function(date) {
return (date.getTime() / 86400000.0) + 2440587.5;
},
calculatePositionOfSun: function(date) {
date = (date instanceof Date) ? date : new Date();
var rad = 0.017453292519943295;
// based on NOAA solar calculations
var ms_past_midnight = ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + date.getUTCMilliseconds();
var jc = (this.jday(date) - 2451545)/36525;
var mean_long_sun = (280.46646+jc*(36000.76983+jc*0.0003032)) % 360;
var mean_anom_sun = 357.52911+jc*(35999.05029-0.0001537*jc);
var sun_eq = Math.sin(rad*mean_anom_sun)*(1.914602-jc*(0.004817+0.000014*jc))+Math.sin(rad*2*mean_anom_sun)*(0.019993-0.000101*jc)+Math.sin(rad*3*mean_anom_sun)*0.000289;
var sun_true_long = mean_long_sun + sun_eq;
var sun_app_long = sun_true_long - 0.00569 - 0.00478*Math.sin(rad*125.04-1934.136*jc);
var mean_obliq_ecliptic = 23+(26+((21.448-jc*(46.815+jc*(0.00059-jc*0.001813))))/60)/60;
var obliq_corr = mean_obliq_ecliptic + 0.00256*Math.cos(rad*125.04-1934.136*jc);
var lat = Math.asin(Math.sin(rad*obliq_corr)*Math.sin(rad*sun_app_long)) / rad;
var eccent = 0.016708634-jc*(0.000042037+0.0000001267*jc);
var y = Math.tan(rad*(obliq_corr/2))*Math.tan(rad*(obliq_corr/2));
var rq_of_time = 4*((y*Math.sin(2*rad*mean_long_sun)-2*eccent*Math.sin(rad*mean_anom_sun)+4*eccent*y*Math.sin(rad*mean_anom_sun)*Math.cos(2*rad*mean_long_sun)-0.5*y*y*Math.sin(4*rad*mean_long_sun)-1.25*eccent*eccent*Math.sin(2*rad*mean_anom_sun))/rad);
var true_solar_time_in_deg = ((ms_past_midnight+rq_of_time*60000) % 86400000) / 240000;
var lng = -((true_solar_time_in_deg < 0) ? true_solar_time_in_deg + 180 : true_solar_time_in_deg - 180);
return new google.maps.LatLng(lat, lng);
},
setDate: function(date) {
this.date = date;
this.refresh();
},
setMap: function(map) {
this.map = map;
this.marker_twilight_civil.setMap(this.map);
this.marker_twilight_nautical.setMap(this.map);
this.marker_twilight_astronomical.setMap(this.map);
this.marker_night.setMap(this.map);
},
show: function() {
this.marker_twilight_civil.setVisible(true);
this.marker_twilight_nautical.setVisible(true);
this.marker_twilight_astronomical.setVisible(true);
this.marker_night.setVisible(true);
this.refresh();
},
hide: function() {
this.marker_twilight_civil.setVisible(false);
this.marker_twilight_nautical.setVisible(false);
this.marker_twilight_astronomical.setVisible(false);
this.marker_night.setVisible(false);
},
isVisible: function() {
return this.marker_night.getVisible();
}
}

24
htdocs/map.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE HTML>
<html>
<head>
<title>OpenWebRX Map</title>
<script src="static/lib/jquery-3.2.1.min.js"></script>
<script src="static/lib/chroma.min.js"></script>
<script src="static/map.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<link rel="stylesheet" type="text/css" href="static/css/map.css" />
<meta charset="utf-8">
</head>
<body>
${header}
<div class="openwebrx-map"></div>
<div class="openwebrx-map-legend">
<h3>Colors</h3>
<select id="openwebrx-map-colormode">
<option value="byband" selected="selected">By Band</option>
<option value="bymode">By Mode</option>
</select>
<div class="content"></div>
</div>
</body>
</html>

340
htdocs/map.js Normal file
View File

@ -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 '<li class="square"><span class="illustration" style="background-color:' + chroma(value).alpha(fillOpacity) + ';border-color:' + chroma(value).alpha(strokeOpacity) + ';"></span>' + key + '</li>';
});
$(".openwebrx-map-legend .content").html('<ul>' + lis.join('') + '</ul>');
}
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(
'<h3>Locator: ' + locator + '</h3>' +
'<div>Active Callsigns:</div>' +
'<ul>' +
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 '<li>' + message + '</li>'
}).join("") +
'</ul>'
);
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(
'<h3>' + callsign + '</h3>' +
'<div>' + timestring + ' using ' + marker.mode + '</div>'
);
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);
})();

View File

@ -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-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() { animate(e("webrx-rx-photo-desc"),"opacity","",1,0,1,500,30); },1500);
window.setTimeout(function() { close_rx_photo() },2500); window.setTimeout(function() { close_rx_photo() },2500);
$('#webrx-top-container .openwebrx-photo-trigger').click(toggle_rx_photo);
} }
dont_toggle_rx_photo_flag=0; dont_toggle_rx_photo_flag=0;
@ -1250,6 +1251,13 @@ function on_ws_recv(evt)
case "metadata": case "metadata":
update_metadata(json.value); update_metadata(json.value);
break; break;
case "wsjt_message":
update_wsjt_panel(json.value);
break;
case "dial_frequencies":
dial_frequencies = json.value;
update_dial_button();
break;
default: default:
console.warn('received message of unknown type: ' + json.type); 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) { function update_metadata(meta) {
if (meta.protocol) switch (meta.protocol) { if (meta.protocol) switch (meta.protocol) {
case 'DMR': case 'DMR':
@ -1356,8 +1387,8 @@ function update_metadata(meta) {
if (meta.mode && meta.mode != "") { if (meta.mode && meta.mode != "") {
mode = "Mode: " + meta.mode; mode = "Mode: " + meta.mode;
source = meta.source || ""; source = meta.source || "";
if (meta.lat && meta.lon) { if (meta.lat && meta.lon && meta.source) {
source = "<a class=\"openwebrx-maps-pin\" href=\"https://www.google.com/maps/search/?api=1&query=" + meta.lat + "," + meta.lon + "\" target=\"_blank\"></a>" + source; source = "<a class=\"openwebrx-maps-pin\" href=\"/map?callsign=" + meta.source + "\" target=\"_blank\"></a>" + source;
} }
up = meta.up ? "Up: " + meta.up : ""; up = meta.up ? "Up: " + meta.up : "";
down = meta.down ? "Down: " + meta.down : ""; down = meta.down ? "Down: " + meta.down : "";
@ -1377,6 +1408,56 @@ function update_metadata(meta) {
} }
function html_escape(input) {
return $('<div/>').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]) + '<a href="/map?locator=' + matches[2] + '" target="_blank">' + matches[2] + '</a>';
} 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]) + '<a href="/map?locator=' + matches[2] + '" target="_blank">' + matches[2] + '</a>' + html_escape(matches[3]);
} else {
linkedmsg = html_escape(linkedmsg);
}
}
$b.append($(
'<tr data-timestamp="' + msg['timestamp'] + '">' +
'<td>' + pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()) + '</td>' +
'<td class="decimal">' + msg['db'] + '</td>' +
'<td class="decimal">' + msg['dt'] + '</td>' +
'<td class="decimal freq">' + msg['freq'] + '</td>' +
'<td class="message">' + linkedmsg + '</td>' +
'</tr>'
));
$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() { function hide_digitalvoice_panels() {
$(".openwebrx-meta-panel").each(function(_, p){ $(".openwebrx-meta-panel").each(function(_, p){
toggle_panel(p.id, false); toggle_panel(p.id, false);
@ -1436,8 +1517,9 @@ function waterfall_dequeue()
function on_ws_opened() 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); divlog("WebSocket opened to "+ws_url);
reconnect_timeout = false;
} }
var was_error=0; var was_error=0;
@ -1818,6 +1900,8 @@ function audio_init()
} }
var reconnect_timeout = false;
function on_ws_closed() function on_ws_closed()
{ {
try try
@ -1826,9 +1910,16 @@ function on_ws_closed()
} }
catch (dont_care) {} catch (dont_care) {}
audio_initialized = 0; 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) function on_ws_error(event)
@ -2332,6 +2423,13 @@ function openwebrx_resize()
check_top_bar_congestion(); 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() function openwebrx_init()
{ {
if(ios||is_chrome) e("openwebrx-big-grey").style.display="table-cell"; 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.setTimeout(function(){window.setInterval(debug_audio,1000);},1000);
window.addEventListener("resize",openwebrx_resize); window.addEventListener("resize",openwebrx_resize);
check_top_bar_congestion(); check_top_bar_congestion();
init_header();
//Synchronise volume with slider //Synchronise volume with slider
updateVolume(); updateVolume();
@ -2351,7 +2450,9 @@ function openwebrx_init()
} }
function digimodes_init() { function digimodes_init() {
hide_digitalvoice_panels(); $(".openwebrx-meta-panel").each(function(_, p){
p.openwebrxHidden = true;
});
// initialze DMR timeslot muting // initialze DMR timeslot muting
$('.openwebrx-dmr-timeslot-panel').click(function(e) { $('.openwebrx-dmr-timeslot-panel').click(function(e) {
@ -2638,12 +2739,19 @@ function demodulator_digital_replace(subtype)
{ {
case "bpsk31": case "bpsk31":
case "rtty": case "rtty":
case "ft8":
case "wspr":
case "jt65":
case "jt9":
case "ft4":
secondary_demod_start(subtype); secondary_demod_start(subtype);
demodulator_analog_replace('usb', true); demodulator_analog_replace('usb', true);
demodulator_buttons_update(); demodulator_buttons_update();
break; break;
} }
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
toggle_panel("openwebrx-panel-digimodes", true); 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() function secondary_demod_create_canvas()
@ -2698,6 +2806,7 @@ function secondary_demod_swap_canvases()
function secondary_demod_init() function secondary_demod_init()
{ {
$("#openwebrx-panel-digimodes")[0].openwebrxHidden = true; $("#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 = $("#openwebrx-digimode-canvas-container")[0];
$(secondary_demod_canvas_container) $(secondary_demod_canvas_container)
.mousemove(secondary_demod_canvas_container_mousemove) .mousemove(secondary_demod_canvas_container_mousemove)
@ -2705,6 +2814,7 @@ function secondary_demod_init()
.mousedown(secondary_demod_canvas_container_mousedown) .mousedown(secondary_demod_canvas_container_mousedown)
.mouseenter(secondary_demod_canvas_container_mousein) .mouseenter(secondary_demod_canvas_container_mousein)
.mouseleave(secondary_demod_canvas_container_mouseout); .mouseleave(secondary_demod_canvas_container_mouseout);
init_wsjt_removal_timer();
} }
function secondary_demod_start(subtype) function secondary_demod_start(subtype)
@ -2762,6 +2872,7 @@ function secondary_demod_close_window()
{ {
secondary_demod_stop(); secondary_demod_stop();
toggle_panel("openwebrx-panel-digimodes", false); toggle_panel("openwebrx-panel-digimodes", false);
toggle_panel("openwebrx-panel-wsjt-message", false);
} }
secondary_demod_fft_offset_db=30; //need to calculate that later secondary_demod_fft_offset_db=30; //need to calculate that later
@ -2803,18 +2914,22 @@ secondary_demod_listbox_updating = false;
function secondary_demod_listbox_changed() function secondary_demod_listbox_changed()
{ {
if (secondary_demod_listbox_updating) return; if (secondary_demod_listbox_updating) return;
switch ($("#openwebrx-secondary-demod-listbox")[0].value) var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
{ switch (sdm) {
case "none": case "none":
demodulator_analog_replace_last(); demodulator_analog_replace_last();
break; break;
case "bpsk31": case "bpsk31":
demodulator_digital_replace('bpsk31');
break;
case "rtty": case "rtty":
demodulator_digital_replace('rtty'); case "ft8":
case "wspr":
case "jt65":
case "jt9":
case "ft4":
demodulator_digital_replace(sdm);
break; break;
} }
update_dial_button();
} }
function secondary_demod_listbox_update() function secondary_demod_listbox_update()

View File

@ -1,94 +0,0 @@
<html>
<!--
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
html, body
{
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
width: 100%;
text-align: center;
margin: 0;
padding: 0;
}
img.logo
{
margin-top: 120px;
}
div.frame
{
text-align: left;
margin:0px auto;
width: 800px;
}
div.panel
{
text-align: center;
background-color:#777777;
border-radius: 15px;
padding: 12px;
font-weight: bold;
color: White;
font-size: 13pt;
/*text-shadow: 1px 1px 4px #444;*/
font-family: sans;
}
div.alt
{
font-size: 10pt;
padding-top: 10px;
}
body div a
{
color: #5ca8ff;
text-shadow: none;
}
span.browser
{
}
</style>
<script>
var irt = function (s,n) {return s.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c>="a"?97:65)<=(c=c.charCodeAt(0)-n)?c:c+26);});}
var sendmail2 = function (s) { window.location.href="mailto:"+irt(s.replace("=",String.fromCharCode(0100)).replace("$","."),8); }
window.addEventListener("load",function(){rs=document.getElementById("reconnect-secs"); rt=document.getElementById("reconnect-text"); cnt=29;window.setInterval(function(){if(cnt<=-1) window.location.href=window.location.href.split("retry.")[0]; else if(cnt==0) {rt.innerHTML="Reconnecting..."; cnt--;} else rs.innerHTML=(cnt--).toString();},1000);},false);
</script>
</head>
<body>
<div class="frame">
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
<div class="panel">
There are no client slots left on this server.
<div class="alt">
Please wait until a client disconnects.<br /><span id="reconnect-text">We will try to reconnect in <span id="reconnect-secs">30</span> seconds...</span>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,95 +0,0 @@
<html>
<!--
This file is part of OpenWebRX,
an open-source SDR receiver software with a web UI.
Copyright (c) 2013-2015 by Andras Retzler <randras@sdr.hu>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<head><title>OpenWebRX</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
html, body
{
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
width: 100%;
text-align: center;
margin: 0;
padding: 0;
}
img.logo
{
margin-top: 120px;
}
div.frame
{
text-align: left;
margin:0px auto;
width: 800px;
}
div.panel
{
text-align: center;
background-color:#777777;
border-radius: 15px;
padding: 12px;
font-weight: bold;
color: White;
font-size: 13pt;
/*text-shadow: 1px 1px 4px #444;*/
font-family: sans;
}
div.alt
{
font-size: 10pt;
padding-top: 10px;
}
body div a
{
color: #5ca8ff;
text-shadow: none;
}
span.browser
{
}
</style>
<script>
var irt = function (s,n) {return s.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c>="a"?97:65)<=(c=c.charCodeAt(0)-n)?c:c+26);});}
var sendmail2 = function (s) { window.location.href="mailto:"+irt(s.replace("=",String.fromCharCode(0100)).replace("$","."),8); }
</script>
</head>
<body>
<div class="frame">
<img class="logo" src="gfx/openwebrx-logo-big.png" style="height: 60px;"/>
<div class="panel">
Only the latest <span class="browser">Google Chrome</span> browser is supported at the moment.<br/>
Please <a href="http://chrome.google.com/">download and install Google Chrome.</a><br />
<div class="alt">
Alternatively, you may proceed to OpenWebRX, but it's not supposed to work as expected. <br />
<a href="/?unsupported">Click here</a> if you still want to try OpenWebRX.</a>
</div>
</div>
</div>
</body>
</html>

20
openwebrx.py Normal file → Executable file
View File

@ -1,3 +1,5 @@
#!/usr/bin/env python3
from http.server import HTTPServer from http.server import HTTPServer
from owrx.http import RequestHandler from owrx.http import RequestHandler
from owrx.config import PropertyManager from owrx.config import PropertyManager
@ -5,8 +7,10 @@ from owrx.feature import FeatureDetector
from owrx.source import SdrService, ClientRegistry from owrx.source import SdrService, ClientRegistry
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from owrx.sdrhu import SdrHuUpdater from owrx.sdrhu import SdrHuUpdater
from owrx.service import ServiceManager
import logging 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")
@ -15,21 +19,25 @@ class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
def main(): def main():
print(""" print(
"""
OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package
_________________________________________________________________________________________________ _________________________________________________________________________________________________
Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu> Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
""") """
)
pm = PropertyManager.getSharedInstance().loadConfig("config_webrx") pm = PropertyManager.getSharedInstance().loadConfig("config_webrx")
featureDetector = FeatureDetector() featureDetector = FeatureDetector()
if not featureDetector.is_available("core"): if not featureDetector.is_available("core"):
print("you are missing required dependencies to run openwebrx. " print(
"please check that the following core requirements are installed:") "you are missing required dependencies to run openwebrx. "
"please check that the following core requirements are installed:"
)
print(", ".join(featureDetector.get_requirements("core"))) print(", ".join(featureDetector.get_requirements("core")))
return return
@ -40,7 +48,9 @@ Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
updater = SdrHuUpdater() updater = SdrHuUpdater()
updater.start() 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() server.serve_forever()

65
owrx/bands.py Normal file
View File

@ -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)]

View File

@ -1,4 +1,5 @@
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,7 +24,7 @@ class Property(object):
return self.value return self.value
def setValue(self, value): def setValue(self, value):
if (self.value == value): if self.value == value:
return self return self
self.value = value self.value = value
for c in self.subscribers: for c in self.subscribers:
@ -36,7 +37,8 @@ class Property(object):
def wire(self, callback): def wire(self, callback):
sub = Subscription(self, callback) sub = Subscription(self, callback)
self.subscribers.append(sub) 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 return sub
def unwire(self, sub): def unwire(self, sub):
@ -47,8 +49,10 @@ class Property(object):
pass pass
return self return self
class PropertyManager(object): class PropertyManager(object):
sharedInstance = None sharedInstance = None
@staticmethod @staticmethod
def getSharedInstance(): def getSharedInstance():
if PropertyManager.sharedInstance is None: if PropertyManager.sharedInstance is None:
@ -56,7 +60,9 @@ class PropertyManager(object):
return PropertyManager.sharedInstance return PropertyManager.sharedInstance
def collect(self, *props): 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.properties = {}
@ -67,12 +73,14 @@ class PropertyManager(object):
def add(self, name, prop): def add(self, name, prop):
self.properties[name] = prop self.properties[name] = prop
def fireCallbacks(value): def fireCallbacks(value):
for c in self.subscribers: for c in self.subscribers:
try: try:
c.call(name, value) c.call(name, value)
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
prop.wire(fireCallbacks) prop.wire(fireCallbacks)
return self return self

View File

@ -1,20 +1,59 @@
from owrx.config import PropertyManager from owrx.config import PropertyManager
from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry
from owrx.feature import FeatureDetector from owrx.feature import FeatureDetector
from owrx.version import openwebrx_version
from owrx.bands import Bandplan
import json import json
from owrx.map import Map
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OpenWebRxClient(object):
config_keys = ["waterfall_colors", "waterfall_min_level", "waterfall_max_level", class Client(object):
"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): def __init__(self, conn):
self.conn = 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.dsp = None
self.sdr = None self.sdr = None
self.configSub = None self.configSub = None
@ -26,12 +65,23 @@ class OpenWebRxClient(object):
self.setSdr() self.setSdr()
# send receiver info # send receiver info
receiver_keys = ["receiver_name", "receiver_location", "receiver_qra", "receiver_asl", "receiver_gps", receiver_keys = [
"photo_title", "photo_desc"] "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) receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys)
self.write_receiver_details(receiver_details) 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) self.write_profiles(profiles)
features = FeatureDetector().feature_availability() features = FeatureDetector().feature_availability()
@ -41,7 +91,7 @@ class OpenWebRxClient(object):
def setSdr(self, id=None): def setSdr(self, id=None):
next = SdrService.getSource(id) next = SdrService.getSource(id)
if (next == self.sdr): if next == self.sdr:
return return
self.stopDsp() self.stopDsp()
@ -53,14 +103,23 @@ class OpenWebRxClient(object):
self.sdr = next self.sdr = next
# send initial config # 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): 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 # TODO mathematical properties? hmmmm
config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"]
self.write_config(config) 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) self.configSub = configProps.wire(sendConfig)
sendConfig(None, None) sendConfig(None, None)
@ -78,8 +137,7 @@ class OpenWebRxClient(object):
if self.configSub is not None: if self.configSub is not None:
self.configSub.cancel() self.configSub.cancel()
self.configSub = None self.configSub = None
self.conn.close() super().close()
logger.debug("connection closed")
def stopDsp(self): def stopDsp(self):
if self.dsp is not None: if self.dsp is not None:
@ -90,8 +148,11 @@ class OpenWebRxClient(object):
def setParams(self, params): def setParams(self, params):
# only the keys in the protected property manager can be overridden from the web # 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()) .defaults(PropertyManager.getSharedInstance())
)
for key, value in params.items(): for key, value in params.items():
protected[key] = value protected[key] = value
@ -99,42 +160,72 @@ class OpenWebRxClient(object):
for key, value in params.items(): for key, value in params.items():
self.dsp.setProperty(key, value) 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): def write_spectrum_data(self, data):
self.protected_send(bytes([0x01]) + data) self.protected_send(bytes([0x01]) + data)
def write_dsp_data(self, data): def write_dsp_data(self, data):
self.protected_send(bytes([0x02]) + data) self.protected_send(bytes([0x02]) + data)
def write_s_meter_level(self, level): 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): 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): 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): def write_secondary_fft(self, data):
self.protected_send(bytes([0x03]) + data) self.protected_send(bytes([0x03]) + data)
def write_secondary_demod(self, data): def write_secondary_demod(self, data):
self.protected_send(bytes([0x04]) + data) self.protected_send(bytes([0x04]) + data)
def write_secondary_dsp_config(self, cfg): 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): 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): 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): def write_profiles(self, profiles):
self.protected_send({"type": "profiles", "value": profiles}) self.protected_send({"type": "profiles", "value": profiles})
def write_features(self, features): def write_features(self, features):
self.protected_send({"type": "features", "value": features}) self.protected_send({"type": "features", "value": features})
def write_metadata(self, metadata): 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): class WebSocketMessageHandler(object):
def __init__(self): def __init__(self):
self.handshake = None self.handshake = None
@ -142,12 +233,21 @@ class WebSocketMessageHandler(object):
self.dsp = None self.dsp = None
def handleTextMessage(self, conn, message): def handleTextMessage(self, conn, message):
if (message[:16] == "SERVER DE CLIENT"): if message[:16] == "SERVER DE CLIENT":
# maybe put some more info in there? nothing to store yet. meta = message[17:].split(" ")
self.handshake = "completed" 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") 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 return

View File

@ -1,19 +1,26 @@
import os import os
import mimetypes import mimetypes
import json
from datetime import datetime from datetime import datetime
from string import Template
from owrx.websocket import WebSocketConnection from owrx.websocket import WebSocketConnection
from owrx.config import PropertyManager from owrx.config import PropertyManager
from owrx.source import ClientRegistry from owrx.source import ClientRegistry
from owrx.connection import WebSocketMessageHandler from owrx.connection import WebSocketMessageHandler
from owrx.version import openwebrx_version from owrx.version import openwebrx_version
from owrx.feature import FeatureDetector
from owrx.metrics import Metrics
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Controller(object): class Controller(object):
def __init__(self, handler, matches): def __init__(self, handler, request):
self.handler = handler self.handler = handler
self.matches = matches self.request = request
def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None): def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None):
self.handler.send_response(code) self.handler.send_response(code)
if content_type is not None: if content_type is not None:
@ -23,15 +30,10 @@ class Controller(object):
if max_age is not None: if max_age is not None:
self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age)) self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age))
self.handler.end_headers() self.handler.end_headers()
if (type(content) == str): if type(content) == str:
content = content.encode() content = content.encode()
self.handler.wfile.write(content) 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): class StatusController(Controller):
def handle_request(self): def handle_request(self):
@ -47,22 +49,25 @@ class StatusController(Controller):
"asl": pm["receiver_asl"], "asl": pm["receiver_asl"],
"loc": pm["receiver_location"], "loc": pm["receiver_location"],
"sw_version": openwebrx_version, "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): class AssetsController(Controller):
def serve_file(self, file, content_type=None): def serve_file(self, file, content_type=None):
try: 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: 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: if modified <= client_modified:
self.send_response("", code=304) self.send_response("", code=304)
return return
f = open('htdocs/' + file, 'rb') f = open("htdocs/" + file, "rb")
data = f.read() data = f.read()
f.close() f.close()
@ -71,17 +76,63 @@ class AssetsController(Controller):
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: except FileNotFoundError:
self.send_response("file not found", code=404) self.send_response("file not found", code=404)
def handle_request(self): def handle_request(self):
filename = self.matches.group(1) filename = self.request.matches.group(1)
self.serve_file(filename) 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): 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): class WebSocketController(Controller):
def handle_request(self): def handle_request(self):
conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
conn.send("CLIENT DE SERVER openwebrx.py")
# enter read loop # enter read loop
conn.read_loop() conn.read_loop()

View File

@ -4,14 +4,17 @@ from functools import reduce
from operator import and_ from operator import and_
import re import re
from distutils.version import LooseVersion from distutils.version import LooseVersion
import inspect
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UnknownFeatureException(Exception): class UnknownFeatureException(Exception):
pass pass
class FeatureDetector(object): class FeatureDetector(object):
features = { features = {
"core": ["csdr", "nmux", "nc"], "core": ["csdr", "nmux", "nc"],
@ -21,12 +24,32 @@ class FeatureDetector(object):
"airspy": ["airspy_rx"], "airspy": ["airspy_rx"],
"digital_voice_digiham": ["digiham", "sox"], "digital_voice_digiham": ["digiham", "sox"],
"digital_voice_dsd": ["dsd", "sox", "digiham"], "digital_voice_dsd": ["dsd", "sox", "digiham"],
"packet": [ "direwolf" ] "wsjt-x": ["wsjtx", "sox"],
"packet": [ "direwolf" ],
} }
def feature_availability(self): def feature_availability(self):
return {name: self.is_available(name) for name in FeatureDetector.features} 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): def is_available(self, feature):
return self.has_requirements(self.get_requirements(feature)) return self.has_requirements(self.get_requirements(feature))
@ -34,38 +57,77 @@ class FeatureDetector(object):
try: try:
return FeatureDetector.features[feature] return FeatureDetector.features[feature]
except KeyError: 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): def has_requirements(self, requirements):
passed = True passed = True
for requirement in requirements: for requirement in requirements:
passed = passed and self.has_requirement(requirement)
return passed
def _get_requirement_method(self, requirement):
methodname = "has_" + requirement methodname = "has_" + requirement
if hasattr(self, methodname) and callable(getattr(self, methodname)): if hasattr(self, methodname) and callable(getattr(self, methodname)):
passed = passed and 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: else:
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
return passed return False
def get_requirement_description(self, requirement):
return inspect.getdoc(self._get_requirement_method(requirement))
def command_is_runnable(self, command): def command_is_runnable(self, command):
return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512 return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512
def has_csdr(self): 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") return self.command_is_runnable("csdr")
def has_nmux(self): 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") return self.command_is_runnable("nmux --help")
def has_nc(self): 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): 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") return self.command_is_runnable("rtl_sdr --help")
def has_rx_tools(self): 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") return self.command_is_runnable("rx_sdr --help")
def has_hackrf_transfer(self):
""" """
To use a HackRF, compile the HackRF host tools from its "stdout" branch: To use a HackRF, compile the HackRF host tools from its "stdout" branch:
```
git clone https://github.com/mossmann/hackrf/ git clone https://github.com/mossmann/hackrf/
cd hackrf cd hackrf
git fetch git fetch
@ -76,8 +138,8 @@ class FeatureDetector(object):
cmake .. -DINSTALL_UDEV_RULES=ON cmake .. -DINSTALL_UDEV_RULES=ON
make make
sudo make install sudo make install
```
""" """
def has_hackrf_transfer(self):
# TODO i don't have a hackrf, so somebody doublecheck this. # TODO i don't have a hackrf, so somebody doublecheck this.
# TODO also check if it has the stdout feature # TODO also check if it has the stdout feature
return self.command_is_runnable("hackrf_transfer --help") return self.command_is_runnable("hackrf_transfer --help")
@ -85,18 +147,19 @@ class FeatureDetector(object):
def command_exists(self, command): def command_exists(self, command):
return os.system("which {0}".format(command)) == 0 return os.system("which {0}".format(command)) == 0
def has_digiham(self):
""" """
To use DMR and YSF, the digiham package is required. You can find the package and installation instructions here: To use digital voice modes, the digiham package is required. You can find the package and installation
https://github.com/jketterl/digiham instructions [here](https://github.com/jketterl/digiham).
Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. 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. 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. As of now, we require version 0.2 of digiham.
""" """
def has_digiham(self):
required_version = LooseVersion("0.2") required_version = LooseVersion("0.2")
digiham_version_regex = re.compile('^digiham version (.*)$') digiham_version_regex = re.compile("^digiham version (.*)$")
def check_digiham_version(command): def check_digiham_version(command):
try: try:
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
@ -105,22 +168,52 @@ class FeatureDetector(object):
return version >= required_version return version >= required_version
except FileNotFoundError: except FileNotFoundError:
return False return False
return reduce(and_,
return reduce(
and_,
map( map(
check_digiham_version, check_digiham_version,
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator", [
"digitalvoice_filter"] "rrc_filter",
"ysf_decoder",
"dmr_decoder",
"mbe_synthesizer",
"gfsk_demodulator",
"digitalvoice_filter",
],
), ),
True) True,
)
def has_dsd(self): 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") return self.command_is_runnable("dsd")
def has_sox(self): 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") return self.command_is_runnable("sox")
def has_direwolf(self): def has_direwolf(self):
return self.command_is_runnable("direwolf --help") return self.command_is_runnable("direwolf --help")
def has_airspy_rx(self): 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") 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)

View File

@ -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 from http.server import BaseHTTPRequestHandler
import re import re
from urllib.parse import urlparse, parse_qs
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RequestHandler(BaseHTTPRequestHandler): class RequestHandler(BaseHTTPRequestHandler):
def __init__(self, request, client_address, server): def __init__(self, request, client_address, server):
self.router = Router() self.router = Router()
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
def do_GET(self): def do_GET(self):
self.router.route(self) self.router.route(self)
class Request(object):
def __init__(self, query=None, matches=None):
self.query = query
self.matches = matches
class Router(object): class Router(object):
mappings = [ mappings = [
{"route": "/", "controller": IndexController}, {"route": "/", "controller": IndexController},
@ -20,8 +40,13 @@ class Router(object):
{"route": "/ws/", "controller": WebSocketController}, {"route": "/ws/", "controller": WebSocketController},
{"regex": "(/favicon.ico)", "controller": AssetsController}, {"regex": "(/favicon.ico)", "controller": AssetsController},
# backwards compatibility for the sdr.hu portal # 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): def find_controller(self, path):
for m in Router.mappings: for m in Router.mappings:
if "route" in m: if "route" in m:
@ -32,11 +57,17 @@ class Router(object):
matches = regex.match(path) matches = regex.match(path)
if matches: if matches:
return (m["controller"], matches) return (m["controller"], matches)
def route(self, handler): 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: if res is not None:
(controller, matches) = res (controller, matches) = res
logger.debug("path: {0}, controller: {1}, matches: {2}".format(handler.path, controller, matches)) query = parse_qs(url.query)
controller(handler, matches).handle_request() 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: else:
handler.send_error(404, "Not Found", "The page you requested could not be found.") handler.send_error(404, "Not Found", "The page you requested could not be found.")

108
owrx/map.py Normal file
View File

@ -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}

View File

@ -4,36 +4,43 @@ import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import threading import threading
from owrx.map import Map, LatLngLocation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DmrCache(object): class DmrCache(object):
sharedInstance = None sharedInstance = None
@staticmethod @staticmethod
def getSharedInstance(): def getSharedInstance():
if DmrCache.sharedInstance is None: if DmrCache.sharedInstance is None:
DmrCache.sharedInstance = DmrCache() DmrCache.sharedInstance = DmrCache()
return DmrCache.sharedInstance return DmrCache.sharedInstance
def __init__(self): def __init__(self):
self.cache = {} self.cache = {}
self.cacheTimeout = timedelta(seconds=86400) self.cacheTimeout = timedelta(seconds=86400)
def isValid(self, key): def isValid(self, key):
if not key in self.cache: return False if not key in self.cache:
return False
entry = self.cache[key] entry = self.cache[key]
return entry["timestamp"] + self.cacheTimeout > datetime.now() return entry["timestamp"] + self.cacheTimeout > datetime.now()
def put(self, key, value): def put(self, key, value):
self.cache[key] = { self.cache[key] = {"timestamp": datetime.now(), "data": value}
"timestamp": datetime.now(),
"data": value
}
def get(self, key): def get(self, key):
if not self.isValid(key): return None if not self.isValid(key):
return None
return self.cache[key]["data"] return self.cache[key]["data"]
class DmrMetaEnricher(object): class DmrMetaEnricher(object):
def __init__(self): def __init__(self):
self.threads = {} self.threads = {}
def downloadRadioIdData(self, id): def downloadRadioIdData(self, id):
cache = DmrCache.getSharedInstance() cache = DmrCache.getSharedInstance()
try: try:
@ -44,9 +51,12 @@ class DmrMetaEnricher(object):
except json.JSONDecodeError: except json.JSONDecodeError:
cache.put(id, None) cache.put(id, None)
del self.threads[id] del self.threads[id]
def enrich(self, meta): def enrich(self, meta):
if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: return None if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]:
if not "source" in meta: return None return None
if not "source" in meta:
return None
id = meta["source"] id = meta["source"]
cache = DmrCache.getSharedInstance() cache = DmrCache.getSharedInstance()
if not cache.isValid(id): if not cache.isValid(id):
@ -60,10 +70,17 @@ class DmrMetaEnricher(object):
return None 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): class MetaParser(object):
enrichers = { enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher()}
"DMR": DmrMetaEnricher()
}
def __init__(self, handler): def __init__(self, handler):
self.handler = handler self.handler = handler
@ -76,6 +93,6 @@ class MetaParser(object):
protocol = meta["protocol"] protocol = meta["protocol"]
if protocol in MetaParser.enrichers: if protocol in MetaParser.enrichers:
additional_data = MetaParser.enrichers[protocol].enrich(meta) 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) self.handler.write_metadata(meta)

30
owrx/metrics.py Normal file
View File

@ -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

View File

@ -4,6 +4,7 @@ import time
from owrx.config import PropertyManager from owrx.config import PropertyManager
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -14,10 +15,12 @@ class SdrHuUpdater(threading.Thread):
def update(self): def update(self):
pm = PropertyManager.getSharedInstance() 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) logger.debug(cmd)
returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate() returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
returned=returned[0].decode('utf-8') returned = returned[0].decode("utf-8")
if "UPDATE:" in returned: if "UPDATE:" in returned:
retrytime_mins = 20 retrytime_mins = 20
value = returned.split("UPDATE:")[1].split("\n", 1)[0] value = returned.split("UPDATE:")[1].split("\n", 1)[0]

118
owrx/service.py Normal file
View File

@ -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

View File

@ -2,6 +2,7 @@ import subprocess
from owrx.config import PropertyManager from owrx.config import PropertyManager
from owrx.feature import FeatureDetector, UnknownFeatureException from owrx.feature import FeatureDetector, UnknownFeatureException
from owrx.meta import MetaParser from owrx.meta import MetaParser
from owrx.wsjt import WsjtParser
import threading import threading
import csdr import csdr
import time import time
@ -13,10 +14,12 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SdrService(object): class SdrService(object):
sdrProps = None sdrProps = None
sources = {} sources = {}
lastPort = None lastPort = None
@staticmethod @staticmethod
def getNextPort(): def getNextPort():
pm = PropertyManager.getSharedInstance() pm = PropertyManager.getSharedInstance()
@ -28,30 +31,45 @@ class SdrService(object):
if SdrService.lastPort > end: if SdrService.lastPort > end:
raise IndexError("no more available ports to start more sdrs") raise IndexError("no more available ports to start more sdrs")
return SdrService.lastPort return SdrService.lastPort
@staticmethod @staticmethod
def loadProps(): def loadProps():
if SdrService.sdrProps is None: if SdrService.sdrProps is None:
pm = PropertyManager.getSharedInstance() pm = PropertyManager.getSharedInstance()
featureDetector = FeatureDetector() featureDetector = FeatureDetector()
def loadIntoPropertyManager(dict: dict): def loadIntoPropertyManager(dict: dict):
propertyManager = PropertyManager() propertyManager = PropertyManager()
for (name, value) in dict.items(): for (name, value) in dict.items():
propertyManager[name] = value propertyManager[name] = value
return propertyManager return propertyManager
def sdrTypeAvailable(value): def sdrTypeAvailable(value):
try: try:
if not featureDetector.is_available(value["type"]): 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 False
return True return True
except UnknownFeatureException: 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 return False
# transform all dictionary items into PropertyManager object, filtering out unavailable ones # transform all dictionary items into PropertyManager object, filtering out unavailable ones
SdrService.sdrProps = { SdrService.sdrProps = {
name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value) 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 @staticmethod
def getSource(id=None): def getSource(id=None):
SdrService.loadProps() SdrService.loadProps()
@ -60,18 +78,23 @@ class SdrService(object):
id = list(SdrService.sdrProps.keys())[0] id = list(SdrService.sdrProps.keys())[0]
sources = SdrService.getSources() sources = SdrService.getSources()
return sources[id] return sources[id]
@staticmethod @staticmethod
def getSources(): def getSources():
SdrService.loadProps() SdrService.loadProps()
for id in SdrService.sdrProps.keys(): for id in SdrService.sdrProps.keys():
if not id in SdrService.sources: if not id in SdrService.sources:
props = SdrService.sdrProps[id] 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) cls = getattr(sys.modules[__name__], className)
SdrService.sources[id] = cls(props, SdrService.getNextPort()) SdrService.sources[id] = cls(props, SdrService.getNextPort())
return SdrService.sources return SdrService.sources
class SdrSourceException(Exception):
pass
class SdrSource(object): class SdrSource(object):
def __init__(self, props, port): def __init__(self, props, port):
self.props = props 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)) logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value))
self.stop() self.stop()
self.start() self.start()
self.rtlProps.wire(restart) self.rtlProps.wire(restart)
self.port = port self.port = port
self.monitor = None self.monitor = None
@ -101,15 +125,16 @@ class SdrSource(object):
def getFormatConversion(self): def getFormatConversion(self):
return None return None
def activateProfile(self, id = None): def activateProfile(self, profile_id=None):
profiles = self.props["profiles"] profiles = self.props["profiles"]
if id is None: if profile_id is None:
id = list(profiles.keys())[0] profile_id = list(profiles.keys())[0]
logger.debug("activating profile {0}".format(id)) logger.debug("activating profile {0}".format(profile_id))
profile = profiles[id] profile = profiles[profile_id]
for (key, value) in profile.items(): for (key, value) in profile.items():
# skip the name, that would overwrite the source name. # skip the name, that would overwrite the source name.
if key == "name": continue if key == "name":
continue
self.props[key] = value self.props[key] = value
def getProfiles(self): def getProfiles(self):
@ -133,7 +158,9 @@ class SdrSource(object):
props = self.rtlProps props = self.rtlProps
start_sdr_command = self.getCommand().format( 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() format_conversion = self.getFormatConversion()
@ -141,17 +168,27 @@ class SdrSource(object):
start_sdr_command += " | " + format_conversion start_sdr_command += " | " + format_conversion
nmux_bufcnt = nmux_bufsize = 0 nmux_bufcnt = nmux_bufsize = 0
while nmux_bufsize < props["samp_rate"]/4: nmux_bufsize += 4096 while nmux_bufsize < props["samp_rate"] / 4:
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6: nmux_bufcnt += 1 nmux_bufsize += 4096
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6:
nmux_bufcnt += 1
if nmux_bufcnt == 0 or nmux_bufsize == 0: 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() self.modificationLock.release()
return return
logger.debug("nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt)) 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) self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp)
logger.info("Started rtl source: " + cmd) logger.info("Started rtl source: " + cmd)
available = False
def wait_for_process_to_end(): def wait_for_process_to_end():
rc = self.process.wait() rc = self.process.wait()
logger.debug("shut down with RC={0}".format(rc)) logger.debug("shut down with RC={0}".format(rc))
@ -160,17 +197,25 @@ class SdrSource(object):
self.monitor = threading.Thread(target=wait_for_process_to_end) self.monitor = threading.Thread(target=wait_for_process_to_end)
self.monitor.start() self.monitor.start()
while True: retries = 1000
while retries > 0:
retries -= 1
if self.monitor is None:
break
testsock = socket.socket() testsock = socket.socket()
try: try:
testsock.connect(("127.0.0.1", self.getPort())) testsock.connect(("127.0.0.1", self.getPort()))
testsock.close() testsock.close()
available = True
break break
except: except:
time.sleep(0.1) time.sleep(0.1)
self.modificationLock.release() self.modificationLock.release()
if not available:
raise SdrSourceException("rtl source failed to start up")
for c in self.clients: for c in self.clients:
c.onSdrAvailable() c.onSdrAvailable()
@ -200,6 +245,7 @@ class SdrSource(object):
def addClient(self, c): def addClient(self, c):
self.clients.append(c) self.clients.append(c)
self.start() self.start()
def removeClient(self, c): def removeClient(self, c):
try: try:
self.clients.remove(c) self.clients.remove(c)
@ -235,6 +281,7 @@ class RtlSdrSource(SdrSource):
def getFormatConversion(self): def getFormatConversion(self):
return "csdr convert_u8_f" return "csdr convert_u8_f"
class HackrfSource(SdrSource): class HackrfSource(SdrSource):
def getCommand(self): def getCommand(self):
return "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-" 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): def getFormatConversion(self):
return "csdr convert_s8_f" return "csdr convert_s8_f"
class SdrplaySource(SdrSource): class SdrplaySource(SdrSource):
def getCommand(self): def getCommand(self):
command = "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm}" command = "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm}"
gainMap = {"rf_gain": "RFGR", "if_gain": "IFGR"} 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 ] 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: if gains:
command += " -g {gains}".format(gains=",".join(gains)) command += " -g {gains}".format(gains=",".join(gains))
if self.rtlProps["antenna"] is not None: if self.rtlProps["antenna"] is not None:
command += " -a \"{antenna}\"" command += ' -a "{antenna}"'
command += " -" command += " -"
return command return command
def sleepOnRestart(self): def sleepOnRestart(self):
time.sleep(1) time.sleep(1)
class AirspySource(SdrSource): class AirspySource(SdrSource):
def getCommand(self): def getCommand(self):
frequency = self.props['center_freq'] / 1e6 frequency = self.props["center_freq"] / 1e6
command = "airspy_rx" command = "airspy_rx"
command += " -f{0}".format(frequency) command += " -f{0}".format(frequency)
command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}" command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}"
return command return command
def getFormatConversion(self): def getFormatConversion(self):
return "csdr convert_s16_f" return "csdr convert_s16_f"
class SpectrumThread(csdr.output): class SpectrumThread(csdr.output):
def __init__(self, sdrSource): def __init__(self, sdrSource):
self.sdrSource = sdrSource self.sdrSource = sdrSource
super().__init__() super().__init__()
self.props = props = self.sdrSource.props.collect( self.props = props = self.sdrSource.props.collect(
"samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor", "fft_compression", "samp_rate",
"csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through" "fft_size",
"fft_fps",
"fft_voverlap_factor",
"fft_compression",
"csdr_dynamic_bufsize",
"csdr_print_bufsizes",
"csdr_through",
"temporary_directory",
).defaults(PropertyManager.getSharedInstance()) ).defaults(PropertyManager.getSharedInstance())
self.dsp = dsp = csdr.dsp(self) self.dsp = dsp = csdr.dsp(self)
@ -287,14 +349,19 @@ class SpectrumThread(csdr.output):
fft_fps = props["fft_fps"] fft_fps = props["fft_fps"]
fft_voverlap_factor = props["fft_voverlap_factor"] 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 = [ self.subscriptions = [
props.getProperty("samp_rate").wire(dsp.set_samp_rate), props.getProperty("samp_rate").wire(dsp.set_samp_rate),
props.getProperty("fft_size").wire(dsp.set_fft_size), props.getProperty("fft_size").wire(dsp.set_fft_size),
props.getProperty("fft_fps").wire(dsp.set_fft_fps), props.getProperty("fft_fps").wire(dsp.set_fft_fps),
props.getProperty("fft_compression").wire(dsp.set_fft_compression), 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) set_fft_averages(None, None)
@ -309,25 +376,15 @@ class SpectrumThread(csdr.output):
if self.sdrSource.isAvailable(): if self.sdrSource.isAvailable():
self.dsp.start() self.dsp.start()
def add_output(self, type, read_fn): def supports_type(self, t):
if type != "audio": return t == "audio"
logger.error("unsupported output type received by FFT: %s", type)
return
def receive_output(self, type, read_fn):
if self.props["csdr_dynamic_bufsize"]: 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") logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1")
def pipe(): threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
run = True
while run:
data = read_fn()
if len(data) == 0:
run = False
else:
self.sdrSource.writeSpectrumData(data)
threading.Thread(target = pipe).start()
def stop(self): def stop(self):
self.dsp.stop() self.dsp.stop()
@ -338,20 +395,36 @@ class SpectrumThread(csdr.output):
def onSdrAvailable(self): def onSdrAvailable(self):
self.dsp.start() self.dsp.start()
def onSdrUnavailable(self): def onSdrUnavailable(self):
self.dsp.stop() self.dsp.stop()
class DspManager(csdr.output): class DspManager(csdr.output):
def __init__(self, handler, sdrSource): def __init__(self, handler, sdrSource):
self.handler = handler self.handler = handler
self.sdrSource = sdrSource self.sdrSource = sdrSource
self.metaParser = MetaParser(self.handler) self.metaParser = MetaParser(self.handler)
self.wsjtParser = WsjtParser(self.handler)
self.localProps = self.sdrSource.getProps().collect( self.localProps = (
"audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize", self.sdrSource.getProps()
"csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality", .collect(
"dmr_filter" "audio_compression",
).defaults(PropertyManager.getSharedInstance()) "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 = csdr.dsp(self)
self.dsp.nc_port = self.sdrSource.getPort() self.dsp.nc_port = self.sdrSource.getPort()
@ -366,6 +439,9 @@ class DspManager(csdr.output):
bpf[1] = cut bpf[1] = cut
self.dsp.set_bpf(*bpf) 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.subscriptions = [
self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression), self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression),
self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression), self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression),
@ -378,7 +454,9 @@ class DspManager(csdr.output):
self.localProps.getProperty("high_cut").wire(set_high_cut), self.localProps.getProperty("high_cut").wire(set_high_cut),
self.localProps.getProperty("mod").wire(self.dsp.set_demodulator), 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("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_offset_freq(0)
@ -387,19 +465,24 @@ class DspManager(csdr.output):
self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"] self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"]
self.dsp.csdr_through = self.localProps["csdr_through"] self.dsp.csdr_through = self.localProps["csdr_through"]
if (self.localProps["digimodes_enable"]): if self.localProps["digimodes_enable"]:
def set_secondary_mod(mod): def set_secondary_mod(mod):
if mod == False: mod = None if mod == False:
mod = None
self.dsp.set_secondary_demodulator(mod) self.dsp.set_secondary_demodulator(mod)
if mod is not None: if mod is not None:
self.handler.write_secondary_dsp_config({ self.handler.write_secondary_dsp_config(
{
"secondary_fft_size": self.localProps["digimodes_fft_size"], "secondary_fft_size": self.localProps["digimodes_fft_size"],
"if_samp_rate": self.dsp.if_samp_rate(), "if_samp_rate": self.dsp.if_samp_rate(),
"secondary_bw":self.dsp.secondary_bw() "secondary_bw": self.dsp.secondary_bw(),
}) }
)
self.subscriptions += [ self.subscriptions += [
self.localProps.getProperty("secondary_mod").wire(set_secondary_mod), 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) self.sdrSource.addClient(self)
@ -410,30 +493,19 @@ class DspManager(csdr.output):
if self.sdrSource.isAvailable(): if self.sdrSource.isAvailable():
self.dsp.start() 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) logger.debug("adding new output of type %s", t)
writers = { writers = {
"audio": self.handler.write_dsp_data, "audio": self.handler.write_dsp_data,
"smeter": self.handler.write_s_meter_level, "smeter": self.handler.write_s_meter_level,
"secondary_fft": self.handler.write_secondary_fft, "secondary_fft": self.handler.write_secondary_fft,
"secondary_demod": self.handler.write_secondary_demod, "secondary_demod": self.handler.write_secondary_demod,
"meta": self.metaParser.parse "meta": self.metaParser.parse,
"wsjt_demod": self.wsjtParser.parse,
} }
write = writers[t] write = writers[t]
def pump(read, write): threading.Thread(target=self.pump(read_fn, write)).start()
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()
def stop(self): def stop(self):
self.dsp.stop() self.dsp.stop()
@ -453,8 +525,10 @@ class DspManager(csdr.output):
logger.debug("received onSdrUnavailable, shutting down DspSource") logger.debug("received onSdrUnavailable, shutting down DspSource")
self.dsp.stop() self.dsp.stop()
class CpuUsageThread(threading.Thread): class CpuUsageThread(threading.Thread):
sharedInstance = None sharedInstance = None
@staticmethod @staticmethod
def getSharedInstance(): def getSharedInstance():
if CpuUsageThread.sharedInstance is None: if CpuUsageThread.sharedInstance is None:
@ -486,17 +560,19 @@ class CpuUsageThread(threading.Thread):
except: except:
return 0 # Workaround, possibly we're on a Mac return 0 # Workaround, possibly we're on a Mac
line = "" line = ""
while not "cpu " in line: line=f.readline() while not "cpu " in line:
line = f.readline()
f.close() f.close()
spl = line.split(" ") spl = line.split(" ")
worktime = int(spl[2]) + int(spl[3]) + int(spl[4]) worktime = int(spl[2]) + int(spl[3]) + int(spl[4])
idletime = int(spl[5]) idletime = int(spl[5])
dworktime = (worktime - self.last_worktime) dworktime = worktime - self.last_worktime
didletime = (idletime - self.last_idletime) didletime = idletime - self.last_idletime
rate = float(dworktime) / (didletime + dworktime) rate = float(dworktime) / (didletime + dworktime)
self.last_worktime = worktime self.last_worktime = worktime
self.last_idletime = idletime self.last_idletime = idletime
if (self.last_worktime==0): return 0 if self.last_worktime == 0:
return 0
return rate return rate
def add_client(self, c): def add_client(self, c):
@ -514,11 +590,14 @@ class CpuUsageThread(threading.Thread):
CpuUsageThread.sharedInstance = None CpuUsageThread.sharedInstance = None
self.doRun = False self.doRun = False
class TooManyClientsException(Exception): class TooManyClientsException(Exception):
pass pass
class ClientRegistry(object): class ClientRegistry(object):
sharedInstance = None sharedInstance = None
@staticmethod @staticmethod
def getSharedInstance(): def getSharedInstance():
if ClientRegistry.sharedInstance is None: if ClientRegistry.sharedInstance is None:

View File

@ -3,8 +3,10 @@ import hashlib
import json import json
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WebSocketConnection(object): class WebSocketConnection(object):
def __init__(self, handler, messageHandler): def __init__(self, handler, messageHandler):
self.handler = handler self.handler = handler
@ -13,38 +15,64 @@ class WebSocketConnection(object):
my_header_keys = list(map(lambda x: x[0], my_headers)) my_header_keys = list(map(lambda x: x[0], my_headers))
h_key_exists = lambda x: my_header_keys.count(x) h_key_exists = lambda x: my_header_keys.count(x)
h_value = lambda x: my_headers[my_header_keys.index(x)][1] 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")): if (
(not h_key_exists("Upgrade"))
or not (h_value("Upgrade") == "websocket")
or (not h_key_exists("Sec-WebSocket-Key"))
):
raise WebSocketException raise WebSocketException
ws_key = h_value("Sec-WebSocket-Key") ws_key = h_value("Sec-WebSocket-Key")
shakey = hashlib.sha1() 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()) 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): def get_header(self, size, opcode):
ws_first_byte = 0b10000000 | (opcode & 0x0F) ws_first_byte = 0b10000000 | (opcode & 0x0F)
if (size > 125): if size > 2 ** 16 - 1:
return bytes([ws_first_byte, 126, (size>>8) & 0xff, size & 0xff]) # 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: 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]) return bytes([ws_first_byte, size])
def send(self, data): def send(self, data):
# convenience # 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. # 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 # string-type messages are sent as text frames
if (type(data) == str): if type(data) == str:
header = self.get_header(len(data), 1) 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 # anything else as binary
else: else:
header = self.get_header(len(data), 2) header = self.get_header(len(data), 2)
data_to_send = header + data data_to_send = header + data
written = self.handler.wfile.write(data_to_send) 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!") logger.error("incomplete write! closing socket!")
self.close() self.close()
else: else:
@ -52,25 +80,25 @@ class WebSocketConnection(object):
def read_loop(self): def read_loop(self):
open = True open = True
while (open): while open:
header = self.handler.rfile.read(2) header = self.handler.rfile.read(2)
opcode = header[0] & 0x0F opcode = header[0] & 0x0F
length = header[1] & 0x7F length = header[1] & 0x7F
mask = (header[1] & 0x80) >> 7 mask = (header[1] & 0x80) >> 7
if (length == 126): if length == 126:
header = self.handler.rfile.read(2) header = self.handler.rfile.read(2)
length = (header[0] << 8) + header[1] length = (header[0] << 8) + header[1]
if (mask): if mask:
masking_key = self.handler.rfile.read(4) masking_key = self.handler.rfile.read(4)
data = self.handler.rfile.read(length) data = self.handler.rfile.read(length)
if (mask): if mask:
data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)]) data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)])
if (opcode == 1): if opcode == 1:
message = data.decode('utf-8') message = data.decode("utf-8")
self.messageHandler.handleTextMessage(self, message) self.messageHandler.handleTextMessage(self, message)
elif (opcode == 2): elif opcode == 2:
self.messageHandler.handleBinaryMessage(self, data) self.messageHandler.handleBinaryMessage(self, data)
elif (opcode == 8): elif opcode == 8:
open = False open = False
self.messageHandler.handleClose(self) self.messageHandler.handleClose(self)
else: else:

277
owrx/wsjt.py Normal file
View File

@ -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("<DecodeFinished>"):
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)

View File

@ -29,4 +29,3 @@ if __name__=="__main__":
if not "sdrhu_key" in pm: if not "sdrhu_key" in pm:
exit(1) exit(1)
SdrHuUpdater().update() SdrHuUpdater().update()