Merge branch 'develop' into packet
This commit is contained in:
		
							
								
								
									
										97
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										97
									
								
								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) | ||||
|  | ||||
|  | ||||
| **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 | ||||
|   } | ||||
| ] | ||||
							
								
								
									
										132
									
								
								config_webrx.py
									
									
									
									
									
								
							
							
						
						
									
										132
									
								
								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. | ||||
| # 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. | ||||
|  | ||||
| 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"] | ||||
|   | ||||
							
								
								
									
										488
									
								
								csdr.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										488
									
								
								csdr.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -21,32 +21,55 @@ 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.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 | ||||
| @@ -67,101 +90,161 @@ 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": | ||||
| @@ -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("[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 | ||||
|         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())), | ||||
|             ) | ||||
|  | ||||
|         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> | ||||
|  | ||||
							
								
								
									
										22
									
								
								openwebrx.py
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										22
									
								
								openwebrx.py
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,3 +1,5 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| from http.server import HTTPServer | ||||
| from owrx.http import RequestHandler | ||||
| from owrx.config import PropertyManager | ||||
| @@ -5,9 +7,11 @@ 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() | ||||
|   | ||||
							
								
								
									
										135
									
								
								owrx/feature.py
									
									
									
									
									
								
							
							
						
						
									
										135
									
								
								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,38 +57,77 @@ 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: | ||||
|             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)): | ||||
|                 passed = passed and getattr(self, methodname)() | ||||
|             return getattr(self, methodname) | ||||
|         return None | ||||
|  | ||||
|     def has_requirement(self, requirement): | ||||
|         method = self._get_requirement_method(requirement) | ||||
|         if method is not None: | ||||
|             return method() | ||||
|         else: | ||||
|             logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) | ||||
|         return passed | ||||
|         return False | ||||
|  | ||||
|     def get_requirement_description(self, requirement): | ||||
|         return inspect.getdoc(self._get_requirement_method(requirement)) | ||||
|  | ||||
|     def command_is_runnable(self, command): | ||||
|         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") | ||||
|  | ||||
|     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 | ||||
| @@ -76,8 +138,8 @@ class FeatureDetector(object): | ||||
|          cmake .. -DINSTALL_UDEV_RULES=ON | ||||
|          make | ||||
|          sudo make install | ||||
|         ``` | ||||
|         """ | ||||
|     def has_hackrf_transfer(self): | ||||
|         # 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 | ||||
|  | ||||
|     def has_digiham(self): | ||||
|         """ | ||||
|     To use DMR and YSF, the digiham package is required. You can find the package and installation instructions here: | ||||
|     https://github.com/jketterl/digiham | ||||
|         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. | ||||
|         """ | ||||
|     def has_digiham(self): | ||||
|         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_, | ||||
|  | ||||
|         return reduce( | ||||
|             and_, | ||||
|             map( | ||||
|                 check_digiham_version, | ||||
|                           ["rrc_filter", "ysf_decoder", "dmr_decoder", "mbe_synthesizer", "gfsk_demodulator", | ||||
|                            "digitalvoice_filter"] | ||||
|                 [ | ||||
|                     "rrc_filter", | ||||
|                     "ysf_decoder", | ||||
|                     "dmr_decoder", | ||||
|                     "mbe_synthesizer", | ||||
|                     "gfsk_demodulator", | ||||
|                     "digitalvoice_filter", | ||||
|                 ], | ||||
|             ), | ||||
|                       True) | ||||
|             True, | ||||
|         ) | ||||
|  | ||||
|     def has_dsd(self): | ||||
|         """ | ||||
|         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 | ||||
							
								
								
									
										237
									
								
								owrx/source.py
									
									
									
									
									
								
							
							
						
						
									
										237
									
								
								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: | ||||
|   | ||||
| @@ -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) | ||||
		Reference in New Issue
	
	Block a user
	 Jakob Ketterl
					Jakob Ketterl