Merge branch 'develop' into packet
This commit is contained in:
commit
5fbbd897b5
99
README.md
99
README.md
@ -9,38 +9,42 @@ OpenWebRX is a multi-user SDR receiver software with a web interface.
|
||||
|
||||
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,
|
||||
- waterfall display can be shifted back in time,
|
||||
- it extensively uses HTML5 features like WebSocket, Web Audio API, and <canvas>,
|
||||
- it works in Google Chrome, Chromium (above version 37) and Mozilla Firefox (above version 28),
|
||||
- currently supports RTL-SDR, HackRF, SDRplay, AirSpy and many other devices, see the <a href="https://github.com/simonyiszk/openwebrx/wiki/">OpenWebRX Wiki</a>,
|
||||
- it has a 3D waterfall display:
|
||||
- it extensively uses HTML5 features like WebSocket, Web Audio API, and Canvas
|
||||
- it works in Google Chrome, Chromium and Mozilla Firefox
|
||||
- currently supports RTL-SDR, HackRF, SDRplay, AirSpy
|
||||
- Multiple SDR devices can be used simultaneously
|
||||
- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF)
|
||||
- [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN)
|
||||
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9)
|
||||
|
||||
![OpenWebRX 3D waterfall](http://blog.sdr.hu/images/openwebrx/screenshot-3d.gif)
|
||||
**News (2019-07-21 by DD5JFK)**
|
||||
- Latest Features:
|
||||
- More WSJT-X modes have been added, including the new FT4 mode
|
||||
- I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the dial frequency for digital modes
|
||||
- fixed some bugs in the websocket communication which broke the map
|
||||
|
||||
**News (2015-08-18)**
|
||||
- My BSc. thesis written on OpenWebRX is <a href="https://sdr.hu/static/bsc-thesis.pdf">available here.</a>
|
||||
- Several bugs were fixed to improve reliability and stability.
|
||||
- OpenWebRX now supports compression of audio and waterfall stream, so the required network uplink bandwidth has been decreased from 2 Mbit/s to about 200 kbit/s per client! (Measured with the default settings. It is also dependent on `fft_size`.)
|
||||
- OpenWebRX now uses <a href="https://github.com/simonyiszk/csdr#sdrjs">sdr.js</a> (*libcsdr* compiled to JavaScript) for some client-side DSP tasks.
|
||||
- Receivers can now be listed on <a href="http://sdr.hu/">SDR.hu</a>.
|
||||
- License for OpenWebRX is now Affero GPL v3.
|
||||
**News (2019-07-13 by DD5JFK)**
|
||||
- Latest Features:
|
||||
- FT8 Integration (using wsjt-x demodulators)
|
||||
- New Map Feature that shows both decoded grid squares from FT8 and Locations decoded from YSF digital voice
|
||||
- New Feature report that will show what functionality is available
|
||||
- There's a new Raspbian SD Card image available (see below)
|
||||
|
||||
**News (2016-02-14)**
|
||||
- The DDC in *csdr* has been manually optimized for ARM NEON, so it runs around 3 times faster on the Raspberry Pi 2 than before.
|
||||
- Also we use *ncat* instead of *rtl_mus*, and it is 3 times faster in some cases.
|
||||
- OpenWebRX now supports URLs like: `http://localhost:8073/#freq=145555000,mod=usb`
|
||||
- UI improvements were made, thanks to John Seamons and Gnoxter.
|
||||
**News (2019-06-30 by DD5JFK)**
|
||||
- I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near future. Please check this place for updates.
|
||||
- My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official version.
|
||||
- I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there!
|
||||
- This version sports the following new and amazing features:
|
||||
- Support of multiple SDR devices simultaneously
|
||||
- Support for multiple profiles per SDR that allow the user to listen to different frequencies
|
||||
- Support for digital voice decoding
|
||||
- Feature detection that will disable functionality when dependencies are not available (if you're missing the digital buttons, this is probably why)
|
||||
- Raspbian SD Card Images and Docker builds available (see below)
|
||||
- I am currently working on the feature set for a stable release, but you are more than welcome to test development versions!
|
||||
|
||||
**News (2017-04-04)**
|
||||
- *ncat* has been replaced with a custom implementation called *nmux* due to a bug that caused regular crashes on some machines. The *nmux* tool is part of the *csdr* package.
|
||||
- Most consumer SDR devices are supported via <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*!
|
||||
> When upgrading OpenWebRX, please make sure that you also upgrade *csdr* and *digiham*!
|
||||
|
||||
## OpenWebRX servers on SDR.hu
|
||||
|
||||
@ -50,22 +54,49 @@ It has the following features:
|
||||
|
||||
## Setup
|
||||
|
||||
OpenWebRX currently requires Linux and python 2.7 to run.
|
||||
### Raspberry Pi SD Card Images
|
||||
|
||||
Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-07-13-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+.
|
||||
|
||||
This is based off the Raspbian Lite distribution, so [their installation instructions](https://www.raspberrypi.org/documentation/installation/installing-images/) apply.
|
||||
|
||||
Please note: I have not updated this to include the Raspberry Pi 4 yet. (It seems to be impossible to build Rasbpian Buster images on x86 hardware right now. Stay tuned!)
|
||||
|
||||
Once you have booted a Raspberry with the SD Card, it will appear in your network with the hostname "openwebrx", which should make it available as http://openwebrx:8073/ on most networks. This may vary depending on your specific setup.
|
||||
|
||||
For Digital voice, the minimum requirement right now seems to be a Rasbperry Pi 3B+. I would like to work on optimizing this for lower specs, but at this point I am not sure how much can be done.
|
||||
|
||||
### Docker Images
|
||||
|
||||
For those familiar with docker, I am providing [recent builds and Releases for both x86 and arm processors on the Docker hub](https://hub.docker.com/r/jketterl/openwebrx). You can find a short introduction there.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
OpenWebRX currently requires Linux and python 3 to run.
|
||||
|
||||
First you will need to install the dependencies:
|
||||
|
||||
- <a href="https://github.com/simonyiszk/csdr">libcsdr</a>
|
||||
- <a href="http://sdr.osmocom.org/trac/wiki/rtl-sdr">rtl-sdr</a>
|
||||
- [csdr](https://github.com/simonyiszk/csdr)
|
||||
- [rtl-sdr](http://sdr.osmocom.org/trac/wiki/rtl-sdr)
|
||||
|
||||
Optional Dependencies if you want to be able to listen do digital voice:
|
||||
|
||||
- [digiham](https://github.com/jketterl/digiham)
|
||||
- [dsd](https://github.com/f4exb/dsdcc)
|
||||
|
||||
Optional Dependency if you want to decode WSJT-X modes:
|
||||
|
||||
- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html)
|
||||
|
||||
After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server:
|
||||
|
||||
python openwebrx.py
|
||||
|
||||
./openwebrx.py
|
||||
|
||||
You can now open the GUI at <a href="http://localhost:8073">http://localhost:8073</a>.
|
||||
|
||||
Please note that the server is also listening on the following ports (on localhost only):
|
||||
|
||||
- port 4951 for the multi-user I/Q server.
|
||||
- ports 4950 to 4960 for the multi-user I/Q servers.
|
||||
|
||||
Now the next step is to customize the parameters of your server in `config_webrx.py`.
|
||||
|
||||
@ -86,8 +117,6 @@ If you have any problems installing OpenWebRX, you should check out the <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.
|
||||
|
||||
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
|
||||
|
||||
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
189
bands.json
Normal 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
|
||||
}
|
||||
]
|
136
config_webrx.py
136
config_webrx.py
@ -35,21 +35,21 @@ config_webrx: configuration options for OpenWebRX
|
||||
# https://github.com/simonyiszk/openwebrx/wiki
|
||||
|
||||
# ==== Server settings ====
|
||||
web_port=8073
|
||||
max_clients=20
|
||||
web_port = 8073
|
||||
max_clients = 20
|
||||
|
||||
# ==== Web GUI configuration ====
|
||||
receiver_name="[Callsign]"
|
||||
receiver_location="Budapest, Hungary"
|
||||
receiver_qra="JN97ML"
|
||||
receiver_asl=200
|
||||
receiver_ant="Longwire"
|
||||
receiver_device="RTL-SDR"
|
||||
receiver_admin="example@example.com"
|
||||
receiver_gps=(47.000000,19.000000)
|
||||
photo_height=350
|
||||
photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory"
|
||||
photo_desc="""
|
||||
receiver_name = "[Callsign]"
|
||||
receiver_location = "Budapest, Hungary"
|
||||
receiver_qra = "JN97ML"
|
||||
receiver_asl = 200
|
||||
receiver_ant = "Longwire"
|
||||
receiver_device = "RTL-SDR"
|
||||
receiver_admin = "example@example.com"
|
||||
receiver_gps = (47.000000, 19.000000)
|
||||
photo_height = 350
|
||||
photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory"
|
||||
photo_desc = """
|
||||
You can add your own background photo and receiver information.<br />
|
||||
Receiver is operated by: <a href="mailto:%[RX_ADMIN]">%[RX_ADMIN]</a><br/>
|
||||
Device: %[RX_DEVICE]<br />
|
||||
@ -64,18 +64,20 @@ Website: <a href="http://localhost" target="_blank">http://localhost</a>
|
||||
sdrhu_key = ""
|
||||
# 3. Set this setting to True to enable listing:
|
||||
sdrhu_public_listing = False
|
||||
server_hostname="localhost"
|
||||
server_hostname = "localhost"
|
||||
|
||||
# ==== DSP/RX settings ====
|
||||
fft_fps=9
|
||||
fft_size=4096 #Should be power of 2
|
||||
fft_voverlap_factor=0.3 #If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
|
||||
fft_fps = 9
|
||||
fft_size = 4096 # Should be power of 2
|
||||
fft_voverlap_factor = (
|
||||
0.3
|
||||
) # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram.
|
||||
|
||||
audio_compression="adpcm" #valid values: "adpcm", "none"
|
||||
fft_compression="adpcm" #valid values: "adpcm", "none"
|
||||
audio_compression = "adpcm" # valid values: "adpcm", "none"
|
||||
fft_compression = "adpcm" # valid values: "adpcm", "none"
|
||||
|
||||
digimodes_enable=True #Decoding digimodes come with higher CPU usage.
|
||||
digimodes_fft_size=1024
|
||||
digimodes_enable = True # Decoding digimodes come with higher CPU usage.
|
||||
digimodes_fft_size = 1024
|
||||
|
||||
# determines the quality, and thus the cpu usage, for the ambe codec used by digital voice modes
|
||||
# if you're running on a Raspi (up to 3B+) you'll want to leave this on 1
|
||||
@ -116,7 +118,7 @@ sdrs = {
|
||||
"rf_gain": 30,
|
||||
"samp_rate": 2400000,
|
||||
"start_freq": 439275000,
|
||||
"start_mod": "nfm"
|
||||
"start_mod": "nfm",
|
||||
},
|
||||
"2m": {
|
||||
"name": "2m komplett",
|
||||
@ -124,9 +126,9 @@ sdrs = {
|
||||
"rf_gain": 30,
|
||||
"samp_rate": 2400000,
|
||||
"start_freq": 145725000,
|
||||
"start_mod": "nfm"
|
||||
}
|
||||
}
|
||||
"start_mod": "nfm",
|
||||
},
|
||||
},
|
||||
},
|
||||
"sdrplay": {
|
||||
"name": "SDRPlay RSP2",
|
||||
@ -134,39 +136,39 @@ sdrs = {
|
||||
"ppm": 0,
|
||||
"profiles": {
|
||||
"20m": {
|
||||
"name":"20m",
|
||||
"name": "20m",
|
||||
"center_freq": 14150000,
|
||||
"rf_gain": 4,
|
||||
"samp_rate": 500000,
|
||||
"start_freq": 14070000,
|
||||
"start_mod": "usb",
|
||||
"antenna": "Antenna A"
|
||||
"antenna": "Antenna A",
|
||||
},
|
||||
"30m": {
|
||||
"name":"30m",
|
||||
"name": "30m",
|
||||
"center_freq": 10125000,
|
||||
"rf_gain": 4,
|
||||
"samp_rate": 250000,
|
||||
"start_freq": 10142000,
|
||||
"start_mod": "usb"
|
||||
"start_mod": "usb",
|
||||
},
|
||||
"40m": {
|
||||
"name":"40m",
|
||||
"name": "40m",
|
||||
"center_freq": 7100000,
|
||||
"rf_gain": 4,
|
||||
"samp_rate": 500000,
|
||||
"start_freq": 7070000,
|
||||
"start_mod": "usb",
|
||||
"antenna": "Antenna A"
|
||||
"antenna": "Antenna A",
|
||||
},
|
||||
"80m": {
|
||||
"name":"80m",
|
||||
"name": "80m",
|
||||
"center_freq": 3650000,
|
||||
"rf_gain": 4,
|
||||
"samp_rate": 500000,
|
||||
"start_freq": 3570000,
|
||||
"start_mod": "usb",
|
||||
"antenna": "Antenna A"
|
||||
"antenna": "Antenna A",
|
||||
},
|
||||
"49m": {
|
||||
"name": "49m Broadcast",
|
||||
@ -175,42 +177,43 @@ sdrs = {
|
||||
"samp_rate": 500000,
|
||||
"start_freq": 6070000,
|
||||
"start_mod": "am",
|
||||
"antenna": "Antenna A"
|
||||
}
|
||||
}
|
||||
"antenna": "Antenna A",
|
||||
},
|
||||
},
|
||||
},
|
||||
# this one is just here to test feature detection
|
||||
"test": {
|
||||
"type": "test"
|
||||
}
|
||||
"test": {"type": "test"},
|
||||
}
|
||||
|
||||
# ==== Misc settings ====
|
||||
|
||||
client_audio_buffer_size = 5
|
||||
#increasing client_audio_buffer_size will:
|
||||
# increasing client_audio_buffer_size will:
|
||||
# - also increase the latency
|
||||
# - decrease the chance of audio underruns
|
||||
|
||||
iq_port_range = [4950, 4960] #TCP port for range ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default.
|
||||
iq_port_range = [
|
||||
4950,
|
||||
4960,
|
||||
] # TCP port for range ncat to listen on. It will send I/Q data over its connections, for internal use in OpenWebRX. It is only accessible from the localhost by default.
|
||||
|
||||
# ==== Color themes ====
|
||||
|
||||
#A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels
|
||||
# A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels
|
||||
|
||||
### default theme by teejez:
|
||||
waterfall_colors = [0x000000ff,0x0000ffff,0x00ffffff,0x00ff00ff,0xffff00ff,0xff0000ff,0xff00ffff,0xffffffff]
|
||||
waterfall_min_level = -88 #in dB
|
||||
waterfall_colors = [0x000000FF, 0x0000FFFF, 0x00FFFFFF, 0x00FF00FF, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFFFF]
|
||||
waterfall_min_level = -88 # in dB
|
||||
waterfall_max_level = -20
|
||||
waterfall_auto_level_margin = (5, 40)
|
||||
### old theme by HA7ILM:
|
||||
#waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]"
|
||||
#waterfall_min_level = -115 #in dB
|
||||
#waterfall_max_level = 0
|
||||
#waterfall_auto_level_margin = (20, 30)
|
||||
# waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]"
|
||||
# waterfall_min_level = -115 #in dB
|
||||
# waterfall_max_level = 0
|
||||
# waterfall_auto_level_margin = (20, 30)
|
||||
##For the old colors, you might also want to set [fft_voverlap_factor] to 0.
|
||||
|
||||
#Note: When the auto waterfall level button is clicked, the following happens:
|
||||
# Note: When the auto waterfall level button is clicked, the following happens:
|
||||
# [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin[0]]
|
||||
# [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin[1]]
|
||||
#
|
||||
@ -219,14 +222,35 @@ waterfall_auto_level_margin = (5, 40)
|
||||
# current_max_power_level __|
|
||||
|
||||
# 3D view settings
|
||||
mathbox_waterfall_frequency_resolution = 128 #bins
|
||||
mathbox_waterfall_history_length = 10 #seconds
|
||||
mathbox_waterfall_colors = [0x000000ff,0x2e6893ff,0x69a5d0ff,0x214b69ff,0x9dc4e0ff,0xfff775ff,0xff8a8aff,0xb20000ff]
|
||||
mathbox_waterfall_frequency_resolution = 128 # bins
|
||||
mathbox_waterfall_history_length = 10 # seconds
|
||||
mathbox_waterfall_colors = [
|
||||
0x000000FF,
|
||||
0x2E6893FF,
|
||||
0x69A5D0FF,
|
||||
0x214B69FF,
|
||||
0x9DC4E0FF,
|
||||
0xFFF775FF,
|
||||
0xFF8A8AFF,
|
||||
0xB20000FF,
|
||||
]
|
||||
|
||||
# === Experimental settings ===
|
||||
#Warning! The settings below are very experimental.
|
||||
csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr.
|
||||
# Warning! The settings below are very experimental.
|
||||
csdr_dynamic_bufsize = False # This allows you to change the buffering mode of csdr.
|
||||
csdr_print_bufsizes = False # This prints the buffer sizes used for csdr processes.
|
||||
csdr_through = False # Setting this True will print out how much data is going into the DSP chains.
|
||||
csdr_through = False # Setting this True will print out how much data is going into the DSP chains.
|
||||
|
||||
nmux_memory = 50 #in megabytes. This sets the approximate size of the circular buffer used by nmux.
|
||||
nmux_memory = 50 # in megabytes. This sets the approximate size of the circular buffer used by nmux.
|
||||
|
||||
google_maps_api_key = ""
|
||||
|
||||
# how long should positions be visible on the map?
|
||||
# they will start fading out after half of that
|
||||
# in seconds; default: 2 hours
|
||||
map_position_retention_time = 2 * 60 * 60
|
||||
|
||||
temporary_directory = "/tmp"
|
||||
|
||||
services_enabled = False
|
||||
services_decoders = ["ft8", "ft4", "wspr"]
|
||||
|
492
csdr.py
Executable file → Normal file
492
csdr.py
Executable file → Normal file
@ -21,33 +21,56 @@ OpenWebRX csdr plugin: do the signal processing with csdr
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
from functools import partial
|
||||
from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class output(object):
|
||||
def add_output(self, type, read_fn):
|
||||
pass
|
||||
def reset(self):
|
||||
def send_output(self, t, read_fn):
|
||||
if not self.supports_type(t):
|
||||
# TODO rewrite the output mechanism in a way that avoids producing unnecessary data
|
||||
logger.warning("dumping output of type %s since it is not supported.", t)
|
||||
threading.Thread(target=self.pump(read_fn, lambda x: None)).start()
|
||||
return
|
||||
self.receive_output(t, read_fn)
|
||||
|
||||
def receive_output(self, t, read_fn):
|
||||
pass
|
||||
|
||||
def pump(self, read, write):
|
||||
def copy():
|
||||
run = True
|
||||
while run:
|
||||
data = read()
|
||||
if data is None or (isinstance(data, bytes) and len(data) == 0):
|
||||
run = False
|
||||
else:
|
||||
write(data)
|
||||
|
||||
return copy
|
||||
|
||||
def supports_type(self, t):
|
||||
return True
|
||||
|
||||
|
||||
class dsp(object):
|
||||
|
||||
def __init__(self, output):
|
||||
self.samp_rate = 250000
|
||||
self.output_rate = 11025 #this is default, and cannot be set at the moment
|
||||
self.output_rate = 11025 # this is default, and cannot be set at the moment
|
||||
self.fft_size = 1024
|
||||
self.fft_fps = 5
|
||||
self.offset_freq = 0
|
||||
self.low_cut = -4000
|
||||
self.high_cut = 4000
|
||||
self.bpf_transition_bw = 320 #Hz, and this is a constant
|
||||
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
|
||||
self.bpf_transition_bw = 320 # Hz, and this is a constant
|
||||
self.ddc_transition_bw_rate = 0.15 # of the IF sample rate
|
||||
self.running = False
|
||||
self.secondary_processes_running = False
|
||||
self.audio_compression = "none"
|
||||
@ -67,105 +90,165 @@ class dsp(object):
|
||||
self.secondary_fft_size = 1024
|
||||
self.secondary_process_fft = None
|
||||
self.secondary_process_demod = None
|
||||
self.pipe_names=["bpf_pipe", "shift_pipe", "squelch_pipe", "smeter_pipe", "meta_pipe", "iqtee_pipe",
|
||||
"iqtee2_pipe", "dmr_control_pipe"]
|
||||
self.secondary_pipe_names=["secondary_shift_pipe"]
|
||||
self.pipe_names = [
|
||||
"bpf_pipe",
|
||||
"shift_pipe",
|
||||
"squelch_pipe",
|
||||
"smeter_pipe",
|
||||
"meta_pipe",
|
||||
"iqtee_pipe",
|
||||
"iqtee2_pipe",
|
||||
"dmr_control_pipe",
|
||||
]
|
||||
self.secondary_pipe_names = ["secondary_shift_pipe"]
|
||||
self.secondary_offset_freq = 1000
|
||||
self.unvoiced_quality = 1
|
||||
self.modification_lock = threading.Lock()
|
||||
self.output = output
|
||||
self.temporary_directory = "/tmp"
|
||||
|
||||
def chain(self,which):
|
||||
chain ="nc -v 127.0.0.1 {nc_port} | "
|
||||
if self.csdr_dynamic_bufsize: chain += "csdr setbuf {start_bufsize} | "
|
||||
if self.csdr_through: chain +="csdr through | "
|
||||
def set_temporary_directory(self, what):
|
||||
self.temporary_directory = what
|
||||
|
||||
def chain(self, which):
|
||||
chain = ["nc -v 127.0.0.1 {nc_port}"]
|
||||
if self.csdr_dynamic_bufsize:
|
||||
chain += ["csdr setbuf {start_bufsize}"]
|
||||
if self.csdr_through:
|
||||
chain += ["csdr through"]
|
||||
if which == "fft":
|
||||
chain += "csdr fft_cc {fft_size} {fft_block_size} | " + \
|
||||
("csdr logpower_cf -70 | " if self.fft_averages == 0 else "csdr logaveragepower_cf -70 {fft_size} {fft_averages} | ") + \
|
||||
"csdr fft_exchange_sides_ff {fft_size}"
|
||||
if self.fft_compression=="adpcm":
|
||||
chain += " | csdr compress_fft_adpcm_f_u8 {fft_size}"
|
||||
chain += [
|
||||
"csdr fft_cc {fft_size} {fft_block_size}",
|
||||
"csdr logpower_cf -70"
|
||||
if self.fft_averages == 0
|
||||
else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}",
|
||||
"csdr fft_exchange_sides_ff {fft_size}",
|
||||
]
|
||||
if self.fft_compression == "adpcm":
|
||||
chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"]
|
||||
return chain
|
||||
chain += "csdr shift_addition_cc --fifo {shift_pipe} | "
|
||||
chain += "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING | "
|
||||
chain += "csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING | csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every} | "
|
||||
chain += [
|
||||
"csdr shift_addition_cc --fifo {shift_pipe}",
|
||||
"csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING",
|
||||
"csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING",
|
||||
]
|
||||
if self.output.supports_type("smeter"):
|
||||
chain += [
|
||||
"csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}"
|
||||
]
|
||||
if self.secondary_demodulator:
|
||||
chain += "csdr tee {iqtee_pipe} | "
|
||||
chain += "csdr tee {iqtee2_pipe} | "
|
||||
if self.output.supports_type("secondary_fft"):
|
||||
chain += ["csdr tee {iqtee_pipe}"]
|
||||
chain += ["csdr tee {iqtee2_pipe}"]
|
||||
# early exit if we don't want audio
|
||||
if not self.output.supports_type("audio"):
|
||||
return chain
|
||||
# safe some cpu cycles... no need to decimate if decimation factor is 1
|
||||
last_decimation_block = "csdr fractional_decimator_ff {last_decimation} | " if self.last_decimation != 1.0 else ""
|
||||
last_decimation_block = (
|
||||
["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else []
|
||||
)
|
||||
if which == "nfm":
|
||||
chain += "csdr fmdemod_quadri_cf | csdr limit_ff | "
|
||||
chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"]
|
||||
chain += last_decimation_block
|
||||
chain += "csdr deemphasis_nfm_ff {output_rate} | csdr convert_f_s16"
|
||||
chain += ["csdr deemphasis_nfm_ff {output_rate}", "csdr convert_f_s16"]
|
||||
elif self.isDigitalVoice(which):
|
||||
chain += "csdr fmdemod_quadri_cf | dc_block | "
|
||||
chain += ["csdr fmdemod_quadri_cf", "dc_block "]
|
||||
chain += last_decimation_block
|
||||
# dsd modes
|
||||
if which in [ "dstar", "nxdn" ]:
|
||||
chain += "csdr limit_ff | csdr convert_f_s16 | "
|
||||
if which in ["dstar", "nxdn"]:
|
||||
chain += ["csdr limit_ff", "csdr convert_f_s16"]
|
||||
if which == "dstar":
|
||||
chain += "dsd -fd"
|
||||
chain += ["dsd -fd -i - -o - -u {unvoiced_quality} -g -1 "]
|
||||
elif which == "nxdn":
|
||||
chain += "dsd -fi"
|
||||
chain += " -i - -o - -u {unvoiced_quality} -g -1 | CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f | "
|
||||
chain += ["dsd -fi -i - -o - -u {unvoiced_quality} -g -1 "]
|
||||
chain += ["CSDR_FIXED_BUFSIZE=32 csdr convert_s16_f"]
|
||||
max_gain = 5
|
||||
# digiham modes
|
||||
else:
|
||||
chain += "rrc_filter | gfsk_demodulator | "
|
||||
chain += ["rrc_filter", "gfsk_demodulator"]
|
||||
if which == "dmr":
|
||||
chain += "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe} | mbe_synthesizer -f -u {unvoiced_quality} | "
|
||||
chain += [
|
||||
"dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}",
|
||||
"mbe_synthesizer -f -u {unvoiced_quality}",
|
||||
]
|
||||
elif which == "ysf":
|
||||
chain += "ysf_decoder --fifo {meta_pipe} | mbe_synthesizer -y -f -u {unvoiced_quality} | "
|
||||
chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y -f -u {unvoiced_quality}"]
|
||||
max_gain = 0.0005
|
||||
chain += "digitalvoice_filter -f | "
|
||||
chain += "CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain} | ".format(max_gain=max_gain)
|
||||
chain += "sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
|
||||
chain += [
|
||||
"digitalvoice_filter -f",
|
||||
"CSDR_FIXED_BUFSIZE=32 csdr agc_ff 160000 0.8 1 0.0000001 {max_gain}".format(max_gain=max_gain),
|
||||
"sox -t raw -r 8000 -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ",
|
||||
]
|
||||
elif which == "packet":
|
||||
chain += "csdr fmdemod_quadri_cf | "
|
||||
chain += ["csdr fmdemod_quadri_cf"]
|
||||
chain += last_decimation_block
|
||||
chain += "csdr convert_f_s16 | "
|
||||
chain += "direwolf -r {audio_rate} - 1>&2"
|
||||
chain += [
|
||||
"csdr convert_f_s16",
|
||||
"direwolf -r {audio_rate} - 1>&2"
|
||||
]
|
||||
elif which == "am":
|
||||
chain += "csdr amdemod_cf | csdr fastdcblock_ff | "
|
||||
chain += ["csdr amdemod_cf", "csdr fastdcblock_ff"]
|
||||
chain += last_decimation_block
|
||||
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
||||
chain += ["csdr agc_ff", "csdr limit_ff", "csdr convert_f_s16"]
|
||||
elif which == "ssb":
|
||||
chain += "csdr realpart_cf | "
|
||||
chain += ["csdr realpart_cf"]
|
||||
chain += last_decimation_block
|
||||
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
||||
chain += ["csdr agc_ff", "csdr limit_ff"]
|
||||
# fixed sample rate necessary for the wsjt-x tools. fix with sox...
|
||||
if self.isWsjtMode() and self.get_audio_rate() != self.get_output_rate():
|
||||
chain += [
|
||||
"sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - "
|
||||
]
|
||||
else:
|
||||
chain += ["csdr convert_f_s16"]
|
||||
|
||||
if self.audio_compression=="adpcm":
|
||||
chain += " | csdr encode_ima_adpcm_i16_u8"
|
||||
if self.audio_compression == "adpcm":
|
||||
chain += ["csdr encode_ima_adpcm_i16_u8"]
|
||||
return chain
|
||||
|
||||
def secondary_chain(self, which):
|
||||
secondary_chain_base="cat {input_pipe} | "
|
||||
secondary_chain_base = "cat {input_pipe} | "
|
||||
if which == "fft":
|
||||
return secondary_chain_base+"csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 " + (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression=="adpcm" else "")
|
||||
return (
|
||||
secondary_chain_base
|
||||
+ "csdr realpart_cf | csdr fft_fc {secondary_fft_input_size} {secondary_fft_block_size} | csdr logpower_cf -70 "
|
||||
+ (" | csdr compress_fft_adpcm_f_u8 {secondary_fft_size}" if self.fft_compression == "adpcm" else "")
|
||||
)
|
||||
elif which == "bpsk31":
|
||||
return secondary_chain_base + "csdr shift_addition_cc --fifo {secondary_shift_pipe} | " + \
|
||||
"csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | " + \
|
||||
"csdr simple_agc_cc 0.001 0.5 | " + \
|
||||
"csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | " + \
|
||||
"CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | " + \
|
||||
"CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
|
||||
return (
|
||||
secondary_chain_base
|
||||
+ "csdr shift_addition_cc --fifo {secondary_shift_pipe} | "
|
||||
+ "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff} | "
|
||||
+ "csdr simple_agc_cc 0.001 0.5 | "
|
||||
+ "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q | "
|
||||
+ "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8 | "
|
||||
+ "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8"
|
||||
)
|
||||
elif self.isWsjtMode(which):
|
||||
chain = secondary_chain_base + "csdr realpart_cf | "
|
||||
if self.last_decimation != 1.0:
|
||||
chain += "csdr fractional_decimator_ff {last_decimation} | "
|
||||
chain += "csdr agc_ff | csdr limit_ff | csdr convert_f_s16"
|
||||
return chain
|
||||
|
||||
def set_secondary_demodulator(self, what):
|
||||
if self.get_secondary_demodulator() == what:
|
||||
return
|
||||
self.secondary_demodulator = what
|
||||
self.calculate_decimation()
|
||||
self.restart()
|
||||
|
||||
def secondary_fft_block_size(self):
|
||||
return (self.samp_rate/self.decimation)/(self.fft_fps*2) #*2 is there because we do FFT on real signal here
|
||||
return (self.samp_rate / self.decimation) / (
|
||||
self.fft_fps * 2
|
||||
) # *2 is there because we do FFT on real signal here
|
||||
|
||||
def secondary_decimation(self):
|
||||
return 1 #currently unused
|
||||
return 1 # currently unused
|
||||
|
||||
def secondary_bpf_cutoff(self):
|
||||
if self.secondary_demodulator == "bpsk31":
|
||||
return 31.25 / self.if_samp_rate()
|
||||
return 31.25 / self.if_samp_rate()
|
||||
return 0
|
||||
|
||||
def secondary_bpf_transition_bw(self):
|
||||
@ -175,7 +258,7 @@ class dsp(object):
|
||||
|
||||
def secondary_samples_per_bits(self):
|
||||
if self.secondary_demodulator == "bpsk31":
|
||||
return int(round(self.if_samp_rate()/31.25))&~3
|
||||
return int(round(self.if_samp_rate() / 31.25)) & ~3
|
||||
return 0
|
||||
|
||||
def secondary_bw(self):
|
||||
@ -183,55 +266,82 @@ class dsp(object):
|
||||
return 31.25
|
||||
|
||||
def start_secondary_demodulator(self):
|
||||
if not self.secondary_demodulator: return
|
||||
logger.debug("[openwebrx] starting secondary demodulator from IF input sampled at %d"%self.if_samp_rate())
|
||||
secondary_command_fft=self.secondary_chain("fft")
|
||||
secondary_command_demod=self.secondary_chain(self.secondary_demodulator)
|
||||
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod + secondary_command_fft)
|
||||
if not self.secondary_demodulator:
|
||||
return
|
||||
logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate())
|
||||
secondary_command_demod = self.secondary_chain(self.secondary_demodulator)
|
||||
self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod)
|
||||
|
||||
secondary_command_fft=secondary_command_fft.format(
|
||||
input_pipe=self.iqtee_pipe,
|
||||
secondary_fft_input_size=self.secondary_fft_size,
|
||||
secondary_fft_size=self.secondary_fft_size,
|
||||
secondary_fft_block_size=self.secondary_fft_block_size(),
|
||||
)
|
||||
secondary_command_demod=secondary_command_demod.format(
|
||||
secondary_command_demod = secondary_command_demod.format(
|
||||
input_pipe=self.iqtee2_pipe,
|
||||
secondary_shift_pipe=self.secondary_shift_pipe,
|
||||
secondary_decimation=self.secondary_decimation(),
|
||||
secondary_samples_per_bits=self.secondary_samples_per_bits(),
|
||||
secondary_bpf_cutoff=self.secondary_bpf_cutoff(),
|
||||
secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(),
|
||||
if_samp_rate=self.if_samp_rate()
|
||||
if_samp_rate=self.if_samp_rate(),
|
||||
last_decimation=self.last_decimation,
|
||||
)
|
||||
|
||||
logger.debug("secondary command (demod) = %s", secondary_command_demod)
|
||||
my_env = os.environ.copy()
|
||||
# if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
||||
if self.csdr_print_bufsizes:
|
||||
my_env["CSDR_PRINT_BUFSIZES"] = "1"
|
||||
if self.output.supports_type("secondary_fft"):
|
||||
secondary_command_fft = self.secondary_chain("fft")
|
||||
secondary_command_fft = secondary_command_fft.format(
|
||||
input_pipe=self.iqtee_pipe,
|
||||
secondary_fft_input_size=self.secondary_fft_size,
|
||||
secondary_fft_size=self.secondary_fft_size,
|
||||
secondary_fft_block_size=self.secondary_fft_block_size(),
|
||||
)
|
||||
logger.debug("secondary command (fft) = %s", secondary_command_fft)
|
||||
|
||||
self.secondary_process_fft = subprocess.Popen(
|
||||
secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env
|
||||
)
|
||||
self.output.send_output(
|
||||
"secondary_fft",
|
||||
partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())),
|
||||
)
|
||||
|
||||
logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (fft) = %s", secondary_command_fft)
|
||||
logger.debug("[openwebrx-dsp-plugin:csdr] secondary command (demod) = %s", secondary_command_demod)
|
||||
my_env=os.environ.copy()
|
||||
#if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
||||
if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1";
|
||||
self.secondary_process_fft = subprocess.Popen(secondary_command_fft, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
||||
logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (fft)")
|
||||
self.secondary_process_demod = subprocess.Popen(secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env) #TODO digimodes
|
||||
logger.debug("[openwebrx-dsp-plugin:csdr] Popen on secondary command (demod)") #TODO digimodes
|
||||
self.secondary_process_demod = subprocess.Popen(
|
||||
secondary_command_demod, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env
|
||||
)
|
||||
self.secondary_processes_running = True
|
||||
|
||||
self.output.add_output("secondary_fft", partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())))
|
||||
self.output.add_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1))
|
||||
if self.isWsjtMode():
|
||||
smd = self.get_secondary_demodulator()
|
||||
if smd == "ft8":
|
||||
chopper = Ft8Chopper(self.secondary_process_demod.stdout)
|
||||
elif smd == "wspr":
|
||||
chopper = WsprChopper(self.secondary_process_demod.stdout)
|
||||
elif smd == "jt65":
|
||||
chopper = Jt65Chopper(self.secondary_process_demod.stdout)
|
||||
elif smd == "jt9":
|
||||
chopper = Jt9Chopper(self.secondary_process_demod.stdout)
|
||||
elif smd == "ft4":
|
||||
chopper = Ft4Chopper(self.secondary_process_demod.stdout)
|
||||
chopper.start()
|
||||
self.output.send_output("wsjt_demod", chopper.read)
|
||||
else:
|
||||
self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1))
|
||||
|
||||
#open control pipes for csdr and send initialization data
|
||||
if self.secondary_shift_pipe != None: #TODO digimodes
|
||||
self.secondary_shift_pipe_file=open(self.secondary_shift_pipe,"w") #TODO digimodes
|
||||
self.set_secondary_offset_freq(self.secondary_offset_freq) #TODO digimodes
|
||||
# open control pipes for csdr and send initialization data
|
||||
if self.secondary_shift_pipe != None: # TODO digimodes
|
||||
self.secondary_shift_pipe_file = open(self.secondary_shift_pipe, "w") # TODO digimodes
|
||||
self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes
|
||||
|
||||
def set_secondary_offset_freq(self, value):
|
||||
self.secondary_offset_freq=value
|
||||
if self.secondary_processes_running:
|
||||
self.secondary_shift_pipe_file.write("%g\n"%(-float(self.secondary_offset_freq)/self.if_samp_rate()))
|
||||
self.secondary_offset_freq = value
|
||||
if self.secondary_processes_running and hasattr(self, "secondary_shift_pipe_file"):
|
||||
self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate()))
|
||||
self.secondary_shift_pipe_file.flush()
|
||||
|
||||
def stop_secondary_demodulator(self):
|
||||
if self.secondary_processes_running == False: return
|
||||
if self.secondary_processes_running == False:
|
||||
return
|
||||
self.try_delete_pipes(self.secondary_pipe_names)
|
||||
if self.secondary_process_fft:
|
||||
try:
|
||||
@ -250,42 +360,47 @@ class dsp(object):
|
||||
def get_secondary_demodulator(self):
|
||||
return self.secondary_demodulator
|
||||
|
||||
def set_secondary_fft_size(self,secondary_fft_size):
|
||||
#to change this, restart is required
|
||||
self.secondary_fft_size=secondary_fft_size
|
||||
def set_secondary_fft_size(self, secondary_fft_size):
|
||||
# to change this, restart is required
|
||||
self.secondary_fft_size = secondary_fft_size
|
||||
|
||||
def set_audio_compression(self,what):
|
||||
def set_audio_compression(self, what):
|
||||
self.audio_compression = what
|
||||
|
||||
def set_fft_compression(self,what):
|
||||
def set_fft_compression(self, what):
|
||||
self.fft_compression = what
|
||||
|
||||
def get_fft_bytes_to_read(self):
|
||||
if self.fft_compression=="none": return self.fft_size*4
|
||||
if self.fft_compression=="adpcm": return (self.fft_size/2)+(10/2)
|
||||
if self.fft_compression == "none":
|
||||
return self.fft_size * 4
|
||||
if self.fft_compression == "adpcm":
|
||||
return (self.fft_size / 2) + (10 / 2)
|
||||
|
||||
def get_secondary_fft_bytes_to_read(self):
|
||||
if self.fft_compression=="none": return self.secondary_fft_size*4
|
||||
if self.fft_compression=="adpcm": return (self.secondary_fft_size/2)+(10/2)
|
||||
if self.fft_compression == "none":
|
||||
return self.secondary_fft_size * 4
|
||||
if self.fft_compression == "adpcm":
|
||||
return (self.secondary_fft_size / 2) + (10 / 2)
|
||||
|
||||
def set_samp_rate(self,samp_rate):
|
||||
self.samp_rate=samp_rate
|
||||
def set_samp_rate(self, samp_rate):
|
||||
self.samp_rate = samp_rate
|
||||
self.calculate_decimation()
|
||||
if self.running: self.restart()
|
||||
if self.running:
|
||||
self.restart()
|
||||
|
||||
def calculate_decimation(self):
|
||||
(self.decimation, self.last_decimation, _) = self.get_decimation(self.samp_rate, self.get_audio_rate())
|
||||
|
||||
def get_decimation(self, input_rate, output_rate):
|
||||
decimation=1
|
||||
while input_rate / (decimation+1) >= output_rate:
|
||||
decimation = 1
|
||||
while input_rate / (decimation + 1) >= output_rate:
|
||||
decimation += 1
|
||||
fraction = float(input_rate / decimation) / output_rate
|
||||
intermediate_rate = input_rate / decimation
|
||||
return (decimation, fraction, intermediate_rate)
|
||||
|
||||
def if_samp_rate(self):
|
||||
return self.samp_rate/self.decimation
|
||||
return self.samp_rate / self.decimation
|
||||
|
||||
def get_name(self):
|
||||
return self.name
|
||||
@ -296,61 +411,73 @@ class dsp(object):
|
||||
def get_audio_rate(self):
|
||||
if self.isDigitalVoice() or self.isPacket():
|
||||
return 48000
|
||||
elif self.isWsjtMode():
|
||||
return 12000
|
||||
return self.get_output_rate()
|
||||
|
||||
def isDigitalVoice(self, demodulator = None):
|
||||
def isDigitalVoice(self, demodulator=None):
|
||||
if demodulator is None:
|
||||
demodulator = self.get_demodulator()
|
||||
return demodulator in ["dmr", "dstar", "nxdn", "ysf"]
|
||||
|
||||
def isWsjtMode(self, demodulator=None):
|
||||
if demodulator is None:
|
||||
demodulator = self.get_secondary_demodulator()
|
||||
return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"]
|
||||
|
||||
def isPacket(self, demodulator = None):
|
||||
if demodulator is None:
|
||||
demodulator = self.get_demodulator()
|
||||
return demodulator == "packet"
|
||||
|
||||
def set_output_rate(self,output_rate):
|
||||
self.output_rate=output_rate
|
||||
def set_output_rate(self, output_rate):
|
||||
self.output_rate = output_rate
|
||||
self.calculate_decimation()
|
||||
|
||||
def set_demodulator(self,demodulator):
|
||||
if (self.demodulator == demodulator): return
|
||||
self.demodulator=demodulator
|
||||
def set_demodulator(self, demodulator):
|
||||
if self.demodulator == demodulator:
|
||||
return
|
||||
self.demodulator = demodulator
|
||||
self.calculate_decimation()
|
||||
self.restart()
|
||||
|
||||
def get_demodulator(self):
|
||||
return self.demodulator
|
||||
|
||||
def set_fft_size(self,fft_size):
|
||||
self.fft_size=fft_size
|
||||
def set_fft_size(self, fft_size):
|
||||
self.fft_size = fft_size
|
||||
self.restart()
|
||||
|
||||
def set_fft_fps(self,fft_fps):
|
||||
self.fft_fps=fft_fps
|
||||
def set_fft_fps(self, fft_fps):
|
||||
self.fft_fps = fft_fps
|
||||
self.restart()
|
||||
|
||||
def set_fft_averages(self,fft_averages):
|
||||
self.fft_averages=fft_averages
|
||||
def set_fft_averages(self, fft_averages):
|
||||
self.fft_averages = fft_averages
|
||||
self.restart()
|
||||
|
||||
def fft_block_size(self):
|
||||
if self.fft_averages == 0: return self.samp_rate/self.fft_fps
|
||||
else: return self.samp_rate/self.fft_fps/self.fft_averages
|
||||
if self.fft_averages == 0:
|
||||
return self.samp_rate / self.fft_fps
|
||||
else:
|
||||
return self.samp_rate / self.fft_fps / self.fft_averages
|
||||
|
||||
def set_offset_freq(self,offset_freq):
|
||||
self.offset_freq=offset_freq
|
||||
def set_offset_freq(self, offset_freq):
|
||||
self.offset_freq = offset_freq
|
||||
if self.running:
|
||||
self.modification_lock.acquire()
|
||||
self.shift_pipe_file.write("%g\n"%(-float(self.offset_freq)/self.samp_rate))
|
||||
self.shift_pipe_file.write("%g\n" % (-float(self.offset_freq) / self.samp_rate))
|
||||
self.shift_pipe_file.flush()
|
||||
self.modification_lock.release()
|
||||
|
||||
def set_bpf(self,low_cut,high_cut):
|
||||
self.low_cut=low_cut
|
||||
self.high_cut=high_cut
|
||||
def set_bpf(self, low_cut, high_cut):
|
||||
self.low_cut = low_cut
|
||||
self.high_cut = high_cut
|
||||
if self.running:
|
||||
self.modification_lock.acquire()
|
||||
self.bpf_pipe_file.write( "%g %g\n"%(float(self.low_cut)/self.if_samp_rate(), float(self.high_cut)/self.if_samp_rate()) )
|
||||
self.bpf_pipe_file.write(
|
||||
"%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate())
|
||||
)
|
||||
self.bpf_pipe_file.flush()
|
||||
self.modification_lock.release()
|
||||
|
||||
@ -358,12 +485,12 @@ class dsp(object):
|
||||
return [self.low_cut, self.high_cut]
|
||||
|
||||
def set_squelch_level(self, squelch_level):
|
||||
self.squelch_level=squelch_level
|
||||
#no squelch required on digital voice modes
|
||||
self.squelch_level = squelch_level
|
||||
# no squelch required on digital voice modes
|
||||
actual_squelch = 0 if self.isDigitalVoice() or self.isPacket() else self.squelch_level
|
||||
if self.running:
|
||||
self.modification_lock.acquire()
|
||||
self.squelch_pipe_file.write("%g\n"%(float(actual_squelch)))
|
||||
self.squelch_pipe_file.write("%g\n" % (float(actual_squelch)))
|
||||
self.squelch_pipe_file.flush()
|
||||
self.modification_lock.release()
|
||||
|
||||
@ -379,7 +506,7 @@ class dsp(object):
|
||||
self.dmr_control_pipe_file.write("{0}\n".format(filter))
|
||||
self.dmr_control_pipe_file.flush()
|
||||
|
||||
def mkfifo(self,path):
|
||||
def mkfifo(self, path):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except:
|
||||
@ -387,64 +514,94 @@ class dsp(object):
|
||||
os.mkfifo(path)
|
||||
|
||||
def ddc_transition_bw(self):
|
||||
return self.ddc_transition_bw_rate*(self.if_samp_rate()/float(self.samp_rate))
|
||||
return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate))
|
||||
|
||||
def try_create_pipes(self, pipe_names, command_base):
|
||||
for pipe_name in pipe_names:
|
||||
if "{"+pipe_name+"}" in command_base:
|
||||
setattr(self, pipe_name, self.pipe_base_path+pipe_name)
|
||||
if "{" + pipe_name + "}" in command_base:
|
||||
setattr(self, pipe_name, self.pipe_base_path + pipe_name)
|
||||
self.mkfifo(getattr(self, pipe_name))
|
||||
else:
|
||||
setattr(self, pipe_name, None)
|
||||
|
||||
def try_delete_pipes(self, pipe_names):
|
||||
for pipe_name in pipe_names:
|
||||
pipe_path = getattr(self,pipe_name,None)
|
||||
pipe_path = getattr(self, pipe_name, None)
|
||||
if pipe_path:
|
||||
try: os.unlink(pipe_path)
|
||||
try:
|
||||
os.unlink(pipe_path)
|
||||
except FileNotFoundError:
|
||||
# it seems like we keep calling this twice. no idea why, but we don't need the resulting error.
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("try_delete_pipes()")
|
||||
|
||||
def start(self):
|
||||
self.modification_lock.acquire()
|
||||
if (self.running):
|
||||
if self.running:
|
||||
self.modification_lock.release()
|
||||
return
|
||||
self.running = True
|
||||
|
||||
command_base=self.chain(self.demodulator)
|
||||
command_base = " | ".join(self.chain(self.demodulator))
|
||||
|
||||
#create control pipes for csdr
|
||||
self.pipe_base_path="/tmp/openwebrx_pipe_{myid}_".format(myid=id(self))
|
||||
# create control pipes for csdr
|
||||
self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self))
|
||||
|
||||
self.try_create_pipes(self.pipe_names, command_base)
|
||||
|
||||
#run the command
|
||||
command=command_base.format( bpf_pipe=self.bpf_pipe, shift_pipe=self.shift_pipe, decimation=self.decimation,
|
||||
last_decimation=self.last_decimation, fft_size=self.fft_size, fft_block_size=self.fft_block_size(), fft_averages=self.fft_averages,
|
||||
bpf_transition_bw=float(self.bpf_transition_bw)/self.if_samp_rate(), ddc_transition_bw=self.ddc_transition_bw(),
|
||||
flowcontrol=int(self.samp_rate*2), start_bufsize=self.base_bufsize*self.decimation, nc_port=self.nc_port,
|
||||
squelch_pipe=self.squelch_pipe, smeter_pipe=self.smeter_pipe, meta_pipe=self.meta_pipe, iqtee_pipe=self.iqtee_pipe, iqtee2_pipe=self.iqtee2_pipe,
|
||||
output_rate = self.get_output_rate(), smeter_report_every = int(self.if_samp_rate()/6000),
|
||||
unvoiced_quality = self.get_unvoiced_quality(), audio_rate = self.get_audio_rate(),
|
||||
dmr_control_pipe = self.dmr_control_pipe)
|
||||
# run the command
|
||||
command = command_base.format(
|
||||
bpf_pipe=self.bpf_pipe,
|
||||
shift_pipe=self.shift_pipe,
|
||||
decimation=self.decimation,
|
||||
last_decimation=self.last_decimation,
|
||||
fft_size=self.fft_size,
|
||||
fft_block_size=self.fft_block_size(),
|
||||
fft_averages=self.fft_averages,
|
||||
bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(),
|
||||
ddc_transition_bw=self.ddc_transition_bw(),
|
||||
flowcontrol=int(self.samp_rate * 2),
|
||||
start_bufsize=self.base_bufsize * self.decimation,
|
||||
nc_port=self.nc_port,
|
||||
squelch_pipe=self.squelch_pipe,
|
||||
smeter_pipe=self.smeter_pipe,
|
||||
meta_pipe=self.meta_pipe,
|
||||
iqtee_pipe=self.iqtee_pipe,
|
||||
iqtee2_pipe=self.iqtee2_pipe,
|
||||
output_rate=self.get_output_rate(),
|
||||
smeter_report_every=int(self.if_samp_rate() / 6000),
|
||||
unvoiced_quality=self.get_unvoiced_quality(),
|
||||
dmr_control_pipe=self.dmr_control_pipe,
|
||||
audio_rate=self.get_audio_rate(),
|
||||
)
|
||||
|
||||
logger.debug("[openwebrx-dsp-plugin:csdr] Command = %s", command)
|
||||
my_env=os.environ.copy()
|
||||
if self.csdr_dynamic_bufsize: my_env["CSDR_DYNAMIC_BUFSIZE_ON"]="1";
|
||||
if self.csdr_print_bufsizes: my_env["CSDR_PRINT_BUFSIZES"]="1";
|
||||
self.process = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
||||
logger.debug("Command = %s", command)
|
||||
my_env = os.environ.copy()
|
||||
if self.csdr_dynamic_bufsize:
|
||||
my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1"
|
||||
if self.csdr_print_bufsizes:
|
||||
my_env["CSDR_PRINT_BUFSIZES"] = "1"
|
||||
|
||||
out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL
|
||||
self.process = subprocess.Popen(command, stdout=out, shell=True, preexec_fn=os.setpgrp, env=my_env)
|
||||
|
||||
def watch_thread():
|
||||
rc = self.process.wait()
|
||||
logger.debug("dsp thread ended with rc=%d", rc)
|
||||
if (rc == 0 and self.running and not self.modification_lock.locked()):
|
||||
if rc == 0 and self.running and not self.modification_lock.locked():
|
||||
logger.debug("restarting since rc = 0, self.running = true, and no modification")
|
||||
self.restart()
|
||||
|
||||
threading.Thread(target = watch_thread).start()
|
||||
threading.Thread(target=watch_thread).start()
|
||||
|
||||
self.output.add_output("audio", partial(self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256))
|
||||
if self.output.supports_type("audio"):
|
||||
self.output.send_output(
|
||||
"audio",
|
||||
partial(
|
||||
self.process.stdout.read, int(self.get_fft_bytes_to_read()) if self.demodulator == "fft" else 256
|
||||
),
|
||||
)
|
||||
|
||||
# open control pipes for csdr
|
||||
if self.bpf_pipe:
|
||||
@ -465,24 +622,28 @@ class dsp(object):
|
||||
if self.bpf_pipe:
|
||||
self.set_bpf(self.low_cut, self.high_cut)
|
||||
if self.smeter_pipe:
|
||||
self.smeter_pipe_file=open(self.smeter_pipe,"r")
|
||||
self.smeter_pipe_file = open(self.smeter_pipe, "r")
|
||||
|
||||
def read_smeter():
|
||||
raw = self.smeter_pipe_file.readline()
|
||||
if len(raw) == 0:
|
||||
return None
|
||||
else:
|
||||
return float(raw.rstrip("\n"))
|
||||
self.output.add_output("smeter", read_smeter)
|
||||
|
||||
self.output.send_output("smeter", read_smeter)
|
||||
if self.meta_pipe != None:
|
||||
# TODO make digiham output unicode and then change this here
|
||||
self.meta_pipe_file=open(self.meta_pipe, "r", encoding="cp437")
|
||||
self.meta_pipe_file = open(self.meta_pipe, "r", encoding="cp437")
|
||||
|
||||
def read_meta():
|
||||
raw = self.meta_pipe_file.readline()
|
||||
if len(raw) == 0:
|
||||
return None
|
||||
else:
|
||||
return raw.rstrip("\n")
|
||||
self.output.add_output("meta", read_meta)
|
||||
|
||||
self.output.send_output("meta", read_meta)
|
||||
|
||||
if self.dmr_control_pipe:
|
||||
self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w")
|
||||
@ -503,10 +664,11 @@ class dsp(object):
|
||||
self.modification_lock.release()
|
||||
|
||||
def restart(self):
|
||||
if not self.running: return
|
||||
if not self.running:
|
||||
return
|
||||
self.stop()
|
||||
self.start()
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
del(self.process)
|
||||
del self.process
|
||||
|
@ -14,8 +14,8 @@ function cmakebuild() {
|
||||
|
||||
cd /tmp
|
||||
|
||||
STATIC_PACKAGES="libusb fftw"
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev fftw-dev"
|
||||
STATIC_PACKAGES="libusb fftw udev"
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev fftw-dev"
|
||||
|
||||
apk add --no-cache $STATIC_PACKAGES
|
||||
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||
|
@ -14,8 +14,8 @@ function cmakebuild() {
|
||||
|
||||
cd /tmp
|
||||
|
||||
STATIC_PACKAGES="libusb"
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++ libusb-dev"
|
||||
STATIC_PACKAGES="libusb udev"
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev"
|
||||
|
||||
apk add --no-cache $STATIC_PACKAGES
|
||||
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||
|
@ -14,8 +14,10 @@ function cmakebuild() {
|
||||
|
||||
cd /tmp
|
||||
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo udev gcc g++"
|
||||
STATIC_PACKAGES="udev"
|
||||
BUILD_PACKAGES="git cmake make patch wget sudo gcc g++"
|
||||
|
||||
apk add --no-cache $STATIC_PACKAGES
|
||||
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||
|
||||
git clone https://github.com/pothosware/SoapySDR
|
||||
|
@ -14,8 +14,8 @@ function cmakebuild() {
|
||||
|
||||
cd /tmp
|
||||
|
||||
STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack"
|
||||
BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers"
|
||||
STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack libusb qt5-qtbase qt5-qtmultimedia qt5-qtserialport"
|
||||
BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers autoconf automake libtool texinfo gfortran libusb-dev qt5-qtbase-dev qt5-qtmultimedia-dev qt5-qtserialport-dev asciidoctor asciidoc"
|
||||
|
||||
apk add --no-cache $STATIC_PACKAGES
|
||||
apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||
@ -23,7 +23,7 @@ apk add --no-cache --virtual .build-deps $BUILD_PACKAGES
|
||||
git clone https://git.code.sf.net/p/itpp/git itpp
|
||||
cmakebuild itpp
|
||||
|
||||
git clone https://github.com/simonyiszk/csdr.git
|
||||
git clone https://github.com/jketterl/csdr.git -b 48khz_filter
|
||||
cd csdr
|
||||
patch -Np1 <<'EOF'
|
||||
--- a/csdr.c
|
||||
@ -68,6 +68,10 @@ rm -rf csdr
|
||||
|
||||
git clone https://github.com/szechyjs/mbelib.git
|
||||
cmakebuild mbelib
|
||||
if [ -d "/usr/local/lib64" ]; then
|
||||
# no idea why it's put into there now. alpine does not handle it correctly, so move it.
|
||||
mv /usr/local/lib64/libmbe* /usr/local/lib
|
||||
fi
|
||||
|
||||
git clone https://github.com/jketterl/digiham.git
|
||||
cmakebuild digiham
|
||||
@ -75,4 +79,10 @@ cmakebuild digiham
|
||||
git clone https://github.com/f4exb/dsd.git
|
||||
cmakebuild dsd
|
||||
|
||||
WSJT_DIR=wsjtx-2.0.1
|
||||
WSJT_TGZ=${WSJT_DIR}.tgz
|
||||
wget http://physics.princeton.edu/pulsar/k1jt/$WSJT_TGZ
|
||||
tar xvfz $WSJT_TGZ
|
||||
cmakebuild $WSJT_DIR
|
||||
|
||||
apk del .build-deps
|
||||
|
12
htdocs/css/features.css
Normal file
12
htdocs/css/features.css
Normal 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
57
htdocs/css/map.css
Normal 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;
|
||||
}
|
8
htdocs/css/openwebrx-globals.css
Normal file
8
htdocs/css/openwebrx-globals.css
Normal file
@ -0,0 +1,8 @@
|
||||
html, body
|
||||
{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
||||
}
|
||||
|
203
htdocs/css/openwebrx-header.css
Normal file
203
htdocs/css/openwebrx-header.css
Normal 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;
|
||||
}
|
||||
|
@ -18,13 +18,10 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
@import url("openwebrx-header.css");
|
||||
@import url("openwebrx-globals.css");
|
||||
|
||||
html, body
|
||||
{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -147,182 +144,16 @@ input[type=range]:focus::-ms-fill-upper
|
||||
background: #B6B6B6;
|
||||
}
|
||||
|
||||
#webrx-top-container
|
||||
{
|
||||
position: relative;
|
||||
z-index:1000;
|
||||
}
|
||||
|
||||
.webrx-top-bar-parts
|
||||
{
|
||||
height:67px;
|
||||
}
|
||||
|
||||
#webrx-top-bar
|
||||
{
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
margin:0;
|
||||
padding:0;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#webrx-top-logo
|
||||
{
|
||||
padding: 12px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#webrx-ha5kfu-top-logo
|
||||
{
|
||||
float: right;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#webrx-top-photo
|
||||
{
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#webrx-rx-avatar-background
|
||||
{
|
||||
cursor:pointer;
|
||||
background-image: url(gfx/openwebrx-avatar-background.png);
|
||||
background-origin: content-box;
|
||||
background-repeat: no-repeat;
|
||||
float: left;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
#webrx-rx-avatar
|
||||
{
|
||||
cursor:pointer;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#webrx-top-photo-clip
|
||||
{
|
||||
min-height: 67px;
|
||||
max-height: 350px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#webrx-page-container
|
||||
{
|
||||
min-height:100%;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
#webrx-rx-photo-title
|
||||
{
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 78px;
|
||||
color: White;
|
||||
font-size: 16pt;
|
||||
text-shadow: 1px 1px 4px #444;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#webrx-rx-photo-desc
|
||||
{
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 109px;
|
||||
color: White;
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
text-shadow: 0px 0px 6px #444;
|
||||
opacity: 1;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
#webrx-rx-photo-desc a
|
||||
{
|
||||
color: #5ca8ff;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
#webrx-rx-texts {
|
||||
float: left;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#webrx-rx-texts div {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
#webrx-rx-title
|
||||
{
|
||||
white-space:nowrap;
|
||||
overflow: hidden;
|
||||
cursor:pointer;
|
||||
font-family: "DejaVu Sans", Verdana, Geneva, sans-serif;
|
||||
color: #909090;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#webrx-rx-desc
|
||||
{
|
||||
white-space:nowrap;
|
||||
overflow: hidden;
|
||||
cursor:pointer;
|
||||
font-size: 10pt;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
#webrx-rx-desc a
|
||||
{
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
#openwebrx-rx-details-arrow
|
||||
{
|
||||
cursor:pointer;
|
||||
position: absolute;
|
||||
left: 470px;
|
||||
top: 51px;
|
||||
}
|
||||
|
||||
#openwebrx-rx-details-arrow a
|
||||
{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#openwebrx-rx-details-arrow-down
|
||||
{
|
||||
display:none;
|
||||
}
|
||||
|
||||
/*canvas#waterfall-canvas
|
||||
{
|
||||
border-style: none;
|
||||
border-width: 1px;
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
}*/
|
||||
|
||||
#openwebrx-scale-container
|
||||
{
|
||||
height: 47px;
|
||||
background-image: url("gfx/openwebrx-scale-background.png");
|
||||
background-image: url("../gfx/openwebrx-scale-background.png");
|
||||
background-repeat: repeat-x;
|
||||
overflow: hidden;
|
||||
z-index:1000;
|
||||
@ -331,14 +162,14 @@ input[type=range]:focus::-ms-fill-upper
|
||||
|
||||
#webrx-canvas-container
|
||||
{
|
||||
/*background-image:url('gfx/openwebrx-blank-background-1.jpg');*/
|
||||
/*background-image:url('../gfx/openwebrx-blank-background-1.jpg');*/
|
||||
position: relative;
|
||||
height: 2000px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
/*background-color: #646464;*/
|
||||
/*background-image: -webkit-linear-gradient(top, rgba(247,247,247,1) 0%, rgba(0,0,0,1) 100%);*/
|
||||
background-image: url('gfx/openwebrx-background-cool-blue.png');
|
||||
background-image: url('../gfx/openwebrx-background-cool-blue.png');
|
||||
background-repeat: no-repeat;
|
||||
background-color: #1e5f7f;
|
||||
cursor: crosshair;
|
||||
@ -428,15 +259,15 @@ input[type=range]:focus::-ms-fill-upper
|
||||
/* removed non-free fonts like that: */
|
||||
/*@font-face {
|
||||
font-family: 'unibody_8_pro_regregular';
|
||||
src: url('gfx/unibody8pro-regular-webfont.eot');
|
||||
src: url('gfx/unibody8pro-regular-webfont.ttf');
|
||||
src: url('../gfx/unibody8pro-regular-webfont.eot');
|
||||
src: url('../gfx/unibody8pro-regular-webfont.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'expletus-sans-medium';
|
||||
src: url('gfx/font-expletus-sans/ExpletusSans-Medium.ttf');
|
||||
src: url('../gfx/font-expletus-sans/ExpletusSans-Medium.ttf');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@ -533,6 +364,20 @@ input[type=range]:focus::-ms-fill-upper
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.openwebrx-dial-button svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.openwebrx-dial-button #ph_dial {
|
||||
fill: #888;
|
||||
}
|
||||
|
||||
.openwebrx-dial-button.available #ph_dial {
|
||||
fill: #FFF;
|
||||
}
|
||||
|
||||
.openwebrx-square-button img
|
||||
{
|
||||
height: 27px;
|
||||
@ -637,47 +482,6 @@ img.openwebrx-mirror-img
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#openwebrx-main-buttons img
|
||||
{
|
||||
}
|
||||
|
||||
#openwebrx-main-buttons ul
|
||||
{
|
||||
display: table;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
|
||||
#openwebrx-main-buttons ul li
|
||||
{
|
||||
display: table-cell;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
#openwebrx-main-buttons li:hover
|
||||
{
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
#openwebrx-main-buttons li:active
|
||||
{
|
||||
background-color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
|
||||
#openwebrx-main-buttons
|
||||
{
|
||||
float: right;
|
||||
margin:0;
|
||||
color: white;
|
||||
text-shadow: 0px 0px 4px #000000;
|
||||
text-align: center;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#openwebrx-panel-receiver
|
||||
{
|
||||
width:110px;
|
||||
@ -812,7 +616,7 @@ img.openwebrx-mirror-img
|
||||
|
||||
#openwebrx-secondary-demod-listbox
|
||||
{
|
||||
width: 201px;
|
||||
width: 174px;
|
||||
height: 27px;
|
||||
padding-left:3px;
|
||||
}
|
||||
@ -951,7 +755,7 @@ img.openwebrx-mirror-img
|
||||
.openwebrx-meta-slot.muted:before {
|
||||
display: block;
|
||||
content: "";
|
||||
background-image: url("gfx/openwebrx-mute.png");
|
||||
background-image: url("../gfx/openwebrx-mute.png");
|
||||
width:100%;
|
||||
height:133px;
|
||||
background-position: center;
|
||||
@ -993,11 +797,11 @@ img.openwebrx-mirror-img
|
||||
}
|
||||
|
||||
.openwebrx-meta-slot.active .openwebrx-meta-user-image {
|
||||
background-image: url("gfx/openwebrx-directcall.png");
|
||||
background-image: url("../gfx/openwebrx-directcall.png");
|
||||
}
|
||||
|
||||
.openwebrx-meta-slot.active .openwebrx-meta-user-image.group {
|
||||
background-image: url("gfx/openwebrx-groupcall.png");
|
||||
background-image: url("../gfx/openwebrx-groupcall.png");
|
||||
}
|
||||
|
||||
.openwebrx-dmr-timeslot-panel * {
|
||||
@ -1005,7 +809,7 @@ img.openwebrx-mirror-img
|
||||
}
|
||||
|
||||
.openwebrx-maps-pin {
|
||||
background-image: url("gfx/google_maps_pin.svg");
|
||||
background-image: url("../gfx/google_maps_pin.svg");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
width: 15px;
|
||||
@ -1013,3 +817,62 @@ img.openwebrx-mirror-img
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message tbody {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message thead tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message th,
|
||||
#openwebrx-panel-wsjt-message td {
|
||||
width: 50px;
|
||||
text-align: left;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message .message {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message .decimal {
|
||||
text-align: right;
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
#openwebrx-panel-wsjt-message .decimal.freq {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container,
|
||||
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container,
|
||||
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container,
|
||||
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container,
|
||||
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-content-container,
|
||||
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel,
|
||||
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel,
|
||||
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel,
|
||||
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel,
|
||||
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-select-channel
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
|
||||
#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container,
|
||||
#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container,
|
||||
#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container,
|
||||
#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container,
|
||||
#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-canvas-container
|
||||
{
|
||||
height: 200px;
|
||||
margin: -10px;
|
||||
}
|
21
htdocs/features.html
Normal file
21
htdocs/features.html
Normal 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
24
htdocs/features.js
Normal 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("")
|
||||
);
|
||||
})
|
||||
});
|
||||
});
|
BIN
htdocs/gfx/openwebrx-panel-map.png
Normal file
BIN
htdocs/gfx/openwebrx-panel-map.png
Normal file
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 |
@ -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>
|
||||
|
30
htdocs/include/header.include.html
Normal file
30
htdocs/include/header.include.html
Normal 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>
|
@ -25,43 +25,15 @@
|
||||
<script src="static/sdr.js"></script>
|
||||
<script src="static/mathbox-bundle.min.js"></script>
|
||||
<script src="static/openwebrx.js"></script>
|
||||
<script src="static/jquery-3.2.1.min.js"></script>
|
||||
<script src="static/jquery.nanoscroller.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="static/nanoscroller.css" />
|
||||
<link rel="stylesheet" type="text/css" href="static/openwebrx.css" />
|
||||
<script src="static/lib/jquery-3.2.1.min.js"></script>
|
||||
<script src="static/lib/jquery.nanoscroller.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="static/lib/nanoscroller.css" />
|
||||
<link rel="stylesheet" type="text/css" href="static/css/openwebrx.css" />
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body onload="openwebrx_init();">
|
||||
<div id="webrx-page-container">
|
||||
<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" 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>
|
||||
${header}
|
||||
<div id="webrx-main-container">
|
||||
<div id="openwebrx-scale-container">
|
||||
<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();">
|
||||
<option value="none"></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>
|
||||
<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 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>
|
||||
@ -160,7 +144,7 @@
|
||||
<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.
|
||||
</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-select-channel"></div>
|
||||
</div>
|
||||
@ -171,6 +155,16 @@
|
||||
</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-meta-frame">
|
||||
<div class="openwebrx-meta-slot">
|
||||
|
58
htdocs/lib/chroma.min.js
vendored
Normal file
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
143
htdocs/lib/nite-overlay.js
Normal 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
24
htdocs/map.html
Normal 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
340
htdocs/map.js
Normal 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);
|
||||
|
||||
})();
|
@ -86,6 +86,7 @@ function init_rx_photo()
|
||||
window.setTimeout(function() { animate(e("webrx-rx-photo-title"),"opacity","",1,0,1,500,30); },1000);
|
||||
window.setTimeout(function() { animate(e("webrx-rx-photo-desc"),"opacity","",1,0,1,500,30); },1500);
|
||||
window.setTimeout(function() { close_rx_photo() },2500);
|
||||
$('#webrx-top-container .openwebrx-photo-trigger').click(toggle_rx_photo);
|
||||
}
|
||||
|
||||
dont_toggle_rx_photo_flag=0;
|
||||
@ -1250,6 +1251,13 @@ function on_ws_recv(evt)
|
||||
case "metadata":
|
||||
update_metadata(json.value);
|
||||
break;
|
||||
case "wsjt_message":
|
||||
update_wsjt_panel(json.value);
|
||||
break;
|
||||
case "dial_frequencies":
|
||||
dial_frequencies = json.value;
|
||||
update_dial_button();
|
||||
break;
|
||||
default:
|
||||
console.warn('received message of unknown type: ' + json.type);
|
||||
}
|
||||
@ -1315,6 +1323,29 @@ function on_ws_recv(evt)
|
||||
}
|
||||
}
|
||||
|
||||
var dial_frequencies = [];
|
||||
|
||||
function find_dial_frequencies() {
|
||||
var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
|
||||
return dial_frequencies.filter(function(d){
|
||||
return d.mode == sdm;
|
||||
});
|
||||
}
|
||||
|
||||
function update_dial_button() {
|
||||
var available = find_dial_frequencies();
|
||||
$("#openwebrx-secondary-demod-dial-button")[available.length ? "addClass" : "removeClass"]("available");
|
||||
}
|
||||
|
||||
function dial_button_click() {
|
||||
var available = find_dial_frequencies();
|
||||
if (!available.length) return;
|
||||
var frequency = available[0].frequency;
|
||||
console.info(frequency);
|
||||
demodulator_set_offset_frequency(0, frequency - center_freq);
|
||||
$("#webrx-actual-freq").html(format_frequency("{x} MHz", frequency, 1e6, 4));
|
||||
}
|
||||
|
||||
function update_metadata(meta) {
|
||||
if (meta.protocol) switch (meta.protocol) {
|
||||
case 'DMR':
|
||||
@ -1356,8 +1387,8 @@ function update_metadata(meta) {
|
||||
if (meta.mode && meta.mode != "") {
|
||||
mode = "Mode: " + meta.mode;
|
||||
source = meta.source || "";
|
||||
if (meta.lat && meta.lon) {
|
||||
source = "<a class=\"openwebrx-maps-pin\" href=\"https://www.google.com/maps/search/?api=1&query=" + meta.lat + "," + meta.lon + "\" target=\"_blank\"></a>" + source;
|
||||
if (meta.lat && meta.lon && meta.source) {
|
||||
source = "<a class=\"openwebrx-maps-pin\" href=\"/map?callsign=" + meta.source + "\" target=\"_blank\"></a>" + source;
|
||||
}
|
||||
up = meta.up ? "Up: " + meta.up : "";
|
||||
down = meta.down ? "Down: " + meta.down : "";
|
||||
@ -1377,6 +1408,56 @@ function update_metadata(meta) {
|
||||
|
||||
}
|
||||
|
||||
function html_escape(input) {
|
||||
return $('<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() {
|
||||
$(".openwebrx-meta-panel").each(function(_, p){
|
||||
toggle_panel(p.id, false);
|
||||
@ -1436,8 +1517,9 @@ function waterfall_dequeue()
|
||||
|
||||
function on_ws_opened()
|
||||
{
|
||||
ws.send("SERVER DE CLIENT openwebrx.js");
|
||||
ws.send("SERVER DE CLIENT client=openwebrx.js type=receiver");
|
||||
divlog("WebSocket opened to "+ws_url);
|
||||
reconnect_timeout = false;
|
||||
}
|
||||
|
||||
var was_error=0;
|
||||
@ -1818,6 +1900,8 @@ function audio_init()
|
||||
|
||||
}
|
||||
|
||||
var reconnect_timeout = false;
|
||||
|
||||
function on_ws_closed()
|
||||
{
|
||||
try
|
||||
@ -1826,9 +1910,16 @@ function on_ws_closed()
|
||||
}
|
||||
catch (dont_care) {}
|
||||
audio_initialized = 0;
|
||||
divlog("WebSocket has closed unexpectedly. Attempting to reconnect in 5 seconds...", 1);
|
||||
if (reconnect_timeout) {
|
||||
// max value: roundabout 8 and a half minutes
|
||||
reconnect_timeout = Math.min(reconnect_timeout * 2, 512000);
|
||||
} else {
|
||||
// initial value: 1s
|
||||
reconnect_timeout = 1000;
|
||||
}
|
||||
divlog("WebSocket has closed unexpectedly. Attempting to reconnect in " + reconnect_timeout / 1000 + " seconds...", 1);
|
||||
|
||||
setTimeout(open_websocket, 5000);
|
||||
setTimeout(open_websocket, reconnect_timeout);
|
||||
}
|
||||
|
||||
function on_ws_error(event)
|
||||
@ -2332,6 +2423,13 @@ function openwebrx_resize()
|
||||
check_top_bar_congestion();
|
||||
}
|
||||
|
||||
function init_header()
|
||||
{
|
||||
$('#openwebrx-main-buttons li[data-toggle-panel]').click(function() {
|
||||
toggle_panel($(this).data('toggle-panel'));
|
||||
});
|
||||
}
|
||||
|
||||
function openwebrx_init()
|
||||
{
|
||||
if(ios||is_chrome) e("openwebrx-big-grey").style.display="table-cell";
|
||||
@ -2344,6 +2442,7 @@ function openwebrx_init()
|
||||
window.setTimeout(function(){window.setInterval(debug_audio,1000);},1000);
|
||||
window.addEventListener("resize",openwebrx_resize);
|
||||
check_top_bar_congestion();
|
||||
init_header();
|
||||
|
||||
//Synchronise volume with slider
|
||||
updateVolume();
|
||||
@ -2351,7 +2450,9 @@ function openwebrx_init()
|
||||
}
|
||||
|
||||
function digimodes_init() {
|
||||
hide_digitalvoice_panels();
|
||||
$(".openwebrx-meta-panel").each(function(_, p){
|
||||
p.openwebrxHidden = true;
|
||||
});
|
||||
|
||||
// initialze DMR timeslot muting
|
||||
$('.openwebrx-dmr-timeslot-panel').click(function(e) {
|
||||
@ -2638,12 +2739,19 @@ function demodulator_digital_replace(subtype)
|
||||
{
|
||||
case "bpsk31":
|
||||
case "rtty":
|
||||
case "ft8":
|
||||
case "wspr":
|
||||
case "jt65":
|
||||
case "jt9":
|
||||
case "ft4":
|
||||
secondary_demod_start(subtype);
|
||||
demodulator_analog_replace('usb', true);
|
||||
demodulator_buttons_update();
|
||||
break;
|
||||
}
|
||||
$('#openwebrx-panel-digimodes').attr('data-mode', subtype);
|
||||
toggle_panel("openwebrx-panel-digimodes", true);
|
||||
toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4'].indexOf(subtype) >= 0);
|
||||
}
|
||||
|
||||
function secondary_demod_create_canvas()
|
||||
@ -2698,6 +2806,7 @@ function secondary_demod_swap_canvases()
|
||||
function secondary_demod_init()
|
||||
{
|
||||
$("#openwebrx-panel-digimodes")[0].openwebrxHidden = true;
|
||||
$("#openwebrx-panel-wsjt-message")[0].openwebrxHidden = true;
|
||||
secondary_demod_canvas_container = $("#openwebrx-digimode-canvas-container")[0];
|
||||
$(secondary_demod_canvas_container)
|
||||
.mousemove(secondary_demod_canvas_container_mousemove)
|
||||
@ -2705,6 +2814,7 @@ function secondary_demod_init()
|
||||
.mousedown(secondary_demod_canvas_container_mousedown)
|
||||
.mouseenter(secondary_demod_canvas_container_mousein)
|
||||
.mouseleave(secondary_demod_canvas_container_mouseout);
|
||||
init_wsjt_removal_timer();
|
||||
}
|
||||
|
||||
function secondary_demod_start(subtype)
|
||||
@ -2762,6 +2872,7 @@ function secondary_demod_close_window()
|
||||
{
|
||||
secondary_demod_stop();
|
||||
toggle_panel("openwebrx-panel-digimodes", false);
|
||||
toggle_panel("openwebrx-panel-wsjt-message", false);
|
||||
}
|
||||
|
||||
secondary_demod_fft_offset_db=30; //need to calculate that later
|
||||
@ -2802,19 +2913,23 @@ function secondary_demod_waterfall_dequeue()
|
||||
secondary_demod_listbox_updating = false;
|
||||
function secondary_demod_listbox_changed()
|
||||
{
|
||||
if(secondary_demod_listbox_updating) return;
|
||||
switch ($("#openwebrx-secondary-demod-listbox")[0].value)
|
||||
{
|
||||
if (secondary_demod_listbox_updating) return;
|
||||
var sdm = $("#openwebrx-secondary-demod-listbox")[0].value;
|
||||
switch (sdm) {
|
||||
case "none":
|
||||
demodulator_analog_replace_last();
|
||||
break;
|
||||
case "bpsk31":
|
||||
demodulator_digital_replace('bpsk31');
|
||||
break;
|
||||
case "rtty":
|
||||
demodulator_digital_replace('rtty');
|
||||
case "ft8":
|
||||
case "wspr":
|
||||
case "jt65":
|
||||
case "jt9":
|
||||
case "ft4":
|
||||
demodulator_digital_replace(sdm);
|
||||
break;
|
||||
}
|
||||
update_dial_button();
|
||||
}
|
||||
|
||||
function secondary_demod_listbox_update()
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
24
openwebrx.py
Normal file → Executable file
24
openwebrx.py
Normal file → Executable file
@ -1,13 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from http.server import HTTPServer
|
||||
from owrx.http import RequestHandler
|
||||
from owrx.config import PropertyManager
|
||||
from owrx.feature import FeatureDetector
|
||||
from owrx.feature import FeatureDetector
|
||||
from owrx.source import SdrService, ClientRegistry
|
||||
from socketserver import ThreadingMixIn
|
||||
from owrx.sdrhu import SdrHuUpdater
|
||||
from owrx.service import ServiceManager
|
||||
|
||||
import logging
|
||||
logging.basicConfig(level = logging.DEBUG, format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
|
||||
class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
|
||||
@ -15,21 +19,25 @@ class ThreadedHttpServer(ThreadingMixIn, HTTPServer):
|
||||
|
||||
|
||||
def main():
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
|
||||
OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package
|
||||
_________________________________________________________________________________________________
|
||||
|
||||
Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
|
||||
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
pm = PropertyManager.getSharedInstance().loadConfig("config_webrx")
|
||||
|
||||
featureDetector = FeatureDetector()
|
||||
if not featureDetector.is_available("core"):
|
||||
print("you are missing required dependencies to run openwebrx. "
|
||||
"please check that the following core requirements are installed:")
|
||||
print(
|
||||
"you are missing required dependencies to run openwebrx. "
|
||||
"please check that the following core requirements are installed:"
|
||||
)
|
||||
print(", ".join(featureDetector.get_requirements("core")))
|
||||
return
|
||||
|
||||
@ -40,7 +48,9 @@ Author contact info: Andras Retzler, HA7ILM <randras@sdr.hu>
|
||||
updater = SdrHuUpdater()
|
||||
updater.start()
|
||||
|
||||
server = ThreadedHttpServer(('0.0.0.0', pm.getPropertyValue("web_port")), RequestHandler)
|
||||
ServiceManager.getSharedInstance().start()
|
||||
|
||||
server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
65
owrx/bands.py
Normal file
65
owrx/bands.py
Normal 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)]
|
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -15,7 +16,7 @@ class Subscription(object):
|
||||
|
||||
|
||||
class Property(object):
|
||||
def __init__(self, value = None):
|
||||
def __init__(self, value=None):
|
||||
self.value = value
|
||||
self.subscribers = []
|
||||
|
||||
@ -23,7 +24,7 @@ class Property(object):
|
||||
return self.value
|
||||
|
||||
def setValue(self, value):
|
||||
if (self.value == value):
|
||||
if self.value == value:
|
||||
return self
|
||||
self.value = value
|
||||
for c in self.subscribers:
|
||||
@ -36,7 +37,8 @@ class Property(object):
|
||||
def wire(self, callback):
|
||||
sub = Subscription(self, callback)
|
||||
self.subscribers.append(sub)
|
||||
if not self.value is None: sub.call(self.value)
|
||||
if not self.value is None:
|
||||
sub.call(self.value)
|
||||
return sub
|
||||
|
||||
def unwire(self, sub):
|
||||
@ -47,8 +49,10 @@ class Property(object):
|
||||
pass
|
||||
return self
|
||||
|
||||
|
||||
class PropertyManager(object):
|
||||
sharedInstance = None
|
||||
|
||||
@staticmethod
|
||||
def getSharedInstance():
|
||||
if PropertyManager.sharedInstance is None:
|
||||
@ -56,9 +60,11 @@ class PropertyManager(object):
|
||||
return PropertyManager.sharedInstance
|
||||
|
||||
def collect(self, *props):
|
||||
return PropertyManager({name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props})
|
||||
return PropertyManager(
|
||||
{name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props}
|
||||
)
|
||||
|
||||
def __init__(self, properties = None):
|
||||
def __init__(self, properties=None):
|
||||
self.properties = {}
|
||||
self.subscribers = []
|
||||
if properties is not None:
|
||||
@ -67,12 +73,14 @@ class PropertyManager(object):
|
||||
|
||||
def add(self, name, prop):
|
||||
self.properties[name] = prop
|
||||
|
||||
def fireCallbacks(value):
|
||||
for c in self.subscribers:
|
||||
try:
|
||||
c.call(name, value)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
prop.wire(fireCallbacks)
|
||||
return self
|
||||
|
||||
@ -88,7 +96,7 @@ class PropertyManager(object):
|
||||
self.getProperty(name).setValue(value)
|
||||
|
||||
def __dict__(self):
|
||||
return {k:v.getValue() for k, v in self.properties.items()}
|
||||
return {k: v.getValue() for k, v in self.properties.items()}
|
||||
|
||||
def hasProperty(self, name):
|
||||
return name in self.properties
|
||||
|
@ -1,20 +1,59 @@
|
||||
from owrx.config import PropertyManager
|
||||
from owrx.source import DspManager, CpuUsageThread, SdrService, ClientRegistry
|
||||
from owrx.feature import FeatureDetector
|
||||
from owrx.version import openwebrx_version
|
||||
from owrx.bands import Bandplan
|
||||
import json
|
||||
from owrx.map import Map
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OpenWebRxClient(object):
|
||||
config_keys = ["waterfall_colors", "waterfall_min_level", "waterfall_max_level",
|
||||
"waterfall_auto_level_margin", "lfo_offset", "samp_rate", "fft_size", "fft_fps",
|
||||
"audio_compression", "fft_compression", "max_clients", "start_mod",
|
||||
"client_audio_buffer_size", "start_freq", "center_freq", "mathbox_waterfall_colors",
|
||||
"mathbox_waterfall_history_length", "mathbox_waterfall_frequency_resolution"]
|
||||
|
||||
class Client(object):
|
||||
def __init__(self, conn):
|
||||
self.conn = conn
|
||||
|
||||
def protected_send(self, data):
|
||||
try:
|
||||
self.conn.send(data)
|
||||
# these exception happen when the socket is closed
|
||||
except OSError:
|
||||
self.close()
|
||||
except ValueError:
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
logger.debug("connection closed")
|
||||
|
||||
|
||||
class OpenWebRxReceiverClient(Client):
|
||||
config_keys = [
|
||||
"waterfall_colors",
|
||||
"waterfall_min_level",
|
||||
"waterfall_max_level",
|
||||
"waterfall_auto_level_margin",
|
||||
"lfo_offset",
|
||||
"samp_rate",
|
||||
"fft_size",
|
||||
"fft_fps",
|
||||
"audio_compression",
|
||||
"fft_compression",
|
||||
"max_clients",
|
||||
"start_mod",
|
||||
"client_audio_buffer_size",
|
||||
"start_freq",
|
||||
"center_freq",
|
||||
"mathbox_waterfall_colors",
|
||||
"mathbox_waterfall_history_length",
|
||||
"mathbox_waterfall_frequency_resolution",
|
||||
]
|
||||
|
||||
def __init__(self, conn):
|
||||
super().__init__(conn)
|
||||
|
||||
self.dsp = None
|
||||
self.sdr = None
|
||||
self.configSub = None
|
||||
@ -26,12 +65,23 @@ class OpenWebRxClient(object):
|
||||
self.setSdr()
|
||||
|
||||
# send receiver info
|
||||
receiver_keys = ["receiver_name", "receiver_location", "receiver_qra", "receiver_asl", "receiver_gps",
|
||||
"photo_title", "photo_desc"]
|
||||
receiver_keys = [
|
||||
"receiver_name",
|
||||
"receiver_location",
|
||||
"receiver_qra",
|
||||
"receiver_asl",
|
||||
"receiver_gps",
|
||||
"photo_title",
|
||||
"photo_desc",
|
||||
]
|
||||
receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys)
|
||||
self.write_receiver_details(receiver_details)
|
||||
|
||||
profiles = [{"name": s.getName() + " " + p["name"], "id":sid + "|" + pid} for (sid, s) in SdrService.getSources().items() for (pid, p) in s.getProfiles().items()]
|
||||
profiles = [
|
||||
{"name": s.getName() + " " + p["name"], "id": sid + "|" + pid}
|
||||
for (sid, s) in SdrService.getSources().items()
|
||||
for (pid, p) in s.getProfiles().items()
|
||||
]
|
||||
self.write_profiles(profiles)
|
||||
|
||||
features = FeatureDetector().feature_availability()
|
||||
@ -39,9 +89,9 @@ class OpenWebRxClient(object):
|
||||
|
||||
CpuUsageThread.getSharedInstance().add_client(self)
|
||||
|
||||
def setSdr(self, id = None):
|
||||
def setSdr(self, id=None):
|
||||
next = SdrService.getSource(id)
|
||||
if (next == self.sdr):
|
||||
if next == self.sdr:
|
||||
return
|
||||
|
||||
self.stopDsp()
|
||||
@ -53,14 +103,23 @@ class OpenWebRxClient(object):
|
||||
self.sdr = next
|
||||
|
||||
# send initial config
|
||||
configProps = self.sdr.getProps().collect(*OpenWebRxClient.config_keys).defaults(PropertyManager.getSharedInstance())
|
||||
configProps = (
|
||||
self.sdr.getProps()
|
||||
.collect(*OpenWebRxReceiverClient.config_keys)
|
||||
.defaults(PropertyManager.getSharedInstance())
|
||||
)
|
||||
|
||||
def sendConfig(key, value):
|
||||
config = dict((key, configProps[key]) for key in OpenWebRxClient.config_keys)
|
||||
config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys)
|
||||
# TODO mathematical properties? hmmmm
|
||||
config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"]
|
||||
self.write_config(config)
|
||||
|
||||
cf = configProps["center_freq"]
|
||||
srh = configProps["samp_rate"] / 2
|
||||
frequencyRange = (cf - srh, cf + srh)
|
||||
self.write_dial_frequendies(Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange))
|
||||
|
||||
self.configSub = configProps.wire(sendConfig)
|
||||
sendConfig(None, None)
|
||||
|
||||
@ -78,8 +137,7 @@ class OpenWebRxClient(object):
|
||||
if self.configSub is not None:
|
||||
self.configSub.cancel()
|
||||
self.configSub = None
|
||||
self.conn.close()
|
||||
logger.debug("connection closed")
|
||||
super().close()
|
||||
|
||||
def stopDsp(self):
|
||||
if self.dsp is not None:
|
||||
@ -90,8 +148,11 @@ class OpenWebRxClient(object):
|
||||
|
||||
def setParams(self, params):
|
||||
# only the keys in the protected property manager can be overridden from the web
|
||||
protected = self.sdr.getProps().collect("samp_rate", "center_freq", "rf_gain", "type", "if_gain") \
|
||||
protected = (
|
||||
self.sdr.getProps()
|
||||
.collect("samp_rate", "center_freq", "rf_gain", "type", "if_gain")
|
||||
.defaults(PropertyManager.getSharedInstance())
|
||||
)
|
||||
for key, value in params.items():
|
||||
protected[key] = value
|
||||
|
||||
@ -99,41 +160,71 @@ class OpenWebRxClient(object):
|
||||
for key, value in params.items():
|
||||
self.dsp.setProperty(key, value)
|
||||
|
||||
def protected_send(self, data):
|
||||
try:
|
||||
self.conn.send(data)
|
||||
# these exception happen when the socket is closed
|
||||
except OSError:
|
||||
self.close()
|
||||
except ValueError:
|
||||
self.close()
|
||||
|
||||
def write_spectrum_data(self, data):
|
||||
self.protected_send(bytes([0x01]) + data)
|
||||
|
||||
def write_dsp_data(self, data):
|
||||
self.protected_send(bytes([0x02]) + data)
|
||||
|
||||
def write_s_meter_level(self, level):
|
||||
self.protected_send({"type":"smeter","value":level})
|
||||
self.protected_send({"type": "smeter", "value": level})
|
||||
|
||||
def write_cpu_usage(self, usage):
|
||||
self.protected_send({"type":"cpuusage","value":usage})
|
||||
self.protected_send({"type": "cpuusage", "value": usage})
|
||||
|
||||
def write_clients(self, clients):
|
||||
self.protected_send({"type":"clients","value":clients})
|
||||
self.protected_send({"type": "clients", "value": clients})
|
||||
|
||||
def write_secondary_fft(self, data):
|
||||
self.protected_send(bytes([0x03]) + data)
|
||||
|
||||
def write_secondary_demod(self, data):
|
||||
self.protected_send(bytes([0x04]) + data)
|
||||
|
||||
def write_secondary_dsp_config(self, cfg):
|
||||
self.protected_send({"type":"secondary_config", "value":cfg})
|
||||
self.protected_send({"type": "secondary_config", "value": cfg})
|
||||
|
||||
def write_config(self, cfg):
|
||||
self.protected_send({"type":"config","value":cfg})
|
||||
self.protected_send({"type": "config", "value": cfg})
|
||||
|
||||
def write_receiver_details(self, details):
|
||||
self.protected_send({"type":"receiver_details","value":details})
|
||||
self.protected_send({"type": "receiver_details", "value": details})
|
||||
|
||||
def write_profiles(self, profiles):
|
||||
self.protected_send({"type":"profiles","value":profiles})
|
||||
self.protected_send({"type": "profiles", "value": profiles})
|
||||
|
||||
def write_features(self, features):
|
||||
self.protected_send({"type":"features","value":features})
|
||||
self.protected_send({"type": "features", "value": features})
|
||||
|
||||
def write_metadata(self, metadata):
|
||||
self.protected_send({"type":"metadata","value":metadata})
|
||||
self.protected_send({"type": "metadata", "value": metadata})
|
||||
|
||||
def write_wsjt_message(self, message):
|
||||
self.protected_send({"type": "wsjt_message", "value": message})
|
||||
|
||||
def write_dial_frequendies(self, frequencies):
|
||||
self.protected_send({"type": "dial_frequencies", "value": frequencies})
|
||||
|
||||
|
||||
class MapConnection(Client):
|
||||
def __init__(self, conn):
|
||||
super().__init__(conn)
|
||||
|
||||
pm = PropertyManager.getSharedInstance()
|
||||
self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__())
|
||||
|
||||
Map.getSharedInstance().addClient(self)
|
||||
|
||||
def close(self):
|
||||
Map.getSharedInstance().removeClient(self)
|
||||
super().close()
|
||||
|
||||
def write_config(self, cfg):
|
||||
self.protected_send({"type": "config", "value": cfg})
|
||||
|
||||
def write_update(self, update):
|
||||
self.protected_send({"type": "update", "value": update})
|
||||
|
||||
|
||||
class WebSocketMessageHandler(object):
|
||||
def __init__(self):
|
||||
@ -142,12 +233,21 @@ class WebSocketMessageHandler(object):
|
||||
self.dsp = None
|
||||
|
||||
def handleTextMessage(self, conn, message):
|
||||
if (message[:16] == "SERVER DE CLIENT"):
|
||||
# maybe put some more info in there? nothing to store yet.
|
||||
self.handshake = "completed"
|
||||
if message[:16] == "SERVER DE CLIENT":
|
||||
meta = message[17:].split(" ")
|
||||
self.handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)}
|
||||
|
||||
conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version))
|
||||
logger.debug("client connection intitialized")
|
||||
|
||||
self.client = OpenWebRxClient(conn)
|
||||
if "type" in self.handshake:
|
||||
if self.handshake["type"] == "receiver":
|
||||
self.client = OpenWebRxReceiverClient(conn)
|
||||
if self.handshake["type"] == "map":
|
||||
self.client = MapConnection(conn)
|
||||
# backwards compatibility
|
||||
else:
|
||||
self.client = OpenWebRxReceiverClient(conn)
|
||||
|
||||
return
|
||||
|
||||
|
@ -1,20 +1,27 @@
|
||||
import os
|
||||
import mimetypes
|
||||
import json
|
||||
from datetime import datetime
|
||||
from string import Template
|
||||
from owrx.websocket import WebSocketConnection
|
||||
from owrx.config import PropertyManager
|
||||
from owrx.source import ClientRegistry
|
||||
from owrx.connection import WebSocketMessageHandler
|
||||
from owrx.version import openwebrx_version
|
||||
from owrx.feature import FeatureDetector
|
||||
from owrx.metrics import Metrics
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Controller(object):
|
||||
def __init__(self, handler, matches):
|
||||
def __init__(self, handler, request):
|
||||
self.handler = handler
|
||||
self.matches = matches
|
||||
def send_response(self, content, code = 200, content_type = "text/html", last_modified: datetime = None, max_age = None):
|
||||
self.request = request
|
||||
|
||||
def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None):
|
||||
self.handler.send_response(code)
|
||||
if content_type is not None:
|
||||
self.handler.send_header("Content-Type", content_type)
|
||||
@ -23,15 +30,10 @@ class Controller(object):
|
||||
if max_age is not None:
|
||||
self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age))
|
||||
self.handler.end_headers()
|
||||
if (type(content) == str):
|
||||
if type(content) == str:
|
||||
content = content.encode()
|
||||
self.handler.wfile.write(content)
|
||||
def render_template(self, template, **variables):
|
||||
f = open('htdocs/' + template)
|
||||
data = f.read()
|
||||
f.close()
|
||||
|
||||
self.send_response(data)
|
||||
|
||||
class StatusController(Controller):
|
||||
def handle_request(self):
|
||||
@ -47,41 +49,90 @@ class StatusController(Controller):
|
||||
"asl": pm["receiver_asl"],
|
||||
"loc": pm["receiver_location"],
|
||||
"sw_version": openwebrx_version,
|
||||
"avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png")
|
||||
"avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png"),
|
||||
}
|
||||
self.send_response("\n".join(["{key}={value}".format(key = key, value = value) for key, value in vars.items()]))
|
||||
self.send_response("\n".join(["{key}={value}".format(key=key, value=value) for key, value in vars.items()]))
|
||||
|
||||
|
||||
class AssetsController(Controller):
|
||||
def serve_file(self, file, content_type = None):
|
||||
def serve_file(self, file, content_type=None):
|
||||
try:
|
||||
modified = datetime.fromtimestamp(os.path.getmtime('htdocs/' + file))
|
||||
modified = datetime.fromtimestamp(os.path.getmtime("htdocs/" + file))
|
||||
|
||||
if "If-Modified-Since" in self.handler.headers:
|
||||
client_modified = datetime.strptime(self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z")
|
||||
client_modified = datetime.strptime(
|
||||
self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z"
|
||||
)
|
||||
if modified <= client_modified:
|
||||
self.send_response("", code = 304)
|
||||
self.send_response("", code=304)
|
||||
return
|
||||
|
||||
f = open('htdocs/' + file, 'rb')
|
||||
f = open("htdocs/" + file, "rb")
|
||||
data = f.read()
|
||||
f.close()
|
||||
|
||||
if content_type is None:
|
||||
(content_type, encoding) = mimetypes.MimeTypes().guess_type(file)
|
||||
self.send_response(data, content_type = content_type, last_modified = modified, max_age = 3600)
|
||||
self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600)
|
||||
except FileNotFoundError:
|
||||
self.send_response("file not found", code = 404)
|
||||
self.send_response("file not found", code=404)
|
||||
|
||||
def handle_request(self):
|
||||
filename = self.matches.group(1)
|
||||
filename = self.request.matches.group(1)
|
||||
self.serve_file(filename)
|
||||
|
||||
class IndexController(AssetsController):
|
||||
|
||||
class TemplateController(Controller):
|
||||
def render_template(self, file, **vars):
|
||||
f = open("htdocs/" + file, "r")
|
||||
template = Template(f.read())
|
||||
f.close()
|
||||
|
||||
return template.safe_substitute(**vars)
|
||||
|
||||
def serve_template(self, file, **vars):
|
||||
self.send_response(self.render_template(file, **vars), content_type="text/html")
|
||||
|
||||
def default_variables(self):
|
||||
return {}
|
||||
|
||||
|
||||
class WebpageController(TemplateController):
|
||||
def template_variables(self):
|
||||
header = self.render_template("include/header.include.html")
|
||||
return {"header": header}
|
||||
|
||||
|
||||
class IndexController(WebpageController):
|
||||
def handle_request(self):
|
||||
self.serve_file("index.html", content_type = "text/html")
|
||||
self.serve_template("index.html", **self.template_variables())
|
||||
|
||||
|
||||
class MapController(WebpageController):
|
||||
def handle_request(self):
|
||||
# TODO check if we have a google maps api key first?
|
||||
self.serve_template("map.html", **self.template_variables())
|
||||
|
||||
|
||||
class FeatureController(WebpageController):
|
||||
def handle_request(self):
|
||||
self.serve_template("features.html", **self.template_variables())
|
||||
|
||||
|
||||
class ApiController(Controller):
|
||||
def handle_request(self):
|
||||
data = json.dumps(FeatureDetector().feature_report())
|
||||
self.send_response(data, content_type="application/json")
|
||||
|
||||
|
||||
class MetricsController(Controller):
|
||||
def handle_request(self):
|
||||
data = json.dumps(Metrics.getSharedInstance().getMetrics())
|
||||
self.send_response(data, content_type="application/json")
|
||||
|
||||
|
||||
class WebSocketController(Controller):
|
||||
def handle_request(self):
|
||||
conn = WebSocketConnection(self.handler, WebSocketMessageHandler())
|
||||
conn.send("CLIENT DE SERVER openwebrx.py")
|
||||
# enter read loop
|
||||
conn.read_loop()
|
||||
|
181
owrx/feature.py
181
owrx/feature.py
@ -4,29 +4,52 @@ from functools import reduce
|
||||
from operator import and_
|
||||
import re
|
||||
from distutils.version import LooseVersion
|
||||
import inspect
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnknownFeatureException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FeatureDetector(object):
|
||||
features = {
|
||||
"core": [ "csdr", "nmux", "nc" ],
|
||||
"rtl_sdr": [ "rtl_sdr" ],
|
||||
"sdrplay": [ "rx_tools" ],
|
||||
"hackrf": [ "hackrf_transfer" ],
|
||||
"airspy": [ "airspy_rx" ],
|
||||
"digital_voice_digiham": [ "digiham", "sox" ],
|
||||
"digital_voice_dsd": [ "dsd", "sox", "digiham" ],
|
||||
"packet": [ "direwolf" ]
|
||||
"core": ["csdr", "nmux", "nc"],
|
||||
"rtl_sdr": ["rtl_sdr"],
|
||||
"sdrplay": ["rx_tools"],
|
||||
"hackrf": ["hackrf_transfer"],
|
||||
"airspy": ["airspy_rx"],
|
||||
"digital_voice_digiham": ["digiham", "sox"],
|
||||
"digital_voice_dsd": ["dsd", "sox", "digiham"],
|
||||
"wsjt-x": ["wsjtx", "sox"],
|
||||
"packet": [ "direwolf" ],
|
||||
}
|
||||
|
||||
def feature_availability(self):
|
||||
return {name: self.is_available(name) for name in FeatureDetector.features}
|
||||
|
||||
def feature_report(self):
|
||||
def requirement_details(name):
|
||||
available = self.has_requirement(name)
|
||||
return {
|
||||
"available": available,
|
||||
# as of now, features are always enabled as soon as they are available. this may change in the future.
|
||||
"enabled": available,
|
||||
"description": self.get_requirement_description(name),
|
||||
}
|
||||
|
||||
def feature_details(name):
|
||||
return {
|
||||
"description": "",
|
||||
"available": self.is_available(name),
|
||||
"requirements": {name: requirement_details(name) for name in self.get_requirements(name)},
|
||||
}
|
||||
|
||||
return {name: feature_details(name) for name in FeatureDetector.features}
|
||||
|
||||
def is_available(self, feature):
|
||||
return self.has_requirements(self.get_requirements(feature))
|
||||
|
||||
@ -34,50 +57,89 @@ class FeatureDetector(object):
|
||||
try:
|
||||
return FeatureDetector.features[feature]
|
||||
except KeyError:
|
||||
raise UnknownFeatureException("Feature \"{0}\" is not known.".format(feature))
|
||||
raise UnknownFeatureException('Feature "{0}" is not known.'.format(feature))
|
||||
|
||||
def has_requirements(self, requirements):
|
||||
passed = True
|
||||
for requirement in requirements:
|
||||
methodname = "has_" + requirement
|
||||
if hasattr(self, methodname) and callable(getattr(self, methodname)):
|
||||
passed = passed and getattr(self, methodname)()
|
||||
else:
|
||||
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
|
||||
passed = passed and self.has_requirement(requirement)
|
||||
return passed
|
||||
|
||||
def _get_requirement_method(self, requirement):
|
||||
methodname = "has_" + requirement
|
||||
if hasattr(self, methodname) and callable(getattr(self, methodname)):
|
||||
return getattr(self, methodname)
|
||||
return None
|
||||
|
||||
def has_requirement(self, requirement):
|
||||
method = self._get_requirement_method(requirement)
|
||||
if method is not None:
|
||||
return method()
|
||||
else:
|
||||
logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement))
|
||||
return False
|
||||
|
||||
def get_requirement_description(self, requirement):
|
||||
return inspect.getdoc(self._get_requirement_method(requirement))
|
||||
|
||||
def command_is_runnable(self, command):
|
||||
return os.system("{0} 2>/dev/null >/dev/null".format(command)) != 32512
|
||||
|
||||
def has_csdr(self):
|
||||
"""
|
||||
OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project
|
||||
page on github](https://github.com/simonyiszk/csdr) for further details and installation instructions.
|
||||
"""
|
||||
return self.command_is_runnable("csdr")
|
||||
|
||||
def has_nmux(self):
|
||||
"""
|
||||
Nmux is another tool provided by the csdr project. It is used for internal multiplexing of the IQ data streams.
|
||||
If you're missing nmux even though you have csdr installed, please update your csdr version.
|
||||
"""
|
||||
return self.command_is_runnable("nmux --help")
|
||||
|
||||
def has_nc(self):
|
||||
return self.command_is_runnable('nc --help')
|
||||
"""
|
||||
Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended
|
||||
for better performance) or GNU netcat packages. Please check your distribution package manager for options.
|
||||
"""
|
||||
return self.command_is_runnable("nc --help")
|
||||
|
||||
def has_rtl_sdr(self):
|
||||
"""
|
||||
The rtl-sdr command is required to read I/Q data from an RTL SDR USB-Stick. It is available in most
|
||||
distribution package managers.
|
||||
"""
|
||||
return self.command_is_runnable("rtl_sdr --help")
|
||||
|
||||
def has_rx_tools(self):
|
||||
"""
|
||||
The rx_tools package can be used to interface with SDR devices compatible with SoapySDR. It is currently used
|
||||
to connect to SDRPlay devices. Please check the following pages for more details:
|
||||
|
||||
* [rx_tools GitHub page](https://github.com/rxseger/rx_tools)
|
||||
* [SoapySDR Project wiki](https://github.com/pothosware/SoapySDR/wiki)
|
||||
* [SDRPlay homepage](https://www.sdrplay.com/)
|
||||
"""
|
||||
return self.command_is_runnable("rx_sdr --help")
|
||||
|
||||
"""
|
||||
To use a HackRF, compile the HackRF host tools from its "stdout" branch:
|
||||
git clone https://github.com/mossmann/hackrf/
|
||||
cd hackrf
|
||||
git fetch
|
||||
git checkout origin/stdout
|
||||
cd host
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -DINSTALL_UDEV_RULES=ON
|
||||
make
|
||||
sudo make install
|
||||
"""
|
||||
def has_hackrf_transfer(self):
|
||||
"""
|
||||
To use a HackRF, compile the HackRF host tools from its "stdout" branch:
|
||||
```
|
||||
git clone https://github.com/mossmann/hackrf/
|
||||
cd hackrf
|
||||
git fetch
|
||||
git checkout origin/stdout
|
||||
cd host
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -DINSTALL_UDEV_RULES=ON
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
"""
|
||||
# TODO i don't have a hackrf, so somebody doublecheck this.
|
||||
# TODO also check if it has the stdout feature
|
||||
return self.command_is_runnable("hackrf_transfer --help")
|
||||
@ -85,18 +147,19 @@ class FeatureDetector(object):
|
||||
def command_exists(self, command):
|
||||
return os.system("which {0}".format(command)) == 0
|
||||
|
||||
"""
|
||||
To use DMR and YSF, the digiham package is required. You can find the package and installation instructions here:
|
||||
https://github.com/jketterl/digiham
|
||||
|
||||
Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work.
|
||||
If you have an older verison of digiham installed, please update it along with openwebrx.
|
||||
As of now, we require version 0.2 of digiham.
|
||||
"""
|
||||
def has_digiham(self):
|
||||
"""
|
||||
To use digital voice modes, the digiham package is required. You can find the package and installation
|
||||
instructions [here](https://github.com/jketterl/digiham).
|
||||
|
||||
Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work.
|
||||
If you have an older verison of digiham installed, please update it along with openwebrx.
|
||||
As of now, we require version 0.2 of digiham.
|
||||
"""
|
||||
required_version = LooseVersion("0.2")
|
||||
|
||||
digiham_version_regex = re.compile('^digiham version (.*)$')
|
||||
digiham_version_regex = re.compile("^digiham version (.*)$")
|
||||
|
||||
def check_digiham_version(command):
|
||||
try:
|
||||
process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE)
|
||||
@ -105,22 +168,52 @@ class FeatureDetector(object):
|
||||
return version >= required_version
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return reduce(and_,
|
||||
map(
|
||||
check_digiham_version,
|
||||
["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator",
|
||||
"digitalvoice_filter"]
|
||||
),
|
||||
True)
|
||||
|
||||
return reduce(
|
||||
and_,
|
||||
map(
|
||||
check_digiham_version,
|
||||
[
|
||||
"rrc_filter",
|
||||
"ysf_decoder",
|
||||
"dmr_decoder",
|
||||
"mbe_synthesizer",
|
||||
"gfsk_demodulator",
|
||||
"digitalvoice_filter",
|
||||
],
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
def has_dsd(self):
|
||||
"""
|
||||
The digital voice modes NXDN and D-Star can be decoded by the dsd project. Please note that you need the version
|
||||
modified by F4EXB that provides stdin/stdout support. You can find it [here](https://github.com/f4exb/dsd).
|
||||
"""
|
||||
return self.command_is_runnable("dsd")
|
||||
|
||||
def has_sox(self):
|
||||
"""
|
||||
The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and
|
||||
the audio sampling rate requested by the client.
|
||||
|
||||
It is available for most distributions through the respective package manager.
|
||||
"""
|
||||
return self.command_is_runnable("sox")
|
||||
|
||||
def has_direwolf(self):
|
||||
return self.command_is_runnable("direwolf --help")
|
||||
|
||||
def has_airspy_rx(self):
|
||||
"""
|
||||
In order to use an Airspy Receiver, you need to install the airspy_rx receiver software.
|
||||
"""
|
||||
return self.command_is_runnable("airspy_rx --help 2> /dev/null")
|
||||
|
||||
def has_wsjtx(self):
|
||||
"""
|
||||
To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the
|
||||
[WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions
|
||||
on how to build from source.
|
||||
"""
|
||||
return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True)
|
||||
|
41
owrx/http.py
41
owrx/http.py
@ -1,17 +1,37 @@
|
||||
from owrx.controllers import StatusController, IndexController, AssetsController, WebSocketController
|
||||
from owrx.controllers import (
|
||||
StatusController,
|
||||
IndexController,
|
||||
AssetsController,
|
||||
WebSocketController,
|
||||
MapController,
|
||||
FeatureController,
|
||||
ApiController,
|
||||
MetricsController,
|
||||
)
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
import re
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
def __init__(self, request, client_address, server):
|
||||
self.router = Router()
|
||||
super().__init__(request, client_address, server)
|
||||
|
||||
def do_GET(self):
|
||||
self.router.route(self)
|
||||
|
||||
|
||||
class Request(object):
|
||||
def __init__(self, query=None, matches=None):
|
||||
self.query = query
|
||||
self.matches = matches
|
||||
|
||||
|
||||
class Router(object):
|
||||
mappings = [
|
||||
{"route": "/", "controller": IndexController},
|
||||
@ -20,8 +40,13 @@ class Router(object):
|
||||
{"route": "/ws/", "controller": WebSocketController},
|
||||
{"regex": "(/favicon.ico)", "controller": AssetsController},
|
||||
# backwards compatibility for the sdr.hu portal
|
||||
{"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController}
|
||||
{"regex": "/(gfx/openwebrx-avatar.png)", "controller": AssetsController},
|
||||
{"route": "/map", "controller": MapController},
|
||||
{"route": "/features", "controller": FeatureController},
|
||||
{"route": "/api/features", "controller": ApiController},
|
||||
{"route": "/metrics", "controller": MetricsController},
|
||||
]
|
||||
|
||||
def find_controller(self, path):
|
||||
for m in Router.mappings:
|
||||
if "route" in m:
|
||||
@ -32,11 +57,17 @@ class Router(object):
|
||||
matches = regex.match(path)
|
||||
if matches:
|
||||
return (m["controller"], matches)
|
||||
|
||||
def route(self, handler):
|
||||
res = self.find_controller(handler.path)
|
||||
url = urlparse(handler.path)
|
||||
res = self.find_controller(url.path)
|
||||
if res is not None:
|
||||
(controller, matches) = res
|
||||
logger.debug("path: {0}, controller: {1}, matches: {2}".format(handler.path, controller, matches))
|
||||
controller(handler, matches).handle_request()
|
||||
query = parse_qs(url.query)
|
||||
logger.debug(
|
||||
"path: {0}, controller: {1}, query: {2}, matches: {3}".format(handler.path, controller, query, matches)
|
||||
)
|
||||
request = Request(query, matches)
|
||||
controller(handler, request).handle_request()
|
||||
else:
|
||||
handler.send_error(404, "Not Found", "The page you requested could not be found.")
|
||||
|
108
owrx/map.py
Normal file
108
owrx/map.py
Normal 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}
|
45
owrx/meta.py
45
owrx/meta.py
@ -4,36 +4,43 @@ import json
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import threading
|
||||
from owrx.map import Map, LatLngLocation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DmrCache(object):
|
||||
sharedInstance = None
|
||||
|
||||
@staticmethod
|
||||
def getSharedInstance():
|
||||
if DmrCache.sharedInstance is None:
|
||||
DmrCache.sharedInstance = DmrCache()
|
||||
return DmrCache.sharedInstance
|
||||
|
||||
def __init__(self):
|
||||
self.cache = {}
|
||||
self.cacheTimeout = timedelta(seconds = 86400)
|
||||
self.cacheTimeout = timedelta(seconds=86400)
|
||||
|
||||
def isValid(self, key):
|
||||
if not key in self.cache: return False
|
||||
if not key in self.cache:
|
||||
return False
|
||||
entry = self.cache[key]
|
||||
return entry["timestamp"] + self.cacheTimeout > datetime.now()
|
||||
|
||||
def put(self, key, value):
|
||||
self.cache[key] = {
|
||||
"timestamp": datetime.now(),
|
||||
"data": value
|
||||
}
|
||||
self.cache[key] = {"timestamp": datetime.now(), "data": value}
|
||||
|
||||
def get(self, key):
|
||||
if not self.isValid(key): return None
|
||||
if not self.isValid(key):
|
||||
return None
|
||||
return self.cache[key]["data"]
|
||||
|
||||
|
||||
class DmrMetaEnricher(object):
|
||||
def __init__(self):
|
||||
self.threads = {}
|
||||
|
||||
def downloadRadioIdData(self, id):
|
||||
cache = DmrCache.getSharedInstance()
|
||||
try:
|
||||
@ -44,9 +51,12 @@ class DmrMetaEnricher(object):
|
||||
except json.JSONDecodeError:
|
||||
cache.put(id, None)
|
||||
del self.threads[id]
|
||||
|
||||
def enrich(self, meta):
|
||||
if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: return None
|
||||
if not "source" in meta: return None
|
||||
if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]:
|
||||
return None
|
||||
if not "source" in meta:
|
||||
return None
|
||||
id = meta["source"]
|
||||
cache = DmrCache.getSharedInstance()
|
||||
if not cache.isValid(id):
|
||||
@ -60,10 +70,17 @@ class DmrMetaEnricher(object):
|
||||
return None
|
||||
|
||||
|
||||
class YsfMetaEnricher(object):
|
||||
def enrich(self, meta):
|
||||
if "source" in meta and "lat" in meta and "lon" in meta:
|
||||
# TODO parsing the float values should probably happen earlier
|
||||
loc = LatLngLocation(float(meta["lat"]), float(meta["lon"]))
|
||||
Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF")
|
||||
return None
|
||||
|
||||
|
||||
class MetaParser(object):
|
||||
enrichers = {
|
||||
"DMR": DmrMetaEnricher()
|
||||
}
|
||||
enrichers = {"DMR": DmrMetaEnricher(), "YSF": YsfMetaEnricher()}
|
||||
|
||||
def __init__(self, handler):
|
||||
self.handler = handler
|
||||
@ -76,6 +93,6 @@ class MetaParser(object):
|
||||
protocol = meta["protocol"]
|
||||
if protocol in MetaParser.enrichers:
|
||||
additional_data = MetaParser.enrichers[protocol].enrich(meta)
|
||||
if additional_data is not None: meta["additional"] = additional_data
|
||||
if additional_data is not None:
|
||||
meta["additional"] = additional_data
|
||||
self.handler.write_metadata(meta)
|
||||
|
||||
|
30
owrx/metrics.py
Normal file
30
owrx/metrics.py
Normal 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
|
@ -4,23 +4,26 @@ import time
|
||||
from owrx.config import PropertyManager
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SdrHuUpdater(threading.Thread):
|
||||
def __init__(self):
|
||||
self.doRun = True
|
||||
super().__init__(daemon = True)
|
||||
super().__init__(daemon=True)
|
||||
|
||||
def update(self):
|
||||
pm = PropertyManager.getSharedInstance()
|
||||
cmd = "wget --timeout=15 -4qO- https://sdr.hu/update --post-data \"url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}\" 2>&1".format(**pm.__dict__())
|
||||
cmd = 'wget --timeout=15 -4qO- https://sdr.hu/update --post-data "url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}" 2>&1'.format(
|
||||
**pm.__dict__()
|
||||
)
|
||||
logger.debug(cmd)
|
||||
returned=subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
|
||||
returned=returned[0].decode('utf-8')
|
||||
returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate()
|
||||
returned = returned[0].decode("utf-8")
|
||||
if "UPDATE:" in returned:
|
||||
retrytime_mins = 20
|
||||
value=returned.split("UPDATE:")[1].split("\n",1)[0]
|
||||
value = returned.split("UPDATE:")[1].split("\n", 1)[0]
|
||||
if value.startswith("SUCCESS"):
|
||||
logger.info("Update succeeded!")
|
||||
else:
|
||||
@ -33,4 +36,4 @@ class SdrHuUpdater(threading.Thread):
|
||||
def run(self):
|
||||
while self.doRun:
|
||||
retrytime_mins = self.update()
|
||||
time.sleep(60*retrytime_mins)
|
||||
time.sleep(60 * retrytime_mins)
|
||||
|
118
owrx/service.py
Normal file
118
owrx/service.py
Normal 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
|
239
owrx/source.py
239
owrx/source.py
@ -2,6 +2,7 @@ import subprocess
|
||||
from owrx.config import PropertyManager
|
||||
from owrx.feature import FeatureDetector, UnknownFeatureException
|
||||
from owrx.meta import MetaParser
|
||||
from owrx.wsjt import WsjtParser
|
||||
import threading
|
||||
import csdr
|
||||
import time
|
||||
@ -13,10 +14,12 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SdrService(object):
|
||||
sdrProps = None
|
||||
sources = {}
|
||||
lastPort = None
|
||||
|
||||
@staticmethod
|
||||
def getNextPort():
|
||||
pm = PropertyManager.getSharedInstance()
|
||||
@ -28,50 +31,70 @@ class SdrService(object):
|
||||
if SdrService.lastPort > end:
|
||||
raise IndexError("no more available ports to start more sdrs")
|
||||
return SdrService.lastPort
|
||||
|
||||
@staticmethod
|
||||
def loadProps():
|
||||
if SdrService.sdrProps is None:
|
||||
pm = PropertyManager.getSharedInstance()
|
||||
featureDetector = FeatureDetector()
|
||||
|
||||
def loadIntoPropertyManager(dict: dict):
|
||||
propertyManager = PropertyManager()
|
||||
for (name, value) in dict.items():
|
||||
propertyManager[name] = value
|
||||
return propertyManager
|
||||
|
||||
def sdrTypeAvailable(value):
|
||||
try:
|
||||
if not featureDetector.is_available(value["type"]):
|
||||
logger.error("The RTL source type \"{0}\" is not available. please check requirements.".format(value["type"]))
|
||||
logger.error(
|
||||
'The RTL source type "{0}" is not available. please check requirements.'.format(
|
||||
value["type"]
|
||||
)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except UnknownFeatureException:
|
||||
logger.error("The RTL source type \"{0}\" is invalid. Please check your configuration".format(value["type"]))
|
||||
logger.error(
|
||||
'The RTL source type "{0}" is invalid. Please check your configuration'.format(value["type"])
|
||||
)
|
||||
return False
|
||||
|
||||
# transform all dictionary items into PropertyManager object, filtering out unavailable ones
|
||||
SdrService.sdrProps = {
|
||||
name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value)
|
||||
}
|
||||
logger.info("SDR sources loaded. Availables SDRs: {0}".format(", ".join(map(lambda x: x["name"], SdrService.sdrProps.values()))))
|
||||
logger.info(
|
||||
"SDR sources loaded. Availables SDRs: {0}".format(
|
||||
", ".join(map(lambda x: x["name"], SdrService.sdrProps.values()))
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def getSource(id = None):
|
||||
def getSource(id=None):
|
||||
SdrService.loadProps()
|
||||
if id is None:
|
||||
# TODO: configure default sdr in config? right now it will pick the first one off the list.
|
||||
id = list(SdrService.sdrProps.keys())[0]
|
||||
sources = SdrService.getSources()
|
||||
return sources[id]
|
||||
|
||||
@staticmethod
|
||||
def getSources():
|
||||
SdrService.loadProps()
|
||||
for id in SdrService.sdrProps.keys():
|
||||
if not id in SdrService.sources:
|
||||
props = SdrService.sdrProps[id]
|
||||
className = ''.join(x for x in props["type"].title() if x.isalnum()) + "Source"
|
||||
className = "".join(x for x in props["type"].title() if x.isalnum()) + "Source"
|
||||
cls = getattr(sys.modules[__name__], className)
|
||||
SdrService.sources[id] = cls(props, SdrService.getNextPort())
|
||||
return SdrService.sources
|
||||
|
||||
|
||||
class SdrSourceException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SdrSource(object):
|
||||
def __init__(self, props, port):
|
||||
self.props = props
|
||||
@ -84,6 +107,7 @@ class SdrSource(object):
|
||||
logger.debug("restarting sdr source due to property change: {0} changed to {1}".format(name, value))
|
||||
self.stop()
|
||||
self.start()
|
||||
|
||||
self.rtlProps.wire(restart)
|
||||
self.port = port
|
||||
self.monitor = None
|
||||
@ -101,15 +125,16 @@ class SdrSource(object):
|
||||
def getFormatConversion(self):
|
||||
return None
|
||||
|
||||
def activateProfile(self, id = None):
|
||||
def activateProfile(self, profile_id=None):
|
||||
profiles = self.props["profiles"]
|
||||
if id is None:
|
||||
id = list(profiles.keys())[0]
|
||||
logger.debug("activating profile {0}".format(id))
|
||||
profile = profiles[id]
|
||||
if profile_id is None:
|
||||
profile_id = list(profiles.keys())[0]
|
||||
logger.debug("activating profile {0}".format(profile_id))
|
||||
profile = profiles[profile_id]
|
||||
for (key, value) in profile.items():
|
||||
# skip the name, that would overwrite the source name.
|
||||
if key == "name": continue
|
||||
if key == "name":
|
||||
continue
|
||||
self.props[key] = value
|
||||
|
||||
def getProfiles(self):
|
||||
@ -133,7 +158,9 @@ class SdrSource(object):
|
||||
props = self.rtlProps
|
||||
|
||||
start_sdr_command = self.getCommand().format(
|
||||
**props.collect("samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain").__dict__()
|
||||
**props.collect(
|
||||
"samp_rate", "center_freq", "ppm", "rf_gain", "lna_gain", "rf_amp", "antenna", "if_gain"
|
||||
).__dict__()
|
||||
)
|
||||
|
||||
format_conversion = self.getFormatConversion()
|
||||
@ -141,36 +168,54 @@ class SdrSource(object):
|
||||
start_sdr_command += " | " + format_conversion
|
||||
|
||||
nmux_bufcnt = nmux_bufsize = 0
|
||||
while nmux_bufsize < props["samp_rate"]/4: nmux_bufsize += 4096
|
||||
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6: nmux_bufcnt += 1
|
||||
while nmux_bufsize < props["samp_rate"] / 4:
|
||||
nmux_bufsize += 4096
|
||||
while nmux_bufsize * nmux_bufcnt < props["nmux_memory"] * 1e6:
|
||||
nmux_bufcnt += 1
|
||||
if nmux_bufcnt == 0 or nmux_bufsize == 0:
|
||||
logger.error("Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py")
|
||||
logger.error(
|
||||
"Error: nmux_bufsize or nmux_bufcnt is zero. These depend on nmux_memory and samp_rate options in config_webrx.py"
|
||||
)
|
||||
self.modificationLock.release()
|
||||
return
|
||||
logger.debug("nmux_bufsize = %d, nmux_bufcnt = %d" % (nmux_bufsize, nmux_bufcnt))
|
||||
cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (nmux_bufsize, nmux_bufcnt, self.port)
|
||||
cmd = start_sdr_command + " | nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" % (
|
||||
nmux_bufsize,
|
||||
nmux_bufcnt,
|
||||
self.port,
|
||||
)
|
||||
self.process = subprocess.Popen(cmd, shell=True, preexec_fn=os.setpgrp)
|
||||
logger.info("Started rtl source: " + cmd)
|
||||
|
||||
available = False
|
||||
|
||||
def wait_for_process_to_end():
|
||||
rc = self.process.wait()
|
||||
logger.debug("shut down with RC={0}".format(rc))
|
||||
self.monitor = None
|
||||
|
||||
self.monitor = threading.Thread(target = wait_for_process_to_end)
|
||||
self.monitor = threading.Thread(target=wait_for_process_to_end)
|
||||
self.monitor.start()
|
||||
|
||||
while True:
|
||||
retries = 1000
|
||||
while retries > 0:
|
||||
retries -= 1
|
||||
if self.monitor is None:
|
||||
break
|
||||
testsock = socket.socket()
|
||||
try:
|
||||
testsock.connect(("127.0.0.1", self.getPort()))
|
||||
testsock.close()
|
||||
available = True
|
||||
break
|
||||
except:
|
||||
time.sleep(0.1)
|
||||
|
||||
self.modificationLock.release()
|
||||
|
||||
if not available:
|
||||
raise SdrSourceException("rtl source failed to start up")
|
||||
|
||||
for c in self.clients:
|
||||
c.onSdrAvailable()
|
||||
|
||||
@ -200,6 +245,7 @@ class SdrSource(object):
|
||||
def addClient(self, c):
|
||||
self.clients.append(c)
|
||||
self.start()
|
||||
|
||||
def removeClient(self, c):
|
||||
try:
|
||||
self.clients.remove(c)
|
||||
@ -235,6 +281,7 @@ class RtlSdrSource(SdrSource):
|
||||
def getFormatConversion(self):
|
||||
return "csdr convert_u8_f"
|
||||
|
||||
|
||||
class HackrfSource(SdrSource):
|
||||
def getCommand(self):
|
||||
return "hackrf_transfer -s {samp_rate} -f {center_freq} -g {rf_gain} -l{lna_gain} -a{rf_amp} -r-"
|
||||
@ -242,39 +289,54 @@ class HackrfSource(SdrSource):
|
||||
def getFormatConversion(self):
|
||||
return "csdr convert_s8_f"
|
||||
|
||||
|
||||
class SdrplaySource(SdrSource):
|
||||
def getCommand(self):
|
||||
command = "rx_sdr -F CF32 -s {samp_rate} -f {center_freq} -p {ppm}"
|
||||
gainMap = { "rf_gain" : "RFGR", "if_gain" : "IFGR"}
|
||||
gains = [ "{0}={{{1}}}".format(gainMap[name], name) for (name, value) in self.rtlProps.collect("rf_gain", "if_gain").__dict__().items() if value is not None ]
|
||||
gainMap = {"rf_gain": "RFGR", "if_gain": "IFGR"}
|
||||
gains = [
|
||||
"{0}={{{1}}}".format(gainMap[name], name)
|
||||
for (name, value) in self.rtlProps.collect("rf_gain", "if_gain").__dict__().items()
|
||||
if value is not None
|
||||
]
|
||||
if gains:
|
||||
command += " -g {gains}".format(gains = ",".join(gains))
|
||||
command += " -g {gains}".format(gains=",".join(gains))
|
||||
if self.rtlProps["antenna"] is not None:
|
||||
command += " -a \"{antenna}\""
|
||||
command += ' -a "{antenna}"'
|
||||
command += " -"
|
||||
return command
|
||||
|
||||
def sleepOnRestart(self):
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
class AirspySource(SdrSource):
|
||||
def getCommand(self):
|
||||
frequency = self.props['center_freq'] / 1e6
|
||||
frequency = self.props["center_freq"] / 1e6
|
||||
command = "airspy_rx"
|
||||
command += " -f{0}".format(frequency)
|
||||
command += " -r /dev/stdout -a{samp_rate} -g {rf_gain}"
|
||||
return command
|
||||
|
||||
def getFormatConversion(self):
|
||||
return "csdr convert_s16_f"
|
||||
|
||||
|
||||
class SpectrumThread(csdr.output):
|
||||
def __init__(self, sdrSource):
|
||||
self.sdrSource = sdrSource
|
||||
super().__init__()
|
||||
|
||||
self.props = props = self.sdrSource.props.collect(
|
||||
"samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor", "fft_compression",
|
||||
"csdr_dynamic_bufsize", "csdr_print_bufsizes", "csdr_through"
|
||||
"samp_rate",
|
||||
"fft_size",
|
||||
"fft_fps",
|
||||
"fft_voverlap_factor",
|
||||
"fft_compression",
|
||||
"csdr_dynamic_bufsize",
|
||||
"csdr_print_bufsizes",
|
||||
"csdr_through",
|
||||
"temporary_directory",
|
||||
).defaults(PropertyManager.getSharedInstance())
|
||||
|
||||
self.dsp = dsp = csdr.dsp(self)
|
||||
@ -287,14 +349,19 @@ class SpectrumThread(csdr.output):
|
||||
fft_fps = props["fft_fps"]
|
||||
fft_voverlap_factor = props["fft_voverlap_factor"]
|
||||
|
||||
dsp.set_fft_averages(int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor))) if fft_voverlap_factor>0 else 0)
|
||||
dsp.set_fft_averages(
|
||||
int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor)))
|
||||
if fft_voverlap_factor > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
self.subscriptions = [
|
||||
props.getProperty("samp_rate").wire(dsp.set_samp_rate),
|
||||
props.getProperty("fft_size").wire(dsp.set_fft_size),
|
||||
props.getProperty("fft_fps").wire(dsp.set_fft_fps),
|
||||
props.getProperty("fft_compression").wire(dsp.set_fft_compression),
|
||||
props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages)
|
||||
props.getProperty("temporary_directory").wire(dsp.set_temporary_directory),
|
||||
props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages),
|
||||
]
|
||||
|
||||
set_fft_averages(None, None)
|
||||
@ -309,25 +376,15 @@ class SpectrumThread(csdr.output):
|
||||
if self.sdrSource.isAvailable():
|
||||
self.dsp.start()
|
||||
|
||||
def add_output(self, type, read_fn):
|
||||
if type != "audio":
|
||||
logger.error("unsupported output type received by FFT: %s", type)
|
||||
return
|
||||
def supports_type(self, t):
|
||||
return t == "audio"
|
||||
|
||||
def receive_output(self, type, read_fn):
|
||||
if self.props["csdr_dynamic_bufsize"]:
|
||||
read_fn(8) #dummy read to skip bufsize & preamble
|
||||
read_fn(8) # dummy read to skip bufsize & preamble
|
||||
logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1")
|
||||
|
||||
def pipe():
|
||||
run = True
|
||||
while run:
|
||||
data = read_fn()
|
||||
if len(data) == 0:
|
||||
run = False
|
||||
else:
|
||||
self.sdrSource.writeSpectrumData(data)
|
||||
|
||||
threading.Thread(target = pipe).start()
|
||||
threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start()
|
||||
|
||||
def stop(self):
|
||||
self.dsp.stop()
|
||||
@ -338,20 +395,36 @@ class SpectrumThread(csdr.output):
|
||||
|
||||
def onSdrAvailable(self):
|
||||
self.dsp.start()
|
||||
|
||||
def onSdrUnavailable(self):
|
||||
self.dsp.stop()
|
||||
|
||||
|
||||
class DspManager(csdr.output):
|
||||
def __init__(self, handler, sdrSource):
|
||||
self.handler = handler
|
||||
self.sdrSource = sdrSource
|
||||
self.metaParser = MetaParser(self.handler)
|
||||
self.wsjtParser = WsjtParser(self.handler)
|
||||
|
||||
self.localProps = self.sdrSource.getProps().collect(
|
||||
"audio_compression", "fft_compression", "digimodes_fft_size", "csdr_dynamic_bufsize",
|
||||
"csdr_print_bufsizes", "csdr_through", "digimodes_enable", "samp_rate", "digital_voice_unvoiced_quality",
|
||||
"dmr_filter"
|
||||
).defaults(PropertyManager.getSharedInstance())
|
||||
self.localProps = (
|
||||
self.sdrSource.getProps()
|
||||
.collect(
|
||||
"audio_compression",
|
||||
"fft_compression",
|
||||
"digimodes_fft_size",
|
||||
"csdr_dynamic_bufsize",
|
||||
"csdr_print_bufsizes",
|
||||
"csdr_through",
|
||||
"digimodes_enable",
|
||||
"samp_rate",
|
||||
"digital_voice_unvoiced_quality",
|
||||
"dmr_filter",
|
||||
"temporary_directory",
|
||||
"center_freq",
|
||||
)
|
||||
.defaults(PropertyManager.getSharedInstance())
|
||||
)
|
||||
|
||||
self.dsp = csdr.dsp(self)
|
||||
self.dsp.nc_port = self.sdrSource.getPort()
|
||||
@ -366,6 +439,9 @@ class DspManager(csdr.output):
|
||||
bpf[1] = cut
|
||||
self.dsp.set_bpf(*bpf)
|
||||
|
||||
def set_dial_freq(key, value):
|
||||
self.wsjtParser.setDialFrequency(self.localProps["center_freq"] + self.localProps["offset_freq"])
|
||||
|
||||
self.subscriptions = [
|
||||
self.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression),
|
||||
self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression),
|
||||
@ -378,28 +454,35 @@ class DspManager(csdr.output):
|
||||
self.localProps.getProperty("high_cut").wire(set_high_cut),
|
||||
self.localProps.getProperty("mod").wire(self.dsp.set_demodulator),
|
||||
self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality),
|
||||
self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter)
|
||||
self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter),
|
||||
self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory),
|
||||
self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq),
|
||||
]
|
||||
|
||||
self.dsp.set_offset_freq(0)
|
||||
self.dsp.set_bpf(-4000,4000)
|
||||
self.dsp.set_bpf(-4000, 4000)
|
||||
self.dsp.csdr_dynamic_bufsize = self.localProps["csdr_dynamic_bufsize"]
|
||||
self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"]
|
||||
self.dsp.csdr_through = self.localProps["csdr_through"]
|
||||
|
||||
if (self.localProps["digimodes_enable"]):
|
||||
if self.localProps["digimodes_enable"]:
|
||||
|
||||
def set_secondary_mod(mod):
|
||||
if mod == False: mod = None
|
||||
if mod == False:
|
||||
mod = None
|
||||
self.dsp.set_secondary_demodulator(mod)
|
||||
if mod is not None:
|
||||
self.handler.write_secondary_dsp_config({
|
||||
"secondary_fft_size":self.localProps["digimodes_fft_size"],
|
||||
"if_samp_rate":self.dsp.if_samp_rate(),
|
||||
"secondary_bw":self.dsp.secondary_bw()
|
||||
})
|
||||
self.handler.write_secondary_dsp_config(
|
||||
{
|
||||
"secondary_fft_size": self.localProps["digimodes_fft_size"],
|
||||
"if_samp_rate": self.dsp.if_samp_rate(),
|
||||
"secondary_bw": self.dsp.secondary_bw(),
|
||||
}
|
||||
)
|
||||
|
||||
self.subscriptions += [
|
||||
self.localProps.getProperty("secondary_mod").wire(set_secondary_mod),
|
||||
self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq)
|
||||
self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq),
|
||||
]
|
||||
|
||||
self.sdrSource.addClient(self)
|
||||
@ -410,30 +493,19 @@ class DspManager(csdr.output):
|
||||
if self.sdrSource.isAvailable():
|
||||
self.dsp.start()
|
||||
|
||||
def add_output(self, t, read_fn):
|
||||
def receive_output(self, t, read_fn):
|
||||
logger.debug("adding new output of type %s", t)
|
||||
writers = {
|
||||
"audio": self.handler.write_dsp_data,
|
||||
"smeter": self.handler.write_s_meter_level,
|
||||
"secondary_fft": self.handler.write_secondary_fft,
|
||||
"secondary_demod": self.handler.write_secondary_demod,
|
||||
"meta": self.metaParser.parse
|
||||
"meta": self.metaParser.parse,
|
||||
"wsjt_demod": self.wsjtParser.parse,
|
||||
}
|
||||
write = writers[t]
|
||||
|
||||
def pump(read, write):
|
||||
def copy():
|
||||
run = True
|
||||
while run:
|
||||
data = read()
|
||||
if data is None or (isinstance(data, bytes) and len(data) == 0):
|
||||
logger.warning("zero read on {0}".format(t))
|
||||
run = False
|
||||
else:
|
||||
write(data)
|
||||
return copy
|
||||
|
||||
threading.Thread(target=pump(read_fn, write)).start()
|
||||
threading.Thread(target=self.pump(read_fn, write)).start()
|
||||
|
||||
def stop(self):
|
||||
self.dsp.stop()
|
||||
@ -453,8 +525,10 @@ class DspManager(csdr.output):
|
||||
logger.debug("received onSdrUnavailable, shutting down DspSource")
|
||||
self.dsp.stop()
|
||||
|
||||
|
||||
class CpuUsageThread(threading.Thread):
|
||||
sharedInstance = None
|
||||
|
||||
@staticmethod
|
||||
def getSharedInstance():
|
||||
if CpuUsageThread.sharedInstance is None:
|
||||
@ -482,21 +556,23 @@ class CpuUsageThread(threading.Thread):
|
||||
|
||||
def get_cpu_usage(self):
|
||||
try:
|
||||
f = open("/proc/stat","r")
|
||||
f = open("/proc/stat", "r")
|
||||
except:
|
||||
return 0 #Workaround, possibly we're on a Mac
|
||||
return 0 # Workaround, possibly we're on a Mac
|
||||
line = ""
|
||||
while not "cpu " in line: line=f.readline()
|
||||
while not "cpu " in line:
|
||||
line = f.readline()
|
||||
f.close()
|
||||
spl = line.split(" ")
|
||||
worktime = int(spl[2]) + int(spl[3]) + int(spl[4])
|
||||
idletime = int(spl[5])
|
||||
dworktime = (worktime - self.last_worktime)
|
||||
didletime = (idletime - self.last_idletime)
|
||||
rate = float(dworktime) / (didletime+dworktime)
|
||||
dworktime = worktime - self.last_worktime
|
||||
didletime = idletime - self.last_idletime
|
||||
rate = float(dworktime) / (didletime + dworktime)
|
||||
self.last_worktime = worktime
|
||||
self.last_idletime = idletime
|
||||
if (self.last_worktime==0): return 0
|
||||
if self.last_worktime == 0:
|
||||
return 0
|
||||
return rate
|
||||
|
||||
def add_client(self, c):
|
||||
@ -514,11 +590,14 @@ class CpuUsageThread(threading.Thread):
|
||||
CpuUsageThread.sharedInstance = None
|
||||
self.doRun = False
|
||||
|
||||
|
||||
class TooManyClientsException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ClientRegistry(object):
|
||||
sharedInstance = None
|
||||
|
||||
@staticmethod
|
||||
def getSharedInstance():
|
||||
if ClientRegistry.sharedInstance is None:
|
||||
@ -549,4 +628,4 @@ class ClientRegistry(object):
|
||||
self.clients.remove(client)
|
||||
except ValueError:
|
||||
pass
|
||||
self.broadcast()
|
||||
self.broadcast()
|
||||
|
@ -1 +1 @@
|
||||
openwebrx_version = "v0.18"
|
||||
openwebrx_version = "v0.18"
|
||||
|
@ -3,48 +3,76 @@ import hashlib
|
||||
import json
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketConnection(object):
|
||||
def __init__(self, handler, messageHandler):
|
||||
self.handler = handler
|
||||
self.messageHandler = messageHandler
|
||||
my_headers = self.handler.headers.items()
|
||||
my_header_keys = list(map(lambda x:x[0],my_headers))
|
||||
h_key_exists = lambda x:my_header_keys.count(x)
|
||||
h_value = lambda x:my_headers[my_header_keys.index(x)][1]
|
||||
if (not h_key_exists("Upgrade")) or not (h_value("Upgrade")=="websocket") or (not h_key_exists("Sec-WebSocket-Key")):
|
||||
my_header_keys = list(map(lambda x: x[0], my_headers))
|
||||
h_key_exists = lambda x: my_header_keys.count(x)
|
||||
h_value = lambda x: my_headers[my_header_keys.index(x)][1]
|
||||
if (
|
||||
(not h_key_exists("Upgrade"))
|
||||
or not (h_value("Upgrade") == "websocket")
|
||||
or (not h_key_exists("Sec-WebSocket-Key"))
|
||||
):
|
||||
raise WebSocketException
|
||||
ws_key = h_value("Sec-WebSocket-Key")
|
||||
shakey = hashlib.sha1()
|
||||
shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key = ws_key).encode())
|
||||
shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key=ws_key).encode())
|
||||
ws_key_toreturn = base64.b64encode(shakey.digest())
|
||||
self.handler.wfile.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format(ws_key_toreturn.decode()).encode())
|
||||
self.handler.wfile.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format(
|
||||
ws_key_toreturn.decode()
|
||||
).encode()
|
||||
)
|
||||
|
||||
def get_header(self, size, opcode):
|
||||
ws_first_byte = 0b10000000 | (opcode & 0x0F)
|
||||
if (size > 125):
|
||||
return bytes([ws_first_byte, 126, (size>>8) & 0xff, size & 0xff])
|
||||
if size > 2 ** 16 - 1:
|
||||
# frame size can be increased up to 2^64 by setting the size to 127
|
||||
# anything beyond that would need to be segmented into frames. i don't really think we'll need more.
|
||||
return bytes(
|
||||
[
|
||||
ws_first_byte,
|
||||
127,
|
||||
(size >> 56) & 0xFF,
|
||||
(size >> 48) & 0xFF,
|
||||
(size >> 40) & 0xFF,
|
||||
(size >> 32) & 0xFF,
|
||||
(size >> 24) & 0xFF,
|
||||
(size >> 16) & 0xFF,
|
||||
(size >> 8) & 0xFF,
|
||||
size & 0xFF,
|
||||
]
|
||||
)
|
||||
elif size > 125:
|
||||
# up to 2^16 can be sent using the extended payload size field by putting the size to 126
|
||||
return bytes([ws_first_byte, 126, (size >> 8) & 0xFF, size & 0xFF])
|
||||
else:
|
||||
# 256 bytes binary message in a single unmasked frame
|
||||
# 125 bytes binary message in a single unmasked frame
|
||||
return bytes([ws_first_byte, size])
|
||||
|
||||
def send(self, data):
|
||||
# convenience
|
||||
if (type(data) == dict):
|
||||
if type(data) == dict:
|
||||
# allow_nan = False disallows NaN and Infinty to be encoded. Browser JSON will not parse them anyway.
|
||||
data = json.dumps(data, allow_nan = False)
|
||||
data = json.dumps(data, allow_nan=False)
|
||||
|
||||
# string-type messages are sent as text frames
|
||||
if (type(data) == str):
|
||||
if type(data) == str:
|
||||
header = self.get_header(len(data), 1)
|
||||
data_to_send = header + data.encode('utf-8')
|
||||
data_to_send = header + data.encode("utf-8")
|
||||
# anything else as binary
|
||||
else:
|
||||
header = self.get_header(len(data), 2)
|
||||
data_to_send = header + data
|
||||
written = self.handler.wfile.write(data_to_send)
|
||||
if (written != len(data_to_send)):
|
||||
if written != len(data_to_send):
|
||||
logger.error("incomplete write! closing socket!")
|
||||
self.close()
|
||||
else:
|
||||
@ -52,25 +80,25 @@ class WebSocketConnection(object):
|
||||
|
||||
def read_loop(self):
|
||||
open = True
|
||||
while (open):
|
||||
while open:
|
||||
header = self.handler.rfile.read(2)
|
||||
opcode = header[0] & 0x0F
|
||||
length = header[1] & 0x7F
|
||||
mask = (header[1] & 0x80) >> 7
|
||||
if (length == 126):
|
||||
if length == 126:
|
||||
header = self.handler.rfile.read(2)
|
||||
length = (header[0] << 8) + header[1]
|
||||
if (mask):
|
||||
if mask:
|
||||
masking_key = self.handler.rfile.read(4)
|
||||
data = self.handler.rfile.read(length)
|
||||
if (mask):
|
||||
if mask:
|
||||
data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)])
|
||||
if (opcode == 1):
|
||||
message = data.decode('utf-8')
|
||||
if opcode == 1:
|
||||
message = data.decode("utf-8")
|
||||
self.messageHandler.handleTextMessage(self, message)
|
||||
elif (opcode == 2):
|
||||
elif opcode == 2:
|
||||
self.messageHandler.handleBinaryMessage(self, data)
|
||||
elif (opcode == 8):
|
||||
elif opcode == 8:
|
||||
open = False
|
||||
self.messageHandler.handleClose(self)
|
||||
else:
|
||||
|
277
owrx/wsjt.py
Normal file
277
owrx/wsjt.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user