47 Commits

Author SHA1 Message Date
53de54120e only specify device strings when configured 2019-11-24 20:34:51 +01:00
fa097bf57e update readme 2019-11-24 19:36:07 +01:00
917eb4fdf1 update readme 2019-11-24 18:23:45 +01:00
a8df774e50 dual authors 2019-11-24 18:08:54 +01:00
0b98ce1ef2 restructure docker image; add separate temp dir that can be placed in a
tmpfs
2019-11-24 15:30:53 +01:00
c6bbdffea0 update ignore files 2019-11-23 18:21:55 +01:00
481918ab5b better profile switching for the gui 2019-11-23 17:22:20 +01:00
b27caf2405 allow initial_squelch_level to be set per profile 2019-11-23 16:56:29 +01:00
d5b7338531 run black 2019-11-23 01:13:16 +01:00
9246500c95 run black 2019-11-23 01:12:21 +01:00
91669a7fda no agc necessary for wsjt-x decoding 2019-11-23 00:35:33 +01:00
c7eb67129a add information about connectors 2019-11-22 23:34:27 +01:00
98901ac668 add pskreporter dupe check and stats 2019-11-22 17:16:40 +01:00
7dde793f9e let's switch to the connectors per default for now 2019-11-22 15:18:29 +01:00
07de82ae82 secondary chain as array, too 2019-11-22 15:00:36 +01:00
9f710cb70e fix for lfo_offset = None 2019-11-21 17:19:51 +01:00
dab62a04df fix offset switching 2019-11-21 16:07:20 +01:00
de51e266f6 add airspy source; fix offset tuning 2019-11-21 15:31:37 +01:00
5375580104 add device handling for rtl 2019-11-20 11:37:06 +01:00
964d9e873d add iq swapping capability 2019-11-19 14:03:32 +01:00
7e8e644e6c purge manifests after use (won't work as expected otherwise) 2019-11-18 21:26:11 +00:00
6bde623698 add manifest stuff 2019-11-18 14:42:05 +01:00
5ba89035b4 add connectors to docker 2019-11-18 14:15:59 +01:00
a9b99fa0ff introduce connector source for sdrplay 2019-11-17 20:52:16 +01:00
6619a1b4a6 the ServiceHandler is fully passive 2019-11-16 15:40:12 +01:00
a36f106c72 add source "busy state" to improve background scheduling 2019-11-15 23:05:52 +01:00
097f8a2b82 refactor event system 2019-11-15 22:13:00 +01:00
bcbb911b24 restore airspy feature test 2019-11-15 19:36:07 +01:00
f18efb2344 use Popen for feature detection to be able to take control of the
working directory
2019-11-14 22:13:02 +01:00
497d98363f fix bookmark edit / delete flyout 2019-11-14 15:31:44 +01:00
367bf666fc listen for frequency changes in the scheduler, too 2019-11-13 19:50:00 +01:00
7489a3bb9d try to improve memory footprint by rebuilding map dictionary in
intervals
2019-11-13 18:01:01 +01:00
2a6c7863b1 improve control socket handling 2019-11-12 15:57:10 +01:00
bf27f51049 let's leave some footsteps 2019-11-12 13:43:39 +01:00
6ba74a0c30 add ppm 2019-11-11 20:35:50 +01:00
ada94f69c3 new modificitions for owrx_connector support 2019-11-11 18:07:14 +01:00
dc5ac081ce fix some javascript code style issues 2019-11-07 10:56:39 +01:00
8a46922e77 panels disappear behind the header 2019-11-01 22:22:46 +01:00
5fdffb5e0c fix scrolling for canvas background and bookmarks. i hope that's all
now.
2019-11-01 19:48:08 +01:00
9f6a4891ed fix styles (broken by debugging) 2019-11-01 18:53:16 +01:00
41d23c66a4 prevent events from being blocked by the panels 2019-11-01 18:47:33 +01:00
9163f3d30e improve autoplay interface 2019-11-01 16:58:36 +01:00
d49fff65e4 switch to different csdr branch 2019-11-01 15:18:39 +01:00
95253e40bd organize timers and threads to get proper shutdown 2019-10-31 22:24:31 +01:00
af1a99c130 prevent deadlocks by shutting down services in correct order 2019-10-31 19:13:33 +01:00
1638fde181 fix gradient (without gradient) 2019-10-28 20:54:31 +01:00
52ea2e88e9 update readme 2019-10-27 17:45:17 +01:00
29 changed files with 685 additions and 278 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.git
.gitignore
.idea
**/*.pyc
**/*.swp

4
.gitignore vendored
View File

@ -1,4 +1,4 @@
*.pyc
*.swp
**/*.pyc
**/*.swp
tags
.idea

View File

@ -19,6 +19,24 @@ It has the following features:
- [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN)
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9)
**News (2019-11-24 by DD5JFK)**
- There is now a new way to interface with SDR hardware, [owrx_connectors](https://github.com/jketterl/owrx_connector).
They talk directly to the hardware (no rtl_sdr / rx_sdr necessary) and offer I/Q data on a socket, just like nmux
did before. They additionally offer a control socket that allows openwebrx to control the SDR parameters directly,
without the need for repeated restarts. This allows for quicker profile changes, and also reduces the risk of your
SDR hardware from failing during the switchover. See `config_webrx.py` for further information and instructions.
- Offset tuning using the `lfo_offset` has been reworked in a way that `center_freq` has to be set to the frequency you
actually want to listen to. If you're using an `lfo_offset` already, you will probably need to change its sign.
- `initial_squelch_level` can now be set on each profile.
- As usual, plenty of fixes and improvements.
**News (2019-10-27 by DD5JFK)**
- Part of the frontend code has been reworked
- Audio buffer minimums have been completely stripped. As a result, you should get better latency. Unfortunately, this also means there will be some skipping when audio starts.
- Now also supports AudioWorklets (for those browser that have it). The Raspberry Pi image has been updated to include https due to the SecureContext requirement.
- Mousewheel controls for the receiver sliders
- Error handling for failed SDR devices
**News (2019-09-29 by DD5FJK)**
- One of the most-requested features is finally coming to OpenWebRX: Bookmarks (sometimes also referred to as labels). There's two kinds of bookmarks available:
- Serverside bookmarks that are set up by the receiver administrator. Check the file `bookmarks.json` for examples!
@ -78,13 +96,13 @@ It has the following features:
### Raspberry Pi SD Card Images
Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-09-29-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+.
Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-10-27-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.
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 https://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.
@ -94,19 +112,23 @@ For those familiar with docker, I am providing [recent builds and Releases for b
### Manual Installation
OpenWebRX currently requires Linux and python 3 to run.
OpenWebRX currently requires Linux and python >= 3.6 to run.
First you will need to install the dependencies:
- [csdr](https://github.com/simonyiszk/csdr)
- [rtl-sdr](http://sdr.osmocom.org/trac/wiki/rtl-sdr)
Optional Dependencies if you want to be able to listen do digital voice:
Optional dependency for improved hardware access (to become mandatory at some point):
- [owrx_connector](https://github.com/jketterl/owrx_connector)
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:
Optional dependency if you want to decode WSJT-X modes:
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html)
@ -123,7 +145,7 @@ Please note that the server is also listening on the following ports (on localho
Now the next step is to customize the parameters of your server in `config_webrx.py`.
Actually, if you do something cool with OpenWebRX, please drop me a mail:
*Andras Retzler, HA7ILM <randras@sdr.hu>*
*Jakob Ketterl, DD5JFK <dd5jfk@darc.de>*
## Usage tips

View File

@ -6,6 +6,7 @@ config_webrx: configuration options for OpenWebRX
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -66,8 +67,8 @@ server_hostname = "localhost"
fft_fps = 9
fft_size = 4096 # Should be power of 2
fft_voverlap_factor = (
0.3
) # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
)
audio_compression = "adpcm" # valid values: "adpcm", "none"
fft_compression = "adpcm" # valid values: "adpcm", "none"
@ -98,14 +99,27 @@ Note: if you experience audio underruns while CPU usage is 100%, you can:
#################################################################################################
# Currently supported types of sdr receivers: "rtl_sdr", "sdrplay", "hackrf", "airspy"
#
# NEW: There is now custom connector software available, that is tailored for the use with
# openwebrx. The connectors allow the SDR to be reprogrammed while running, which allows for
# quicker profile changes. It also reduces the risk of a USB disconnect that can happen when the
# SDR software is restarted, since the connector will run continuously.
# Check out the connector repository here: https://github.com/jketterl/owrx_connector
#
# The following connectors are available (simply use them as the "type" in the config below):
# "rtl_sdr_connector", "sdrplay_connector", "airspy_connector"
#
# NOTE: These connectors will become the default as soon as they have become mature; the existing
# receiver types will then automatically be migrated to connectors. At that point, the old "nmux"
# method will start to be phased out.
sdrs = {
"rtlsdr": {
"name": "RTL-SDR USB Stick",
"type": "rtl_sdr",
"type": "rtl_sdr_connector",
"ppm": 0,
# you can change this if you use an upconverter. formula is:
# shown_center_freq = center_freq + lfo_offset
# center_freq + lfo_offset = actual frequency on the sdr
# "lfo_offset": 0,
"profiles": {
"70cm": {
@ -128,7 +142,7 @@ sdrs = {
},
"sdrplay": {
"name": "SDRPlay RSP2",
"type": "sdrplay",
"type": "sdrplay_connector",
"ppm": 0,
"profiles": {
"20m": {

61
csdr.py
View File

@ -4,6 +4,7 @@ OpenWebRX csdr plugin: do the signal processing with csdr
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -24,6 +25,7 @@ import subprocess
import os
import signal
import threading
import math
from functools import partial
from owrx.kiss import KissClient, DirewolfConfig
@ -84,7 +86,7 @@ class dsp(object):
self.csdr_dynamic_bufsize = False
self.csdr_print_bufsizes = False
self.csdr_through = False
self.squelch_level = 0
self.squelch_level = -150
self.fft_averages = 50
self.iqtee = False
self.iqtee2 = False
@ -213,35 +215,35 @@ class dsp(object):
return chain
def secondary_chain(self, which):
secondary_chain_base = "cat {input_pipe} | "
chain = ["cat {input_pipe}"]
if which == "fft":
return (
secondary_chain_base
+ "csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 "
+ (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression == "adpcm" else "")
)
chain += [
"csdr realpart_cf",
"csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size}",
"csdr logpower_cf -70",
]
if self.fft_compression == "adpcm":
chain += ["csdr compress_fft_adpcm_f_u8 {secondary_fft_size}"]
return chain
elif which == "bpsk31":
return (
secondary_chain_base
+ "csdr shift_addition_cc --fifo {secondary_shift_pipe} | "
+ "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | "
+ "csdr simple_agc_cc 0.001 0.5 | "
+ "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | "
+ "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | "
+ "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
)
return chain + [
"csdr shift_addition_cc --fifo {secondary_shift_pipe}",
"csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff}",
"csdr simple_agc_cc 0.001 0.5",
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q",
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8",
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8",
]
elif self.isWsjtMode(which):
chain = secondary_chain_base + "csdr realpart_cf | "
chain += ["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
chain += ["csdr fractional_decimator_ff {last_decimation}"]
return chain + ["csdr limit_ff", "csdr convert_f_s16"]
elif which == "packet":
chain = secondary_chain_base + "csdr fmdemod_quadri_cf | "
chain += ["csdr fmdemod_quadri_cf"]
if self.last_decimation != 1.0:
chain += "csdr fractional_decimator_ff {last_decimation} | "
chain += "csdr convert_f_s16 | direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h - 1>&2"
return chain
chain += ["csdr fractional_decimator_ff {last_decimation}"]
return chain + ["csdr convert_f_s16", "direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h - 1>&2"]
def set_secondary_demodulator(self, what):
if self.get_secondary_demodulator() == what:
@ -281,7 +283,7 @@ class dsp(object):
if not self.secondary_demodulator:
return
logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
secondary_command_demod = self.secondary_chain(self.secondary_demodulator)
secondary_command_demod = " | ".join(self.secondary_chain(self.secondary_demodulator))
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
self.try_create_configs(secondary_command_demod)
@ -304,7 +306,7 @@ class dsp(object):
if self.csdr_print_bufsizes:
my_env["CSDR_PRINT_BUFSIZES"] = "1"
if self.output.supports_type("secondary_fft"):
secondary_command_fft = self.secondary_chain("fft")
secondary_command_fft = " | ".join(self.secondary_chain("fft"))
secondary_command_fft = secondary_command_fft.format(
input_pipe=self.iqtee_pipe,
secondary_fft_input_size=self.secondary_fft_size,
@ -520,13 +522,16 @@ class dsp(object):
def get_bpf(self):
return [self.low_cut, self.high_cut]
def convertToLinear(self, db):
return float(math.pow(10, db / 10))
def set_squelch_level(self, squelch_level):
self.squelch_level = squelch_level
# no squelch required on digital voice modes
actual_squelch = 0 if self.isDigitalVoice() or self.isPacket() else self.squelch_level
actual_squelch = -150 if self.isDigitalVoice() or self.isPacket() else self.squelch_level
if self.running:
self.modification_lock.acquire()
self.squelch_pipe_file.write("%g\n" % (float(actual_squelch)))
self.squelch_pipe_file.write("%g\n" % (self.convertToLinear(actual_squelch)))
self.squelch_pipe_file.flush()
self.modification_lock.release()

View File

@ -3,15 +3,22 @@ FROM $BASE_IMAGE
RUN apk add --no-cache bash
RUN ln -s /usr/local/lib /usr/local/lib64
ADD docker/scripts/direwolf-1.5.patch /
ADD docker/scripts/install-dependencies.sh /
RUN /install-dependencies.sh
ADD . /openwebrx
ADD . /opt/openwebrx
WORKDIR /openwebrx
WORKDIR /opt/openwebrx
RUN mkdir -p /tmp/openwebrx && \
mv "/opt/openwebrx/config_webrx.py" "/opt/openwebrx/config_webrx.py.orig" && \
sed 's/temporary_directory = "\/tmp"/temporary_directory = "\/tmp\/openwebrx"/' < "/opt/openwebrx/config_webrx.py.orig" > "/opt/openwebrx/config_webrx.py" && \
rm "/opt/openwebrx/config_webrx.py.orig"
VOLUME /config
ENTRYPOINT [ "/openwebrx/docker/scripts/run.sh" ]
ENTRYPOINT [ "/opt/openwebrx/docker/scripts/run.sh" ]
EXPOSE 8073

View File

@ -9,3 +9,6 @@ RUN /install-dependencies-hackrf.sh
RUN /install-dependencies-soapysdr.sh
RUN /install-dependencies-sdrplay.sh
RUN /install-dependencies-airspy.sh
ADD docker/scripts/install-connectors.sh /
RUN /install-connectors.sh

View File

@ -4,3 +4,5 @@ FROM openwebrx-base:$ARCH
ADD docker/scripts/install-dependencies-rtlsdr.sh /
RUN /install-dependencies-rtlsdr.sh
ADD docker/scripts/install-connectors.sh /
RUN /install-connectors.sh

View File

@ -5,3 +5,5 @@ ADD docker/scripts/install-dependencies-sdrplay.sh /
ADD docker/scripts/install-lib.*.patch /
RUN /install-dependencies-sdrplay.sh
ADD docker/scripts/install-connectors.sh /
RUN /install-connectors.sh

View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euxo pipefail
function cmakebuild() {
cd $1
mkdir build
cd build
cmake ..
make
make install
cd ../..
rm -rf $1
}
cd /tmp
BUILD_PACKAGES="git cmake make gcc g++ musl-dev"
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
git clone https://github.com/jketterl/owrx_connector.git
cmakebuild owrx_connector
apk del .build-deps

View File

@ -23,7 +23,7 @@ apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
git clone https://git.code.sf.net/p/itpp/git itpp
cmakebuild itpp
git clone https://github.com/jketterl/csdr.git -b 48khz_filter
git clone https://github.com/jketterl/csdr.git -b docker_fixes
cd csdr
make
make install
@ -32,10 +32,6 @@ rm -rf csdr
git clone https://github.com/szechyjs/mbelib.git
cmakebuild mbelib
if [ -d "/usr/local/lib64" ]; then
# no idea why it's put into there now. alpine does not handle it correctly, so move it.
mv /usr/local/lib64/libmbe* /usr/local/lib
fi
git clone https://github.com/jketterl/digiham.git
cmakebuild digiham

View File

@ -3,6 +3,7 @@
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -170,6 +171,7 @@ input[type=range]:focus::-ms-fill-upper
background-repeat: repeat-x;
background-size: cover;
background-color: #444;
z-index: 1001;
}
#openwebrx-bookmarks-container
@ -258,16 +260,20 @@ input[type=range]:focus::-ms-fill-upper
border-top-color: #0F0;
}
#webrx-canvas-container
{
#webrx-canvas-background {
flex-grow: 1;
position: relative;
overflow: hidden;
background-image: url('../gfx/openwebrx-background-cool-blue.png');
background-repeat: no-repeat;
background-color: #1e5f7f;
background-size: cover;
}
#webrx-canvas-container
{
position: relative;
overflow: hidden;
cursor: crosshair;
height: 100%;
}
#webrx-canvas-container canvas
@ -276,7 +282,8 @@ input[type=range]:focus::-ms-fill-upper
border-style: none;
image-rendering: crisp-edges;
image-rendering: -webkit-optimize-contrast;
/*transition: left 200ms, width 200ms;*/
width: 100%;
height: 200px;
}
#openwebrx-mathbox-container
@ -363,6 +370,8 @@ input[type=range]:focus::-ms-fill-upper
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 0;
overflow: visible;
}
#openwebrx-panels-container-left {
@ -645,7 +654,7 @@ img.openwebrx-mirror-img
font-family: 'expletus-sans-medium';
}
#openwebrx-big-grey
#openwebrx-autoplay-overlay
{
position: fixed;
width: 100%;
@ -657,9 +666,6 @@ img.openwebrx-mirror-img
left: 0;
top: 0;
z-index: 1001;
display: none;
vertical-align: middle;
text-align: center;
color: white;
font-weight: bold;
font-size: 20pt;
@ -667,11 +673,19 @@ img.openwebrx-mirror-img
transition: opacity 0.3s linear;
}
#openwebrx-big-grey img
#openwebrx-autoplay-overlay img
{
width: 150px;
}
#openwebrx-autoplay-overlay .overlay-content {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
#openwebrx-digimode-canvas-container
{
/*margin: -10px -10px 10px -10px;*/

View File

@ -4,6 +4,7 @@
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -45,9 +46,11 @@
</div>
</div>
<div id="openwebrx-mathbox-container"> </div>
<div id="webrx-canvas-background">
<div id="webrx-canvas-container">
<!-- add canvas here by javascript -->
</div>
</div>
<div id="openwebrx-panels-container">
<div id="openwebrx-panels-container-left">
<div class="openwebrx-panel" data-panel-name="client-under-devel" style="width: 245px; background-color: Red;">
@ -116,8 +119,9 @@
<div class="openwebrx-panel" id="openwebrx-panel-log" data-panel-name="debug" style="width: 619px;">
<div class="openwebrx-panel-inner nano" id="openwebrx-log-scroll">
<div class="nano-content">
<div id="openwebrx-client-log-title">OpenWebRX client log</strong></div>
<span id="openwebrx-client-1">Author: </span><a href="http://blog.sdr.hu/about" target="_blank">András Retzler, HA7ILM</a><br />You can support OpenWebRX development via <a href="http://blog.sdr.hu/support" target="_blank">PayPal!</a><br/>
<div id="openwebrx-client-log-title">OpenWebRX client log</div>
<div>Author contact: <a href="http://blog.sdr.hu/about" target="_blank">András Retzler, HA7ILM</a></div>
<div>Author contact: <a href="http://www.justjakob.de/" target="_blank">Jakob Ketterl, DD5JFK</a></div>
<div id="openwebrx-debugdiv"></div>
</div>
</div>
@ -214,10 +218,10 @@
</div>
</div>
</div>
<div id="openwebrx-big-grey" onclick="playButtonClick();">
<div id="openwebrx-play-button-text">
<div id="openwebrx-autoplay-overlay" style="display:none;">
<div class="overlay-content">
<img id="openwebrx-play-button" src="static/gfx/openwebrx-play-button.png" />
<br /><br />Start OpenWebRX
<div>Start OpenWebRX</div>
</div>
</div>
<div id="openwebrx-dialog-bookmark" class="openwebrx-dialog" style="display:none;">

View File

@ -1,6 +1,6 @@
function Measurement() {
this.reset();
};
}
Measurement.prototype.add = function(v) {
this.value += v;
@ -25,7 +25,7 @@ Measurement.prototype.reset = function() {
Measurement.prototype.report = function(range, interval, callback) {
return new Reporter(this, range, interval, callback);
}
};
function Reporter(measurement, range, interval, callback) {
this.measurement = measurement;
@ -33,7 +33,7 @@ function Reporter(measurement, range, interval, callback) {
this.samples = [];
this.callback = callback;
this.interval = setInterval(this.report.bind(this), interval);
};
}
Reporter.prototype.sample = function(){
this.samples.push({

View File

@ -3,6 +3,7 @@
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
@ -140,9 +141,8 @@ function setSquelchToAuto() {
}
function updateSquelch() {
var sliderValue = parseInt(e("openwebrx-panel-squelch").value);
var outputValue = (sliderValue === parseInt(e("openwebrx-panel-squelch").min)) ? 0 : getLinearSmeterValue(sliderValue);
ws.send(JSON.stringify({"type": "dspcontrol", "params": {"squelch_level": outputValue}}));
var sliderValue = parseInt($("#openwebrx-panel-squelch").val());
ws.send(JSON.stringify({"type": "dspcontrol", "params": {"squelch_level": sliderValue}}));
}
var waterfall_min_level;
@ -196,7 +196,8 @@ function setSquelchSliderBackground(val) {
var relative = (val - min) / (max - min);
// use a brighter color when squelch is open
var color = val >= sliderPosition ? '#22ff2f' : '#008908';
var style = 'linear-gradient(90deg, ' + color + ' ' + relative * 100 + '%, #B6B6B6 ' + (1 - relative) * 100 + '%)';
// we don't use the gradient, but separate the colors discretely using css tricks
var style = 'linear-gradient(90deg, ' + color + ', ' + color + ' ' + relative * 100 + '%, #B6B6B6 ' + relative * 100 + '%)';
$slider.css('--track-background', style);
}
@ -562,6 +563,10 @@ function demodulator_set_offset_frequency(which, to_what) {
$("#webrx-actual-freq").html(format_frequency("{x} MHz", center_freq + to_what, 1e6, 4));
}
function waterfallWidth() {
return $('body').width();
}
// ========================================================
// =================== SCALE ROUTINES ===================
@ -601,7 +606,7 @@ function scale_canvas_mousedown(evt) {
function scale_offset_freq_from_px(x, visible_range) {
if (typeof visible_range === "undefined") visible_range = get_visible_freq_range();
return (visible_range.start + visible_range.bw * (x / canvas_container.clientWidth)) - center_freq;
return (visible_range.start + visible_range.bw * (x / waterfallWidth())) - center_freq;
}
function scale_canvas_mousemove(evt) {
@ -642,20 +647,20 @@ function scale_canvas_mouseup(evt) {
}
function scale_px_from_freq(f, range) {
return Math.round(((f - range.start) / range.bw) * canvas_container.clientWidth);
return Math.round(((f - range.start) / range.bw) * waterfallWidth());
}
function get_visible_freq_range() {
var out = {};
var fcalc = function (x) {
var canvasWidth = canvas_container.clientWidth * zoom_levels[zoom_level];
var canvasWidth = waterfallWidth() * zoom_levels[zoom_level];
return Math.round(((-zoom_offset_px + x) / canvasWidth) * bandwidth) + (center_freq - bandwidth / 2);
};
out.start = fcalc(0);
out.center = fcalc(canvas_container.clientWidth / 2);
out.end = fcalc(canvas_container.clientWidth);
out.center = fcalc(waterfallWidth() / 2);
out.end = fcalc(waterfallWidth());
out.bw = out.end - out.start;
out.hps = out.bw / canvas_container.clientWidth;
out.hps = out.bw / waterfallWidth();
return out;
}
@ -731,7 +736,7 @@ function get_scale_mark_spacing(range) {
var out = {};
var fcalc = function (freq) {
out.numlarge = (range.bw / freq);
out.large = canvas_container.clientWidth / out.numlarge; //distance between large markers (these have text)
out.large = waterfallWidth() / out.numlarge; //distance between large markers (these have text)
out.ratio = 5; //(ratio-1) small markers exist per large marker
out.small = out.large / out.ratio; //distance between small markers
if (out.small < scale_min_space_bw_small_markers) return false;
@ -890,8 +895,8 @@ function canvas_mousemove(evt) {
var deltaX = canvas_drag_last_x - evt.pageX;
var dpx = range.hps * deltaX;
if (
!(zoom_center_rel + dpx > (bandwidth / 2 - canvas_container.clientWidth * (1 - zoom_center_where) * range.hps)) &&
!(zoom_center_rel + dpx < -bandwidth / 2 + canvas_container.clientWidth * zoom_center_where * range.hps)
!(zoom_center_rel + dpx > (bandwidth / 2 - waterfallWidth() * (1 - zoom_center_where) * range.hps)) &&
!(zoom_center_rel + dpx < -bandwidth / 2 + waterfallWidth() * zoom_center_where * range.hps)
) {
zoom_center_rel += dpx;
}
@ -928,14 +933,18 @@ function canvas_end_drag() {
}
function zoom_center_where_calc(screenposX) {
//return (screenposX-(window.innerWidth-canvas_container.clientWidth))/canvas_container.clientWidth;
return screenposX / canvas_container.clientWidth;
return screenposX / waterfallWidth();
}
function get_relative_x(evt) {
var relativeX = (evt.offsetX) ? evt.offsetX : evt.layerX;
var relativeX = evt.offsetX || evt.layerX;
if ($(evt.target).closest(canvas_container).length) return relativeX;
// compensate for the frequency scale, since that is not resized by the browser.
var relatives = $(evt.target).closest('#openwebrx-frequency-container').map(function(){
return evt.pageX - this.offsetLeft;
});
if (relatives.length) relativeX = relatives[0];
return relativeX - zoom_offset_px;
}
@ -992,7 +1001,7 @@ function zoom_set(level) {
if (!(level >= 0 && level <= zoom_levels.length - 1)) return;
level = parseInt(level);
zoom_level = level;
//zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+canvas_container.clientWidth/2); //zoom to screen center instead of demod envelope
//zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+waterfallWidth()/2); //zoom to screen center instead of demod envelope
zoom_center_rel = demodulators[0].offset_frequency;
zoom_center_where = 0.5 + (zoom_center_rel / bandwidth); //this is a kind of hack
resize_canvases(true);
@ -1001,13 +1010,12 @@ function zoom_set(level) {
}
function zoom_calc() {
var winsize = canvas_container.clientWidth;
var winsize = waterfallWidth();
var canvases_new_width = winsize * zoom_levels[zoom_level];
zoom_offset_px = -((canvases_new_width * (0.5 + zoom_center_rel / bandwidth)) - (winsize * zoom_center_where));
if (zoom_offset_px > 0) zoom_offset_px = 0;
if (zoom_offset_px < winsize - canvases_new_width)
zoom_offset_px = winsize - canvases_new_width;
//console.log("zoom_calc || zopx:"+zoom_offset_px.toString()+ " maxoff:"+(winsize-canvases_new_width).toString()+" relval:"+(0.5+zoom_center_rel/bandwidth).toString() );
}
var networkSpeedMeasurement;
@ -1037,7 +1045,7 @@ function on_ws_recv(evt) {
starting_mod = config['start_mod'];
starting_offset_frequency = config['start_offset_freq'];
bandwidth = config['samp_rate'];
center_freq = config['center_freq'] + config['lfo_offset'];
center_freq = config['center_freq'];
fft_size = config['fft_size'];
fft_fps = config['fft_fps'];
var audio_compression = config['audio_compression'];
@ -1049,6 +1057,9 @@ function on_ws_recv(evt) {
mathbox_waterfall_colors = config['mathbox_waterfall_colors'];
mathbox_waterfall_frequency_resolution = config['mathbox_waterfall_frequency_resolution'];
mathbox_waterfall_history_length = config['mathbox_waterfall_history_length'];
var sql = Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150;
$("#openwebrx-panel-squelch").val(sql);
updateSquelch();
waterfall_init();
initialize_demodulator();
@ -1056,7 +1067,7 @@ function on_ws_recv(evt) {
waterfall_clear();
currentprofile = config['profile_id'];
currentprofile = config['sdr_id'] + '|' + config['profile_id'];
$('#openwebrx-sdr-profiles-listbox').val(currentprofile);
break;
@ -1421,7 +1432,7 @@ function divlog(what, is_error) {
was_error |= is_error;
if (is_error) {
what = "<span class=\"webrx-error\">" + what + "</span>";
if (e("openwebrx-panel-log").openwebrxHidden) toggle_panel("openwebrx-panel-log"); //show panel if any error is present
toggle_panel("openwebrx-panel-log", true); //show panel if any error is present
}
e("openwebrx-debugdiv").innerHTML += what + "<br />";
var nano = $('.nano');
@ -1473,9 +1484,7 @@ function onAudioStart(success, apiType){
//hide log panel in a second (if user has not hidden it yet)
window.setTimeout(function () {
if (typeof e("openwebrx-panel-log").openwebrxHidden === "undefined" && !was_error) {
toggle_panel("openwebrx-panel-log");
}
toggle_panel("openwebrx-panel-log", !!was_error);
}, 2000);
//Synchronise volume with slider
@ -1573,9 +1582,6 @@ function add_canvas() {
new_canvas.width = fft_size;
new_canvas.height = canvas_default_height;
canvas_actual_line = canvas_default_height - 1;
new_canvas.style.width = (canvas_container.clientWidth * zoom_levels[zoom_level]).toString() + "px";
new_canvas.style.left = zoom_offset_px.toString() + "px";
new_canvas.style.height = canvas_default_height.toString() + "px";
new_canvas.openwebrx_top = (-canvas_default_height + 1);
new_canvas.style.top = new_canvas.openwebrx_top.toString() + "px";
canvas_context = new_canvas.getContext("2d");
@ -1615,11 +1621,9 @@ function resize_canvases(zoom) {
if (typeof zoom === "undefined") zoom = false;
if (!zoom) mkzoomlevels();
zoom_calc();
var new_width = (canvas_container.clientWidth * zoom_levels[zoom_level]).toString() + "px";
var zoom_value = zoom_offset_px.toString() + "px";
canvases.forEach(function (p) {
p.style.width = new_width;
p.style.left = zoom_value;
$('#webrx-canvas-container').css({
width: waterfallWidth() * zoom_levels[zoom_level] + 'px',
left: zoom_offset_px + "px"
});
}
@ -1917,10 +1921,10 @@ var audioEngine;
function openwebrx_init() {
audioEngine = new AudioEngine(audio_buffer_maximal_length_sec, audioReporter);
$overlay = $('#openwebrx-autoplay-overlay');
$overlay.on('click', playButtonClick);
if (!audioEngine.isAllowed()) {
e("openwebrx-big-grey").style.display = "table-cell";
var opb = e("openwebrx-play-button-text");
opb.style.marginTop = (window.innerHeight / 2 - opb.clientHeight / 2).toString() + "px";
$overlay.show();
} else {
audioEngine.start(onAudioStart);
}
@ -1972,10 +1976,11 @@ function update_dmr_timeslot_filtering() {
function playButtonClick() {
//On iOS, we can only start audio from a click or touch event.
audioEngine.start(onAudioStart);
e("openwebrx-big-grey").style.opacity = 0;
window.setTimeout(function () {
e("openwebrx-big-grey").style.display = "none";
}, 1100);
var $overlay = $('#openwebrx-autoplay-overlay');
$overlay.css('opacity', 0);
$overlay.on('transitionend', function() {
$overlay.hide();
});
}
var rt = function (s, n) {
@ -2049,7 +2054,7 @@ function initPanels() {
el.style.transitionDuration = null;
el.style.transitionDelay = null;
el.style.transitionProperty = null;
if (el.movement && el.movement == 'collapse') {
if (el.movement && el.movement === 'collapse') {
el.style.display = 'none';
}
});
@ -2219,16 +2224,14 @@ function secondary_demod_push_data(x) {
var c = y.charCodeAt(0);
return (c === 10 || (c >= 32 && c <= 126));
}).map(function (y) {
if (y === "&"
)
if (y === "&")
return "&amp;";
if (y === "<") return "&lt;";
if (y === ">") return "&gt;";
if (y === " ") return "&nbsp;";
return y;
}).map(function (y) {
if (y === "\n"
)
if (y === "\n")
return "<br />";
return "<span class=\"part\">" + y + "</span>";
}).join("");

View File

@ -9,6 +9,7 @@ from socketserver import ThreadingMixIn
from owrx.sdrhu import SdrHuUpdater
from owrx.service import Services
from owrx.websocket import WebSocketConnection
from owrx.pskreporter import PskReporter
import logging
@ -27,6 +28,7 @@ OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE fil
_________________________________________________________________________________________________
Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
Author contact info: Jakob Ketterl, DD5JFK <dd5jfk@darc.de>
"""
)
@ -61,3 +63,4 @@ if __name__ == "__main__":
except KeyboardInterrupt:
WebSocketConnection.closeAll()
Services.stop()
PskReporter.stop()

View File

@ -26,6 +26,7 @@ class Bookmark(object):
class Bookmarks(object):
sharedInstance = None
@staticmethod
def getSharedInstance():
if Bookmarks.sharedInstance is None:

View File

@ -57,7 +57,6 @@ class OpenWebRxReceiverClient(Client):
"waterfall_min_level",
"waterfall_max_level",
"waterfall_auto_level_margin",
"lfo_offset",
"samp_rate",
"fft_size",
"fft_fps",
@ -70,6 +69,8 @@ class OpenWebRxReceiverClient(Client):
"mathbox_waterfall_colors",
"mathbox_waterfall_history_length",
"mathbox_waterfall_frequency_resolution",
"initial_squelch_level",
"profile_id",
]
def __init__(self, conn):
@ -170,8 +171,8 @@ class OpenWebRxReceiverClient(Client):
config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys)
# TODO mathematical properties? hmmmm
config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"]
# TODO this is a hack that only works because setting the profile always causes plenty of config change
config["profile_id"] = self.sdr.getId() + "|" + self.sdr.getProfileId()
# TODO this is a hack to support multiple sdrs
config["sdr_id"] = self.sdr.getId()
self.write_config(config)
cf = configProps["center_freq"]
@ -243,7 +244,7 @@ class OpenWebRxReceiverClient(Client):
self.send(bytes([0x03]) + data)
def write_secondary_demod(self, data):
message = data.decode('ascii')
message = data.decode("ascii")
self.send({"type": "secondary_demod", "value": message})
def write_secondary_dsp_config(self, cfg):

View File

@ -1,10 +1,11 @@
import os
import subprocess
from functools import reduce
from operator import and_
import re
from distutils.version import LooseVersion
import inspect
from owrx.config import PropertyManager
import shlex
import logging
@ -19,9 +20,12 @@ class FeatureDetector(object):
features = {
"core": ["csdr", "nmux", "nc"],
"rtl_sdr": ["rtl_sdr"],
"rtl_sdr_connector": ["rtl_connector"],
"sdrplay": ["rx_tools"],
"sdrplay_connector": ["soapy_connector"],
"hackrf": ["hackrf_transfer"],
"airspy": ["airspy_rx"],
"airspy_connector": ["soapy_connector"],
"digital_voice_digiham": ["digiham", "sox"],
"digital_voice_dsd": ["dsd", "sox", "digiham"],
"wsjt-x": ["wsjtx", "sox"],
@ -83,7 +87,13 @@ class FeatureDetector(object):
return inspect.getdoc(self._get_requirement_method(requirement))
def command_is_runnable(self, command):
return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512
tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"]
cmd = shlex.split(command)
try:
process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=tmp_dir)
return process.wait() != 32512
except FileNotFoundError:
return False
def has_csdr(self):
"""
@ -144,9 +154,6 @@ class FeatureDetector(object):
# TODO also check if it has the stdout feature
return self.command_is_runnable("hackrf_transfer --help")
def command_exists(self, command):
return os.system("which {0}".format(command)) == 0
def has_digiham(self):
"""
To use digital voice modes, the digiham package is required. You can find the package and installation
@ -163,7 +170,10 @@ class FeatureDetector(object):
def check_digiham_version(command):
try:
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
version = LooseVersion(digiham_version_regex.match(process.stdout.readline().decode()).group(1))
matches = digiham_version_regex.match(process.stdout.readline().decode())
if matches is None:
return False
version = LooseVersion(matches.group(1))
process.wait(1)
return version >= required_version
except FileNotFoundError:
@ -185,6 +195,40 @@ class FeatureDetector(object):
True,
)
def _check_connector(self, command):
required_version = LooseVersion("0.1")
owrx_connector_version_regex = re.compile("^owrx-connector version (.*)$")
try:
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
matches = owrx_connector_version_regex.match(process.stdout.readline().decode())
if matches is None:
return False
version = LooseVersion(matches.group(1))
process.wait(1)
return version >= required_version
except FileNotFoundError:
return False
def has_rtl_connector(self):
"""
The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker
frequency switching, uses less CPU and can even provide more stability in some cases.
You can get it here: https://github.com/jketterl/owrx_connector
"""
return self._check_connector("rtl_connector")
def has_soapy_connector(self):
"""
The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker
frequency switching, uses less CPU and can even provide more stability in some cases.
You can get it here: https://github.com/jketterl/owrx_connector
"""
return self._check_connector("soapy_connector")
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
@ -208,7 +252,7 @@ class FeatureDetector(object):
"""
In order to use an Airspy Receiver, you need to install the airspy_rx receiver software.
"""
return self.command_is_runnable("airspy_rx --help 2> /dev/null")
return self.command_is_runnable("airspy_rx --help")
def has_wsjtx(self):
"""

View File

@ -25,13 +25,23 @@ class Map(object):
def __init__(self):
self.clients = []
self.positions = {}
self.positionsLock = threading.Lock()
def removeLoop():
loops = 0
while True:
try:
self.removeOldPositions()
except Exception:
logger.exception("error while removing old map positions")
loops += 1
# rebuild the positions dictionary every once in a while, it consumes lots of memory otherwise
if loops == 60:
try:
self.rebuildPositions()
except Exception:
logger.exception("error while rebuilding positions")
loops = 0
time.sleep(60)
threading.Thread(target=removeLoop, daemon=True).start()
@ -64,6 +74,7 @@ class Map(object):
def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None):
ts = datetime.now()
with self.positionsLock:
self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band}
self.broadcast(
[
@ -80,12 +91,14 @@ class Map(object):
def touchLocation(self, callsign):
# not implemented on the client side yet, so do not use!
ts = datetime.now()
with self.positionsLock:
if callsign in self.positions:
self.positions[callsign]["updated"] = ts
self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}])
def removeLocation(self, callsign):
self.positions.pop(callsign, None)
with self.positionsLock:
del self.positions[callsign]
# TODO broadcast removal to clients
def removeOldPositions(self):
@ -97,6 +110,11 @@ class Map(object):
for callsign in to_be_removed:
self.removeLocation(callsign)
def rebuildPositions(self):
with self.positionsLock:
p = {key: value for key, value in self.positions.items()}
self.positions = p
class LatLngLocation(Location):
def __init__(self, lat: float, lon: float):

View File

@ -62,7 +62,7 @@ class DmrMetaEnricher(object):
cache = DmrCache.getSharedInstance()
if not cache.isValid(id):
if not id in self.threads:
self.threads[id] = threading.Thread(target=self.downloadRadioIdData, args=[id])
self.threads[id] = threading.Thread(target=self.downloadRadioIdData, args=[id], daemon=True)
self.threads[id].start()
return None
data = cache.get(id)

View File

@ -3,10 +3,12 @@ import threading
import time
import random
import socket
from sched import scheduler
from functools import reduce
from operator import and_
from owrx.config import PropertyManager
from owrx.version import openwebrx_version
from owrx.locator import Locator
from owrx.metrics import Metrics, CounterMetric
logger = logging.getLogger(__name__)
@ -20,6 +22,9 @@ class PskReporterDummy(object):
def spot(self, spot):
pass
def cancelTimer(self):
pass
class PskReporter(object):
sharedInstance = None
@ -37,24 +42,46 @@ class PskReporter(object):
PskReporter.sharedInstance = PskReporterDummy()
return PskReporter.sharedInstance
@staticmethod
def stop():
if PskReporter.sharedInstance:
PskReporter.sharedInstance.cancelTimer()
def __init__(self):
self.spots = []
self.spotLock = threading.Lock()
self.uploader = Uploader()
self.scheduler = scheduler(time.time, time.sleep)
self.scheduleNextUpload()
threading.Thread(target=self.scheduler.run).start()
self.timer = None
metrics = Metrics.getSharedInstance()
self.dupeCounter = CounterMetric()
metrics.addMetric("pskreporter.duplicates", self.dupeCounter)
self.spotCounter = CounterMetric()
metrics.addMetric("pskreporter.spots", self.spotCounter)
def scheduleNextUpload(self):
if self.timer:
return
delay = PskReporter.interval + random.uniform(0, 30)
logger.debug("scheduling next pskreporter upload in %f seconds", delay)
self.scheduler.enter(delay, 1, self.upload)
self.timer = threading.Timer(delay, self.upload)
self.timer.start()
def spotEquals(self, s1, s2):
keys = ["callsign", "timestamp", "locator", "mode", "msg"]
return reduce(and_, map(lambda key: s1[key] == s2[key], keys))
def spot(self, spot):
if not spot["mode"] in PskReporter.supportedModes:
return
with self.spotLock:
if any(x for x in self.spots if self.spotEquals(spot, x)):
# dupe
self.dupeCounter.inc()
else:
self.spotCounter.inc()
self.spots.append(spot)
self.scheduleNextUpload()
def upload(self):
try:
@ -67,8 +94,13 @@ class PskReporter(object):
except Exception:
logger.exception("Failed to upload spots")
self.timer = None
self.scheduleNextUpload()
def cancelTimer(self):
if self.timer:
self.timer.cancel()
class Uploader(object):
receieverDelimiter = [0x99, 0x92]

View File

@ -1,7 +1,7 @@
import threading
import socket
from owrx.socket import getAvailablePort
from datetime import datetime, timezone, timedelta
from owrx.source import SdrService
from owrx.source import SdrService, SdrSource
from owrx.bands import Bandplan
from csdr import dsp, output
from owrx.wsjt import WsjtParser
@ -110,11 +110,15 @@ class ServiceScheduler(object):
def __init__(self, source, schedule):
self.source = source
self.schedule = Schedule.parse(schedule)
self.active = False
self.source.addClient(self)
self.selectionTimer = None
self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange)
self.scheduleSelection()
def shutdown(self):
self.cancelTimer()
self.source.removeClient(self)
def scheduleSelection(self, time=None):
seconds = 10
if time is not None:
@ -128,22 +132,25 @@ class ServiceScheduler(object):
if self.selectionTimer:
self.selectionTimer.cancel()
def isActive(self):
return self.active
def getClientClass(self):
return SdrSource.CLIENT_BACKGROUND
def onSdrAvailable(self):
pass
def onSdrUnavailable(self):
def onStateChange(self, state):
if state == SdrSource.STATE_STOPPING:
self.scheduleSelection()
def onSdrFailed(self):
elif state == SdrSource.STATE_FAILED:
self.cancelTimer()
def onBusyStateChange(self, state):
if state == SdrSource.BUSYSTATE_IDLE:
self.scheduleSelection()
def onFrequencyChange(self, name, value):
self.scheduleSelection()
def selectProfile(self):
self.active = False
if self.source.hasActiveClients():
logger.debug("source has active clients; not touching")
if self.source.hasClients(SdrSource.CLIENT_USER):
logger.debug("source has active users; not touching")
return
logger.debug("source seems to be idle, selecting profile for background services")
entry = self.schedule.getCurrentEntry()
@ -159,7 +166,6 @@ class ServiceScheduler(object):
self.scheduleSelection(entry.getScheduledEnd())
try:
self.active = True
self.source.activateProfile(entry.getProfile())
self.source.start()
except KeyError:
@ -177,32 +183,35 @@ class ServiceHandler(object):
props.collect("center_freq", "samp_rate").wire(self.onFrequencyChange)
if self.source.isAvailable():
self.scheduleServiceStartup()
self.scheduler = None
if "schedule" in props:
ServiceScheduler(self.source, props["schedule"])
self.scheduler = ServiceScheduler(self.source, props["schedule"])
def isActive(self):
return False
def getClientClass(self):
return SdrSource.CLIENT_INACTIVE
def onSdrAvailable(self):
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
self.scheduleServiceStartup()
def onSdrUnavailable(self):
elif state == SdrSource.STATE_STOPPING:
logger.debug("sdr source becoming unavailable; stopping services.")
self.stopServices()
def onSdrFailed(self):
elif state == SdrSource.STATE_FAILED:
logger.debug("sdr source failed; stopping services.")
self.stopServices()
def onBusyStateChange(self, state):
pass
def isSupported(self, mode):
# TODO this should be in a more central place (the frontend also needs this)
requirements = {
'ft8': 'wsjt-x',
'ft4': 'wsjt-x',
'jt65': 'wsjt-x',
'jt9': 'wsjt-x',
'wspr': 'wsjt-x',
'packet': 'packet',
"ft8": "wsjt-x",
"ft4": "wsjt-x",
"jt65": "wsjt-x",
"jt9": "wsjt-x",
"wspr": "wsjt-x",
"packet": "packet",
}
fd = FeatureDetector()
@ -214,6 +223,12 @@ class ServiceHandler(object):
return mode in available
def shutdown(self):
self.stopServices()
self.source.removeClient(self)
if self.scheduler:
self.scheduler.shutdown()
def stopServices(self):
with self.lock:
services = self.services
@ -234,14 +249,6 @@ class ServiceHandler(object):
self.startupTimer = threading.Timer(10, self.updateServices)
self.startupTimer.start()
def getAvailablePort(self):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
s.close()
return port
def updateServices(self):
logger.debug("re-scheduling services due to sdr changes")
self.stopServices()
@ -282,13 +289,15 @@ class ServiceHandler(object):
resampler_props["center_freq"] = cf
# TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths
resampler_props["samp_rate"] = bw + 24000
resampler = Resampler(resampler_props, self.getAvailablePort(), self.source)
resampler = Resampler(resampler_props, getAvailablePort(), self.source)
resampler.start()
self.services.append(resampler)
for dial in group:
self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler))
# resampler goes in after the services since it must not be shutdown as long as the services are still running
self.services.append(resampler)
def optimizeResampling(self, freqs, bandwidth):
freqs = sorted(freqs, key=lambda f: f["frequency"])
distances = [
@ -370,6 +379,7 @@ class AprsHandler(object):
class Services(object):
handlers = []
@staticmethod
def start():
if not PropertyManager.getSharedInstance()["services_enabled"]:
@ -380,7 +390,7 @@ class Services(object):
@staticmethod
def stop():
for handler in Services.handlers:
handler.stopServices()
handler.shutdown()
Services.handlers = []

10
owrx/socket.py Normal file
View File

@ -0,0 +1,10 @@
import socket
def getAvailablePort():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
s.close()
return port

View File

@ -5,6 +5,7 @@ from owrx.meta import MetaParser
from owrx.wsjt import WsjtParser
from owrx.aprs import AprsParser
from owrx.metrics import Metrics, DirectMetric
from owrx.socket import getAvailablePort
import threading
import csdr
import time
@ -99,21 +100,28 @@ class SdrService(object):
class SdrSource(object):
STATE_STOPPED = 0
STATE_STARTING = 1
STATE_RUNNING = 2
STATE_STOPPING = 3
STATE_TUNING = 4
STATE_FAILED = 5
BUSYSTATE_IDLE = 0
BUSYSTATE_BUSY = 1
CLIENT_INACTIVE = 0
CLIENT_BACKGROUND = 1
CLIENT_USER = 2
def __init__(self, id, props, port):
self.id = id
self.props = props
self.profile_id = None
self.activateProfile()
self.rtlProps = self.props.collect(
"samp_rate", "nmux_memory", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain"
).defaults(PropertyManager.getSharedInstance())
self.rtlProps = self.props.collect(*self.getEventNames()).defaults(PropertyManager.getSharedInstance())
self.wireEvents()
def restart(name, value):
logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value))
self.stop()
self.start()
self.rtlProps.wire(restart)
self.port = port
self.monitor = None
self.clients = []
@ -122,6 +130,30 @@ class SdrSource(object):
self.process = None
self.modificationLock = threading.Lock()
self.failed = False
self.state = SdrSource.STATE_STOPPED
self.busyState = SdrSource.BUSYSTATE_IDLE
def getEventNames(self):
return [
"samp_rate",
"nmux_memory",
"center_freq",
"ppm",
"rf_gain",
"lna_gain",
"rf_amp",
"antenna",
"if_gain",
"lfo_offset",
]
def wireEvents(self):
def restart(name, value):
logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value))
self.stop()
self.start()
self.rtlProps.wire(restart)
# override this in subclasses
def getCommand(self):
@ -136,10 +168,11 @@ class SdrSource(object):
if profile_id is None:
profile_id = list(profiles.keys())[0]
if profile_id == self.profile_id:
return;
return
logger.debug("activating profile {0}".format(profile_id))
self.profile_id = profile_id
profile = profiles[profile_id]
self.props["profile_id"] = profile_id
for (key, value) in profile.items():
# skip the name, that would overwrite the source name.
if key == "name":
@ -164,6 +197,17 @@ class SdrSource(object):
def getPort(self):
return self.port
def useNmux(self):
return True
def getCommandValues(self):
dict = self.rtlProps.collect(*self.getEventNames()).__dict__()
if "lfo_offset" in dict and dict["lfo_offset"] is not None:
dict["tuner_freq"] = dict["center_freq"] + dict["lfo_offset"]
else:
dict["tuner_freq"] = dict["center_freq"]
return dict
def start(self):
self.modificationLock.acquire()
if self.monitor:
@ -172,16 +216,13 @@ class SdrSource(object):
props = self.rtlProps
start_sdr_command = self.getCommand().format(
**props.collect(
"samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain"
).__dict__()
)
cmd = self.getCommand().format(**self.getCommandValues())
format_conversion = self.getFormatConversion()
if format_conversion is not None:
start_sdr_command += " | " + format_conversion
cmd += " | " + format_conversion
if self.useNmux():
nmux_bufcnt = nmux_bufsize = 0
while nmux_bufsize < props["samp_rate"] / 4:
nmux_bufsize += 4096
@ -194,11 +235,12 @@ class SdrSource(object):
self.modificationLock.release()
return
logger.debug("nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt))
cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (
cmd = cmd + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (
nmux_bufsize,
nmux_bufcnt,
self.port,
)
self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp)
logger.info("Started rtl source: " + cmd)
@ -229,13 +271,14 @@ class SdrSource(object):
if not available:
self.failed = True
self.postStart()
self.modificationLock.release()
for c in self.clients:
if self.failed:
c.onSdrFailed()
else:
c.onSdrAvailable()
self.setState(SdrSource.STATE_FAILED if self.failed else SdrSource.STATE_RUNNING)
def postStart(self):
pass
def isAvailable(self):
return self.monitor is not None
@ -244,8 +287,7 @@ class SdrSource(object):
return self.failed
def stop(self):
for c in self.clients:
c.onSdrUnavailable()
self.setState(SdrSource.STATE_STOPPING)
self.modificationLock.acquire()
@ -260,24 +302,33 @@ class SdrSource(object):
self.sleepOnRestart()
self.modificationLock.release()
self.setState(SdrSource.STATE_STOPPED)
def sleepOnRestart(self):
pass
def hasActiveClients(self):
activeClients = [c for c in self.clients if c.isActive()]
return len(activeClients) > 0
def hasClients(self, *args):
clients = [c for c in self.clients if c.getClientClass() in args]
return len(clients) > 0
def addClient(self, c):
self.clients.append(c)
if self.hasActiveClients():
hasUsers = self.hasClients(SdrSource.CLIENT_USER)
hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND)
if hasUsers or hasBackgroundTasks:
self.start()
self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
def removeClient(self, c):
try:
self.clients.remove(c)
except ValueError:
pass
if not self.hasActiveClients():
hasUsers = self.hasClients(SdrSource.CLIENT_USER)
hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND)
self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE)
if not hasUsers and not hasBackgroundTasks:
self.stop()
def addSpectrumClient(self, c):
@ -299,6 +350,20 @@ class SdrSource(object):
for c in self.spectrumClients:
c.write_spectrum_data(data)
def setState(self, state):
if state == self.state:
return
self.state = state
for c in self.clients:
c.onStateChange(state)
def setBusyState(self, state):
if state == self.busyState:
return
self.busyState = state
for c in self.clients:
c.onBusyStateChange(state)
class Resampler(SdrSource):
def __init__(self, props, port, sdr):
@ -321,6 +386,8 @@ class Resampler(SdrSource):
self.modificationLock.release()
return
self.setState(SdrSource.STATE_STARTING)
props = self.rtlProps
resampler_command = [
@ -380,19 +447,135 @@ class Resampler(SdrSource):
self.modificationLock.release()
for c in self.clients:
if self.failed:
c.onSdrFailed()
else:
c.onSdrAvailable()
self.setState(SdrSource.STATE_FAILED if self.failed else SdrSource.STATE_RUNNING)
def activateProfile(self, profile_id=None):
pass
class ConnectorSource(SdrSource):
def __init__(self, id, props, port):
super().__init__(id, props, port)
self.controlSocket = None
self.controlPort = getAvailablePort()
def sendControlMessage(self, prop, value):
logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value))
self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode())
def wireEvents(self):
def reconfigure(prop, value):
if self.monitor is None:
return
if (
(prop == "center_freq" or prop == "lfo_offset")
and "lfo_offset" in self.rtlProps
and self.rtlProps["lfo_offset"] is not None
):
freq = self.rtlProps["center_freq"] + self.rtlProps["lfo_offset"]
self.sendControlMessage("center_freq", freq)
else:
self.sendControlMessage(prop, value)
self.rtlProps.wire(reconfigure)
def postStart(self):
logger.debug("opening control socket...")
self.controlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.controlSocket.connect(("localhost", self.controlPort))
def stop(self):
super().stop()
if self.controlSocket:
self.controlSocket.close()
self.controlSocket = None
def getFormatConversion(self):
return None
def useNmux(self):
return False
class RtlSdrConnectorSource(ConnectorSource):
def getEventNames(self):
return [
"samp_rate",
"center_freq",
"ppm",
"rf_gain",
"device",
"iqswap",
"lfo_offset",
]
def getCommand(self):
cmd = (
"rtl_connector -p {port} -c {controlPort}".format(port=self.port, controlPort=self.controlPort)
+ " -s {samp_rate} -f {tuner_freq} -g {rf_gain} -P {ppm}"
)
if "device" in self.rtlProps and self.rtlProps["device"] is not None:
cmd += ' -d "{device}"'
if self.rtlProps["iqswap"]:
cmd += " -i"
return cmd
class SdrplayConnectorSource(ConnectorSource):
def getEventNames(self):
return [
"samp_rate",
"center_freq",
"ppm",
"rf_gain",
"antenna",
"device",
"iqswap",
"lfo_offset",
]
def getCommand(self):
cmd = (
"soapy_connector -p {port} -c {controlPort}".format(port=self.port, controlPort=self.controlPort)
+ ' -s {samp_rate} -f {tuner_freq} -g "{rf_gain}" -P {ppm} -a "{antenna}"'
)
if "device" in self.rtlProps and self.rtlProps["device"] is not None:
cmd += ' -d "{device}"'
if self.rtlProps["iqswap"]:
cmd += " -i"
return cmd
class AirspyConnectorSource(ConnectorSource):
def getEventNames(self):
return [
"samp_rate",
"center_freq",
"ppm",
"rf_gain",
"device",
"iqswap",
"lfo_offset",
"bias_tee",
]
def getCommand(self):
cmd = (
"soapy_connector -p {port} -c {controlPort}".format(port=self.port, controlPort=self.controlPort)
+ ' -s {samp_rate} -f {tuner_freq} -g "{rf_gain}" -P {ppm}'
)
if "device" in self.rtlProps and self.rtlProps["device"] is not None:
cmd += ' -d "{device}"'
if self.rtlProps["iqswap"]:
cmd += " -i"
if self.rtlProps["bias_tee"]:
cmd += " -t biastee=true"
return cmd
class RtlSdrSource(SdrSource):
def getCommand(self):
return "rtl_sdr -s {samp_rate} -f {center_freq} -p {ppm} -g {rf_gain} -"
return "rtl_sdr -s {samp_rate} -f {tuner_freq} -p {ppm} -g {rf_gain} -"
def getFormatConversion(self):
return "csdr convert_u8_f"
@ -400,7 +583,7 @@ class RtlSdrSource(SdrSource):
class HackrfSource(SdrSource):
def getCommand(self):
return "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-"
return "hackrf_transfer -s {samp_rate} -f {tuner_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-"
def getFormatConversion(self):
return "csdr convert_s8_f"
@ -408,7 +591,7 @@ class HackrfSource(SdrSource):
class SdrplaySource(SdrSource):
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 {tuner_freq} -p {ppm}"
gainMap = {"rf_gain": "RFGR", "if_gain": "IFGR"}
gains = [
"{0}={{{1}}}".format(gainMap[name], name)
@ -428,7 +611,7 @@ class SdrplaySource(SdrSource):
class AirspySource(SdrSource):
def getCommand(self):
frequency = self.props["center_freq"] / 1e6
frequency = self.props["tuner_freq"] / 1e6
command = "airspy_rx"
command += " -f{0}".format(frequency)
command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}"
@ -509,17 +692,17 @@ class SpectrumThread(csdr.output):
c.cancel()
self.subscriptions = []
def isActive(self):
return True
def getClientClass(self):
return SdrSource.CLIENT_USER
def onSdrAvailable(self):
def onStateChange(self, state):
if state in [SdrSource.STATE_STOPPING, SdrSource.STATE_FAILED]:
self.dsp.stop()
elif state == SdrSource.STATE_RUNNING:
self.dsp.start()
def onSdrUnavailable(self):
self.dsp.stop()
def onSdrFailed(self):
self.dsp.stop()
def onBusyStateChange(self, state):
pass
class DspManager(csdr.output):
@ -644,22 +827,24 @@ class DspManager(csdr.output):
def setProperty(self, prop, value):
self.localProps.getProperty(prop).setValue(value)
def isActive(self):
return True
def getClientClass(self):
return SdrSource.CLIENT_USER
def onSdrAvailable(self):
logger.debug("received onSdrAvailable, attempting DspSource restart")
def onStateChange(self, state):
if state == SdrSource.STATE_RUNNING:
logger.debug("received STATE_RUNNING, attempting DspSource restart")
self.dsp.start()
def onSdrUnavailable(self):
logger.debug("received onSdrUnavailable, shutting down DspSource")
elif state == SdrSource.STATE_STOPPING:
logger.debug("received STATE_STOPPING, shutting down DspSource")
self.dsp.stop()
def onSdrFailed(self):
logger.debug("received onSdrFailed, shutting down DspSource")
elif state == SdrSource.STATE_FAILED:
logger.debug("received STATE_FAILED, shutting down DspSource")
self.dsp.stop()
self.handler.handleSdrFailure("sdr device failed")
def onBusyStateChange(self, state):
pass
class CpuUsageThread(threading.Thread):
sharedInstance = None

View File

@ -120,7 +120,6 @@ class WebSocketConnection(object):
self._sendBytes(data_to_send)
def _sendBytes(self, data_to_send):
def chunks(l, n):
"""Yield successive n-sized chunks from l."""
for i in range(0, len(l), n):

View File

@ -1,8 +1,6 @@
import threading
import wave
from datetime import datetime, timedelta, date, timezone
import time
import sched
from datetime import datetime, timedelta, timezone
import subprocess
import os
from multiprocessing.connection import Pipe
@ -93,8 +91,7 @@ class WsjtChopper(threading.Thread):
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.schedulerLock = threading.Lock()
self.timer = None
(self.outputReader, self.outputWriter) = Pipe()
self.doRun = True
super().__init__()
@ -110,27 +107,23 @@ class WsjtChopper(threading.Thread):
return filename, wavefile
def getNextDecodingTime(self):
t = datetime.now()
t = datetime.utcnow()
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()
return t
def startScheduler(self):
self._scheduleNextSwitch()
threading.Thread(target=self.scheduler.run).start()
def emptyScheduler(self):
with self.schedulerLock:
for event in self.scheduler.queue:
self.scheduler.cancel(event)
def cancelTimer(self):
if self.timer:
self.timer.cancel()
def _scheduleNextSwitch(self):
with self.schedulerLock:
if self.doRun:
self.scheduler.enterabs(self.getNextDecodingTime(), 1, self.switchFiles)
delta = self.getNextDecodingTime() - datetime.utcnow()
self.timer = threading.Timer(delta.total_seconds(), self.switchFiles)
self.timer.start()
def switchFiles(self):
self.switchingLock.acquire()
@ -169,7 +162,7 @@ class WsjtChopper(threading.Thread):
def run(self) -> None:
logger.debug("WSJT chopper starting up")
self.startScheduler()
self._scheduleNextSwitch()
while self.doRun:
data = self.source.read(256)
if data is None or (isinstance(data, bytes) and len(data) == 0):
@ -182,7 +175,7 @@ class WsjtChopper(threading.Thread):
logger.debug("WSJT chopper shutting down")
self.outputReader.close()
self.outputWriter.close()
self.emptyScheduler()
self.cancelTimer()
try:
os.unlink(self.wavefilename)
except Exception:

View File

@ -3,6 +3,14 @@ set -euxo pipefail
ARCH=$(uname -m)
ALL_ARCHS="x86_64 armv7l"
for image in openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-full openwebrx; do
docker push jketterl/$image:$ARCH
IMAGE_LIST=""
for a in $ALL_ARCHS; do
IMAGE_LIST="$IMAGE_LIST jketterl/$image:$a"
done
docker manifest create --amend jketterl/$image:latest $IMAGE_LIST
docker manifest push --purge jketterl/$image:latest
done

View File

@ -4,6 +4,7 @@
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>
Copyright (c) 2019 by Jakob Ketterl <dd5jfk@darc.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as